diff --git a/.gitignore b/.gitignore index 527afa6..fc7dba5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ DSFFloatLabelledTextControls.xcodeproj/xcuserdata xcschememanagement.plist .swiftpm Demos/DSFFloatLabelledTextControls.xcodeproj/xcuserdata +gyb.cpython-310.pyc diff --git a/Sources/DSFFloatLabelledTextField/DSFFloatLabelledSecureTextField.swift b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledSecureTextField.swift index b2aa182..7685f33 100644 --- a/Sources/DSFFloatLabelledTextField/DSFFloatLabelledSecureTextField.swift +++ b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledSecureTextField.swift @@ -1,5 +1,7 @@ +// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb ** // // DSFFloatLabelledSecureTextField.swift +// (generated using tools/regenerate-sources.sh) // // Copyright © 2023 Darren Ford. All rights reserved. // @@ -167,11 +169,6 @@ import AppKit self.setup() } - open override func awakeFromNib() { - super.awakeFromNib() - - } - open override func viewDidMoveToWindow() { super.viewDidMoveToWindow() diff --git a/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift index f7369ed..4913b03 100644 --- a/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift +++ b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift @@ -1,5 +1,7 @@ +// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb ** // // DSFFloatLabelledTextField.swift +// (generated using tools/regenerate-sources.sh) // // Copyright © 2023 Darren Ford. All rights reserved. // @@ -141,7 +143,6 @@ import AppKit customCell.alignment = self.alignment customCell.formatter = self.formatter customCell.topOffset = self.placeholderHeight - self.cell = customCell } diff --git a/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb new file mode 100644 index 0000000..7ad34cf --- /dev/null +++ b/Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb @@ -0,0 +1,448 @@ +// ** Automatically generated from DSFFloatLabelledTextField.swift.gyb ** +// +// DSFFloatLabelled${field_type}Field.swift +// (generated using tools/regenerate-sources.sh) +// +// Copyright © 2023 Darren Ford. All rights reserved. +// +// MIT license +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +// + +import AppKit + +/// DSFFloatLabelledTextField delegate protocol +@objc public protocol DSFFloatLabelled${field_type}FieldDelegate: NSObjectProtocol { + /// Called when the label is shown or hidden + @objc optional func floatLabelledTextField(_ field: DSFFloatLabelled${field_type}Field, didShowFloatingLabel didShow: Bool) + /// Called when the field becomes or loses first responder status + @objc optional func floatLabelledTextField(_ field: DSFFloatLabelled${field_type}Field, didFocus: Bool) + /// Called when the content of the field changes + @objc optional func floatLabelledTextFieldContentChanged(_ field: DSFFloatLabelled${field_type}Field) +} + +/// An NS${field_type}Field that implements the Float Label Pattern +@IBDesignable open class DSFFloatLabelled${field_type}Field: NS${field_type}Field { + + /// Optional delegate to provide callbacks for the floating label state. + /// + /// This delegate can be set via Interface Builder or programatically + @IBOutlet @objc public weak var floatLabelDelegate: DSFFloatLabelled${field_type}FieldDelegate? + + /// The size (in pt) of the floating label text + @IBInspectable public var placeholderTextSize: CGFloat = NSFont.smallSystemFontSize { + didSet { + self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize) + self.reconfigureControl() + } + } + + /// Spacing between the floating label and the text field text + @IBInspectable public var placeholderSpacing: CGFloat = 0.0 { + didSet { + self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize) + self.reconfigureControl() + } + } + + // Override so that we can notify when the developer changes the text programatically too + open override var stringValue: String { + get { + return super.stringValue + } + set { + super.stringValue = newValue + NotificationCenter.default.post(name: NSControl.textDidChangeNotification, object: self) + } + } + + // Override so that we can update when the developer changes the placeholder string + open override var placeholderString: String? { + get { + return super.placeholderString + } + set { + super.placeholderString = newValue + self.floatingLabel.stringValue = newValue ?? "" + } + } + + /// Floating label + private let floatingLabel = NSTextField() + + /// Is the label currently showing + private var isShowing: Bool = false { + didSet { + self.floatLabelDelegate?.floatLabelledTextField?(self, didShowFloatingLabel: self.isShowing) + } + } + + /// Constraint to tie the label to the top of the control + private var floatingTop: NSLayoutConstraint? + + /// Height of the control + private var heightConstraint: NSLayoutConstraint? + + /// Observers for the font and placeholder text + private var fontObserver: NSKeyValueObservation? + private var placeholderObserver: NSKeyValueObservation? + + /// Returns the height of the placeholder text + var placeholderHeight: CGFloat { + let layoutManager = NSLayoutManager() + return layoutManager.defaultLineHeight(for: self.floatingLabel.font!) + 1 + } + + /// Returns the height of the primary (editable) text + private var textHeight: CGFloat { + let layoutManager = NSLayoutManager() + return layoutManager.defaultLineHeight(for: self.font!) + 1 + } + + /// Returns the total height of the control given the font settings + private var controlHeight: CGFloat { + return self.textHeight + self.placeholderSpacing + self.placeholderHeight + } + + open override var intrinsicContentSize: NSSize { + var sz = super.intrinsicContentSize + sz.height = self.controlHeight + return sz + } + + func configureCell() { + let customCell = DSFFloatLabelled${field_type}FieldCell() + customCell.isEditable = true + customCell.wraps = false + customCell.usesSingleLineMode = true + customCell.placeholderString = self.placeholderString + customCell.title = self.stringValue + customCell.font = self.font + customCell.isBordered = self.isBordered + customCell.isBezeled = self.isBezeled + customCell.bezelStyle = self.bezelStyle + customCell.isScrollable = true + customCell.isContinuous = self.isContinuous + customCell.alignment = self.alignment + customCell.formatter = self.formatter + customCell.topOffset = self.placeholderHeight + self.cell = customCell + } + + func setTopOffset(_ value: CGFloat) { + guard var f = self.cell as? DSFFloatLabelledTextFieldCellProtocol else { + return + } + f.topOffset = value + } + + /// Set the fonts to be used in the control + open func setFonts(primary: NSFont, secondary: NSFont) { + self.floatingLabel.font = secondary + self.font = primary + } + + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.setup() + } + + required public init?(coder: NSCoder) { + super.init(coder: coder) + self.setup() + } + + open override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + // When we're added to the view, make sure that the floating label alignment matches us + // (as the user might have changed the alignment BEFORE adding to the superview + self.floatingLabel.alignment = self.alignment + } + + private func setup() { + // Setup the common elements of the control + self.commonSetup() + + // Configure a default text cell + self.configureCell() + + // Listen to changes in the primary font so we can reconfigure to match + self.fontObserver = self.observe(\.font, options: [.new]) { [weak self] _, _ in + self?.reconfigureControl() + } + + // Listen to changes in the placeholder text so we can reflect it in the floater + self.placeholderObserver = self.observe(\.placeholderString, options: [.new]) { [weak self] _, _ in + guard let `self` = self else { return } + self.floatingLabel.stringValue = self.placeholderString! + self.reconfigureControl() + } + } + + /// Build the floating label + private func createFloatingLabel() { + if self.floatingLabel.superview == nil { + self.addSubview(self.floatingLabel) + } + + self.floatingLabel.wantsLayer = true + self.floatingLabel.isEditable = false + self.floatingLabel.isSelectable = false + self.floatingLabel.isEnabled = true + self.floatingLabel.isBezeled = false + self.floatingLabel.isBordered = false + self.floatingLabel.translatesAutoresizingMaskIntoConstraints = false + self.floatingLabel.font = NSFont.systemFont(ofSize: self.placeholderTextSize) + self.floatingLabel.textColor = NSColor.placeholderTextColor + self.floatingLabel.stringValue = self.placeholderString ?? "" + self.floatingLabel.alphaValue = 0.0 + self.floatingLabel.alignment = self.alignment + self.floatingLabel.drawsBackground = false + + self.floatingTop = NSLayoutConstraint( + item: self.floatingLabel, attribute: .top, + relatedBy: .equal, + toItem: self, attribute: .top, + multiplier: 1.0, constant: 10 + ) + self.addConstraint(self.floatingTop!) + + var x = NSLayoutConstraint( + item: self.floatingLabel, attribute: .leading, + relatedBy: .equal, + toItem: self, attribute: .leading, + multiplier: 1.0, constant: self.isBezeled ? 4 : 0 + ) + self.addConstraint(x) + + x = NSLayoutConstraint( + item: self.floatingLabel, attribute: .trailing, + relatedBy: .equal, + toItem: self, attribute: .trailing, + multiplier: 1.0, constant: self.isBezeled ? -4 : 0 + ) + self.addConstraint(x) + + self.floatingLabel.setContentHuggingPriority(NSLayoutConstraint.Priority(10), for: .horizontal) + self.floatingLabel.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(10), for: .horizontal) + } + + private func commonSetup() { + self.wantsLayer = true + self.translatesAutoresizingMaskIntoConstraints = false + self.usesSingleLineMode = true + self.delegate = self + + // Default to natural layout + self.alignment = .natural + + self.createFloatingLabel() + + self.heightConstraint = NSLayoutConstraint( + item: self, attribute: .height, + relatedBy: .equal, + toItem: nil, attribute: .notAnAttribute, + multiplier: 1.0, constant: self.controlHeight + ) + self.addConstraint(self.heightConstraint!) + + // If the field already has text, make sure the placeholder is shown + if self.stringValue.count > 0 { + self.showPlaceholder(animated: false) + } + } + + /// Change the layout if any changes occur + private func reconfigureControl() { + if self.isCurrentFocus() { + /// If we are currently editing, then finish before changing. + self.window?.endEditing(for: nil) + } + self.expandFrame() + } + + /// Rebuild the frame of the text field to match the new settings + private func expandFrame() { + self.heightConstraint?.constant = self.controlHeight + self.setTopOffset(self.placeholderHeight + self.placeholderSpacing) + self.needsLayout = true + } + + open override func layout() { + super.layout() + + self.setTopOffset(self.placeholderHeight + self.placeholderSpacing) + self.needsLayout = true + } + +} + +// MARK: - Focus and editing + +extension DSFFloatLabelled${field_type}Field: NSTextFieldDelegate { + + // Change the floating label color to represent active state + private func setFloatingLabelActive(_ active: Bool) { + if active { + self.floatingLabel.textColor = NSColor.simulatedAccentColor + } + else { + self.floatingLabel.textColor = NSColor.placeholderTextColor + } + + self.floatLabelDelegate?.floatLabelledTextField?(self, didFocus: active) + } + + public func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { + return + } + + if field.stringValue.count > 0, !self.isShowing { + self.showPlaceholder(animated: true) + } + else if field.stringValue.count == 0, self.isShowing { + self.hidePlaceholder() + } + + self.floatLabelDelegate?.floatLabelledTextFieldContentChanged?(self) + } + + open override func becomeFirstResponder() -> Bool { + let becomeResult = super.becomeFirstResponder() + if becomeResult, self.isCurrentFocus() { + // Set the color of the 'label' to match the current focus color. + // We need to perform this on the main thread after the current set of notifications are processed + // Why? We (occasionally) receive a 'controlTextDidEndEditing' message AFTER we receive a + // 'becomeFirstResponder'. I've read that this is related to the text field automatically selecting + // text when taking focus, but I haven't been able to verify this in any useful manner. + DispatchQueue.main.async { [weak self] in + if let `self` = self { + self.setFloatingLabelActive(true) + } + } + } + return becomeResult + } + + open func controlTextDidEndEditing(_: Notification) { + // When we lose focus, set the label color back to the placeholder color + self.setFloatingLabelActive(false) + } + + /// Does our text field currently have input focus? + private func isCurrentFocus() -> Bool { + // 1. Get the window's first responder + // 2. Check is has an active field editor + // 3. Is the delegate of the field editor us? + if let fr = self.window?.firstResponder as? NSTextView, + self.window?.fieldEditor(false, for: nil) != nil, + fr.delegate === self { + return true + } + return false + } +} + +// MARK: - Animations + +extension DSFFloatLabelled${field_type}Field { + + /// Duration of the fade in/out of the secondary label + private var animationDuration: TimeInterval { + return shouldAnimate() ? 0.4 : 0.0 + } + + /// Returns true if the system is NOT set to reduce motion (accessibility settings) + private func shouldAnimate() -> Bool { + if #available(OSX 10.12, *) { + return !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } else { + // Fallback on earlier versions. Just animate + return true + } + } + + private func showPlaceholder(animated: Bool) { + self.isShowing = true + if animated { + NSAnimationContext.runAnimationGroup({ [weak self] context in + guard let `self` = self else { return } + context.allowsImplicitAnimation = true + context.duration = self.animationDuration + self.floatingTop?.constant = 0 + self.floatingLabel.alphaValue = 1.0 + self.layoutSubtreeIfNeeded() + }, completionHandler: { + // + }) + } + else { + self.setFloatingLabelActive(false) + self.floatingTop?.constant = 0 + self.floatingLabel.alphaValue = 1.0 + } + } + + private func hidePlaceholder() { + self.isShowing = false + let duration = self.animationDuration + NSAnimationContext.runAnimationGroup({ [weak self] context in + guard let `self` = self else { return } + context.allowsImplicitAnimation = true + context.duration = duration + self.floatingTop?.constant = self.textHeight / 1.5 + self.floatingLabel.alphaValue = 0.0 + self.layoutSubtreeIfNeeded() + }, completionHandler: { + // + }) + } +} + +// MARK: - Cell definition + +private class DSFFloatLabelled${field_type}FieldCell: NS${field_type}FieldCell, DSFFloatLabelledTextFieldCellProtocol { + var topOffset: CGFloat = 0 + + private func offset() -> CGFloat { + return self.topOffset - (self.isBezeled ? 5 : 1) + } + + override func titleRect(forBounds rect: NSRect) -> NSRect { + return NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height) + } + + override func edit(withFrame rect: NSRect, in controlView: NSView, editor textObj: NSText, delegate: Any?, event: NSEvent?) { + let insetRect = NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height) + super.edit(withFrame: insetRect, in: controlView, editor: textObj, delegate: delegate, event: event) + } + + override func select(withFrame rect: NSRect, in controlView: NSView, editor textObj: NSText, delegate: Any?, start selStart: Int, length selLength: Int) { + let insetRect = NSRect(x: rect.origin.x, y: rect.origin.y + self.offset(), width: rect.width, height: rect.height) + super.select(withFrame: insetRect, in: controlView, editor: textObj, delegate: delegate, start: selStart, length: selLength) + } + + override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + let insetRect = NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y + self.offset(), width: cellFrame.width, height: cellFrame.height) + super.drawInterior(withFrame: insetRect, in: controlView) + } +} diff --git a/tools/gyb b/tools/gyb new file mode 100644 index 0000000..8206bd8 --- /dev/null +++ b/tools/gyb @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +import gyb +gyb.main() diff --git a/tools/gyb.py b/tools/gyb.py new file mode 100644 index 0000000..7b570ac --- /dev/null +++ b/tools/gyb.py @@ -0,0 +1,1258 @@ +#!/usr/bin/env python3 +# GYB: Generate Your Boilerplate (improved names welcome; at least +# this one's short). See -h output for instructions + +import io +import os +import re +import sys +import textwrap +import tokenize +from bisect import bisect +from io import StringIO + + +def get_line_starts(s): + """Return a list containing the start index of each line in s. + + The list also contains a sentinel index for the end of the string, + so there will be one more element in the list than there are lines + in the string + """ + starts = [0] + + for line in s.split('\n'): + starts.append(starts[-1] + len(line) + 1) + + starts[-1] -= 1 + return starts + + +def strip_trailing_nl(s): + """If s ends with a newline, drop it; else return s intact""" + return s[:-1] if s.endswith('\n') else s + + +def split_lines(s): + """Split s into a list of lines, each of which has a trailing newline + + If the lines are later concatenated, the result is s, possibly + with a single appended newline. + """ + return [line + '\n' for line in s.split('\n')] + + +# text on a line up to the first '$$', '${', or '%%' +literalText = r'(?: [^$\n%] | \$(?![${]) | %(?!%) )*' + +# The part of an '%end' line that follows the '%' sign +linesClose = r'[\ \t]* end [\ \t]* (?: \# .* )? $' + +# Note: Where "# Absorb" appears below, the regexp attempts to eat up +# through the end of ${...} and %{...}% constructs. In reality we +# handle this with the Python tokenizer, which avoids mis-detections +# due to nesting, comments and strings. This extra absorption in the +# regexp facilitates testing the regexp on its own, by preventing the +# interior of some of these constructs from being treated as literal +# text. +tokenize_re = re.compile( + r''' +# %-lines and %{...}-blocks + # \n? # absorb one preceding newline + ^ + (?: + (?P + (?P<_indent> [\ \t]* % (?! [{%] ) [\ \t]* ) (?! [\ \t] | ''' + + linesClose + r''' ) .* + ( \n (?P=_indent) (?! ''' + linesClose + r''' ) .* ) * + ) + | (?P [\ \t]* % [ \t]* ''' + linesClose + r''' ) + | [\ \t]* (?P %\{ ) + (?: [^}]| \} (?!%) )* \}% # Absorb + ) + \n? # absorb one trailing newline + +# Substitutions +| (?P \$\{ ) + [^}]* \} # Absorb + +# %% and $$ are literal % and $ respectively +| (?P[$%]) (?P=symbol) + +# Literal text +| (?P ''' + literalText + r''' + (?: + # newline that doesn't precede space+% + (?: \n (?! [\ \t]* %[^%] ) ) + ''' + literalText + r''' + )* + \n? + ) +''', re.VERBOSE | re.MULTILINE) + +gyb_block_close = re.compile(r'\}%[ \t]*\n?') + + +def token_pos_to_index(token_pos, start, line_starts): + """Translate a tokenize (line, column) pair into an absolute + position in source text given the position where we started + tokenizing and a list that maps lines onto their starting + character indexes. + """ + relative_token_line_plus1, token_col = token_pos + + # line number where we started tokenizing + start_line_num = bisect(line_starts, start) - 1 + + # line number of the token in the whole text + abs_token_line = relative_token_line_plus1 - 1 + start_line_num + + # if found in the first line, adjust the end column to account + # for the extra text + if relative_token_line_plus1 == 1: + token_col += start - line_starts[start_line_num] + + # Sometimes tokenizer errors report a line beyond the last one + if abs_token_line >= len(line_starts): + return line_starts[-1] + + return line_starts[abs_token_line] + token_col + + +def tokenize_python_to_unmatched_close_curly(source_text, start, line_starts): + """Apply Python's tokenize to source_text starting at index start + while matching open and close curly braces. When an unmatched + close curly brace is found, return its index. If not found, + return len(source_text). If there's a tokenization error, return + the position of the error. + """ + stream = StringIO(source_text) + stream.seek(start) + nesting = 0 + + try: + for kind, text, token_start, token_end, line_text \ + in tokenize.generate_tokens(stream.readline): + + if text == '{': + nesting += 1 + elif text == '}': + nesting -= 1 + if nesting < 0: + return token_pos_to_index(token_start, start, line_starts) + + except tokenize.TokenError as error: + (message, error_pos) = error.args + return token_pos_to_index(error_pos, start, line_starts) + + return len(source_text) + + +def tokenize_template(template_text): + r"""Given the text of a template, returns an iterator over + (tokenType, token, match) tuples. + + **Note**: this is template syntax tokenization, not Python + tokenization. + + When a non-literal token is matched, a client may call + iter.send(pos) on the iterator to reset the position in + template_text at which scanning will resume. + + This function provides a base level of tokenization which is + then refined by ParseContext.token_generator. + + >>> from pprint import * + >>> pprint(list((kind, text) for kind, text, _ in tokenize_template( + ... '%for x in range(10):\n% print x\n%end\njuicebox'))) + [('gybLines', '%for x in range(10):\n% print x'), + ('gybLinesClose', '%end'), + ('literal', 'juicebox')] + + >>> pprint(list((kind, text) for kind, text, _ in tokenize_template( + ... '''Nothing + ... % if x: + ... % for i in range(3): + ... ${i} + ... % end + ... % else: + ... THIS SHOULD NOT APPEAR IN THE OUTPUT + ... '''))) + [('literal', 'Nothing\n'), + ('gybLines', '% if x:\n% for i in range(3):'), + ('substitutionOpen', '${'), + ('literal', '\n'), + ('gybLinesClose', '% end'), + ('gybLines', '% else:'), + ('literal', 'THIS SHOULD NOT APPEAR IN THE OUTPUT\n')] + + >>> for kind, text, _ in tokenize_template(''' + ... This is $some$ literal stuff containing a ${substitution} + ... followed by a %{...} block: + ... %{ + ... # Python code + ... }% + ... and here $${are} some %-lines: + ... % x = 1 + ... % y = 2 + ... % if z == 3: + ... % print '${hello}' + ... % end + ... % for x in zz: + ... % print x + ... % # different indentation + ... % twice + ... and some lines that literally start with a %% token + ... %% first line + ... %% second line + ... '''): + ... print((kind, text.strip().split('\n',1)[0])) + ('literal', 'This is $some$ literal stuff containing a') + ('substitutionOpen', '${') + ('literal', 'followed by a %{...} block:') + ('gybBlockOpen', '%{') + ('literal', 'and here ${are} some %-lines:') + ('gybLines', '% x = 1') + ('gybLinesClose', '% end') + ('gybLines', '% for x in zz:') + ('gybLines', '% # different indentation') + ('gybLines', '% twice') + ('literal', 'and some lines that literally start with a % token') + """ + pos = 0 + end = len(template_text) + + saved_literal = [] + literal_first_match = None + + while pos < end: + m = tokenize_re.match(template_text, pos, end) + + # pull out the one matched key (ignoring internal patterns starting + # with _) + ((kind, text), ) = ( + (kind, text) for (kind, text) in m.groupdict().items() + if text is not None and kind[0] != '_') + + if kind in ('literal', 'symbol'): + if len(saved_literal) == 0: + literal_first_match = m + # literals and symbols get batched together + saved_literal.append(text) + pos = None + else: + # found a non-literal. First yield any literal we've accumulated + if saved_literal != []: + yield 'literal', ''.join(saved_literal), literal_first_match + saved_literal = [] + + # Then yield the thing we found. If we get a reply, it's + # the place to resume tokenizing + pos = yield kind, text, m + + # If we were not sent a new position by our client, resume + # tokenizing at the end of this match. + if pos is None: + pos = m.end(0) + else: + # Client is not yet ready to process next token + yield + + if saved_literal != []: + yield 'literal', ''.join(saved_literal), literal_first_match + + +def split_gyb_lines(source_lines): + r"""Return a list of lines at which to split the incoming source + + These positions represent the beginnings of python line groups that + will require a matching %end construct if they are to be closed. + + >>> src = split_lines('''\ + ... if x: + ... print x + ... if y: # trailing comment + ... print z + ... if z: # another comment\ + ... ''') + >>> s = split_gyb_lines(src) + >>> len(s) + 2 + >>> src[s[0]] + ' print z\n' + >>> s[1] - len(src) + 0 + + >>> src = split_lines('''\ + ... if x: + ... if y: print 1 + ... if z: + ... print 2 + ... pass\ + ... ''') + >>> s = split_gyb_lines(src) + >>> len(s) + 1 + >>> src[s[0]] + ' if y: print 1\n' + + >>> src = split_lines('''\ + ... if x: + ... if y: + ... print 1 + ... print 2 + ... ''') + >>> s = split_gyb_lines(src) + >>> len(s) + 2 + >>> src[s[0]] + ' if y:\n' + >>> src[s[1]] + ' print 1\n' + """ + last_token_text, last_token_kind = None, None + unmatched_indents = [] + + dedents = 0 + try: + for token_kind, token_text, token_start, \ + (token_end_line, token_end_col), line_text \ + in tokenize.generate_tokens(lambda i=iter(source_lines): + next(i)): + + if token_kind in (tokenize.COMMENT, tokenize.ENDMARKER): + continue + + if token_text == '\n' and last_token_text == ':': + unmatched_indents.append(token_end_line) + + # The tokenizer appends dedents at EOF; don't consider + # those as matching indentations. Instead just save them + # up... + if last_token_kind == tokenize.DEDENT: + dedents += 1 + # And count them later, when we see something real. + if token_kind != tokenize.DEDENT and dedents > 0: + unmatched_indents = unmatched_indents[:-dedents] + dedents = 0 + + last_token_text, last_token_kind = token_text, token_kind + + except tokenize.TokenError: + # Let the later compile() call report the error + return [] + + if last_token_text == ':': + unmatched_indents.append(len(source_lines)) + + return unmatched_indents + + +def code_starts_with_dedent_keyword(source_lines): + r"""Return True iff the incoming Python source_lines begin with "else", + "elif", "except", or "finally". + + Initial comments and whitespace are ignored. + + >>> code_starts_with_dedent_keyword(split_lines('if x in y: pass')) + False + >>> code_starts_with_dedent_keyword(split_lines('except ifSomethingElse:')) + True + >>> code_starts_with_dedent_keyword( + ... split_lines('\n# comment\nelse: # yes')) + True + """ + token_text = None + for token_kind, token_text, _, _, _ \ + in tokenize.generate_tokens(lambda i=iter(source_lines): next(i)): + + if token_kind != tokenize.COMMENT and token_text.strip() != '': + break + + return token_text in ('else', 'elif', 'except', 'finally') + + +class ParseContext(object): + + """State carried through a parse of a template""" + + filename = '' + template = '' + line_starts = [] + code_start_line = -1 + code_text = None + tokens = None # The rest of the tokens + close_lines = False + + def __init__(self, filename, template=None): + self.filename = os.path.abspath(filename) + if sys.platform == 'win32': + self.filename = '/'.join(self.filename.split(os.sep)) + if template is None: + with io.open(os.path.normpath(filename), encoding='utf-8') as f: + self.template = f.read() + else: + self.template = template + self.line_starts = get_line_starts(self.template) + self.tokens = self.token_generator(tokenize_template(self.template)) + self.next_token() + + def pos_to_line(self, pos): + return bisect(self.line_starts, pos) - 1 + + def token_generator(self, base_tokens): + r"""Given an iterator over (kind, text, match) triples (see + tokenize_template above), return a refined iterator over + token_kinds. + + Among other adjustments to the elements found by base_tokens, + this refined iterator tokenizes python code embedded in + template text to help determine its true extent. The + expression "base_tokens.send(pos)" is used to reset the index at + which base_tokens resumes scanning the underlying text. + + >>> ctx = ParseContext('dummy', ''' + ... %for x in y: + ... % print x + ... % end + ... literally + ... ''') + >>> while ctx.token_kind: + ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) + ... ignored = ctx.next_token() + ('literal', '\n') + ('gybLinesOpen', 'for x in y:\n') + ('gybLines', ' print x\n') + ('gybLinesClose', '% end') + ('literal', 'literally\n') + + >>> ctx = ParseContext('dummy', + ... '''Nothing + ... % if x: + ... % for i in range(3): + ... ${i} + ... % end + ... % else: + ... THIS SHOULD NOT APPEAR IN THE OUTPUT + ... ''') + >>> while ctx.token_kind: + ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) + ... ignored = ctx.next_token() + ('literal', 'Nothing\n') + ('gybLinesOpen', 'if x:\n') + ('gybLinesOpen', ' for i in range(3):\n') + ('substitutionOpen', 'i') + ('literal', '\n') + ('gybLinesClose', '% end') + ('gybLinesOpen', 'else:\n') + ('literal', 'THIS SHOULD NOT APPEAR IN THE OUTPUT\n') + + >>> ctx = ParseContext('dummy', + ... '''% for x in [1, 2, 3]: + ... % if x == 1: + ... literal1 + ... % elif x > 1: # add output line here to fix bug + ... % if x == 2: + ... literal2 + ... % end + ... % end + ... % end + ... ''') + >>> while ctx.token_kind: + ... print((ctx.token_kind, ctx.code_text or ctx.token_text)) + ... ignored = ctx.next_token() + ('gybLinesOpen', 'for x in [1, 2, 3]:\n') + ('gybLinesOpen', ' if x == 1:\n') + ('literal', 'literal1\n') + ('gybLinesOpen', 'elif x > 1: # add output line here to fix bug\n') + ('gybLinesOpen', ' if x == 2:\n') + ('literal', 'literal2\n') + ('gybLinesClose', '% end') + ('gybLinesClose', '% end') + ('gybLinesClose', '% end') + """ + for self.token_kind, self.token_text, self.token_match in base_tokens: + kind = self.token_kind + self.code_text = None + + # Do we need to close the current lines? + self.close_lines = kind == 'gybLinesClose' + + # %{...}% and ${...} constructs + if kind.endswith('Open'): + + # Tokenize text that follows as Python up to an unmatched '}' + code_start = self.token_match.end(kind) + self.code_start_line = self.pos_to_line(code_start) + + close_pos = tokenize_python_to_unmatched_close_curly( + self.template, code_start, self.line_starts) + self.code_text = self.template[code_start:close_pos] + yield kind + + if (kind == 'gybBlockOpen'): + # Absorb any '}% \n' + m2 = gyb_block_close.match(self.template, close_pos) + if not m2: + raise ValueError("Invalid block closure") + next_pos = m2.end(0) + else: + assert kind == 'substitutionOpen' + # skip past the closing '}' + next_pos = close_pos + 1 + + # Resume tokenizing after the end of the code. + base_tokens.send(next_pos) + + elif kind == 'gybLines': + + self.code_start_line = self.pos_to_line( + self.token_match.start('gybLines')) + indentation = self.token_match.group('_indent') + + # Strip off the leading indentation and %-sign + source_lines = re.split( + '^' + re.escape(indentation), + self.token_match.group('gybLines') + '\n', + flags=re.MULTILINE)[1:] + + if code_starts_with_dedent_keyword(source_lines): + self.close_lines = True + + last_split = 0 + for line in split_gyb_lines(source_lines): + self.token_kind = 'gybLinesOpen' + self.code_text = ''.join(source_lines[last_split:line]) + yield self.token_kind + last_split = line + self.code_start_line += line - last_split + self.close_lines = False + + self.code_text = ''.join(source_lines[last_split:]) + if self.code_text: + self.token_kind = 'gybLines' + yield self.token_kind + else: + yield self.token_kind + + def next_token(self): + """Move to the next token""" + for kind in self.tokens: + return self.token_kind + + self.token_kind = None + + +_default_line_directive = \ + '// ###sourceLocation(file: "%(file)s", line: %(line)d)' + + +class ExecutionContext(object): + + """State we pass around during execution of a template""" + + def __init__(self, line_directive=_default_line_directive, + **local_bindings): + self.local_bindings = local_bindings + self.line_directive = line_directive + self.local_bindings['__context__'] = self + self.result_text = [] + self.last_file_line = None + + def append_text(self, text, file, line): + # see if we need to inject a line marker + if self.line_directive: + if (file, line) != self.last_file_line: + # We can only insert the line directive at a line break + if len(self.result_text) == 0 \ + or self.result_text[-1].endswith('\n'): + substitutions = {'file': file, 'line': line + 1} + format_str = self.line_directive + '\n' + self.result_text.append(format_str % substitutions) + # But if the new text contains any line breaks, we can create + # one + elif '\n' in text: + i = text.find('\n') + self.result_text.append(text[:i + 1]) + # and try again + self.append_text(text[i + 1:], file, line + 1) + return + + self.result_text.append(text) + self.last_file_line = (file, line + text.count('\n')) + + +class ASTNode(object): + + """Abstract base class for template AST nodes""" + + def __init__(self): + raise NotImplementedError("ASTNode.__init__ is not implemented.") + + def execute(self, context): + raise NotImplementedError("ASTNode.execute is not implemented.") + + def __str__(self, indent=''): + raise NotImplementedError("ASTNode.__str__ is not implemented.") + + def format_children(self, indent): + if not self.children: + return ' []' + + return '\n'.join( + ['', indent + '['] + + [x.__str__(indent + 4 * ' ') for x in self.children] + + [indent + ']']) + + +class Block(ASTNode): + + """A sequence of other AST nodes, to be executed in order""" + + children = [] + + def __init__(self, context): + self.children = [] + + while context.token_kind and not context.close_lines: + if context.token_kind == 'literal': + node = Literal + else: + node = Code + self.children.append(node(context)) + + def execute(self, context): + for x in self.children: + x.execute(context) + + def __str__(self, indent=''): + return indent + 'Block:' + self.format_children(indent) + + +class Literal(ASTNode): + + """An AST node that generates literal text""" + + def __init__(self, context): + self.text = context.token_text + start_position = context.token_match.start(context.token_kind) + self.start_line_number = context.pos_to_line(start_position) + self.filename = context.filename + context.next_token() + + def execute(self, context): + context.append_text(self.text, self.filename, self.start_line_number) + + def __str__(self, indent=''): + return '\n'.join( + [indent + x for x in ['Literal:'] + + strip_trailing_nl(self.text).split('\n')]) + + +class Code(ASTNode): + + """An AST node that is evaluated as Python""" + + code = None + children = () + kind = None + + def __init__(self, context): + + source = '' + source_line_count = 0 + + def accumulate_code(): + s = source + (context.code_start_line - source_line_count) * '\n' \ + + textwrap.dedent(context.code_text) + line_count = context.code_start_line + \ + context.code_text.count('\n') + context.next_token() + return s, line_count + + eval_exec = 'exec' + if context.token_kind.startswith('substitution'): + eval_exec = 'eval' + source, source_line_count = accumulate_code() + source = '(' + source.strip() + ')' + + else: + while context.token_kind == 'gybLinesOpen': + source, source_line_count = accumulate_code() + source += ' __children__[%d].execute(__context__)\n' % len( + self.children) + source_line_count += 1 + + self.children += (Block(context),) + + if context.token_kind == 'gybLinesClose': + context.next_token() + + if context.token_kind == 'gybLines': + source, source_line_count = accumulate_code() + + # Only handle a substitution as part of this code block if + # we don't already have some %-lines. + elif context.token_kind == 'gybBlockOpen': + + # Opening ${...} and %{...}% constructs + source, source_line_count = accumulate_code() + + self.filename = context.filename + self.start_line_number = context.code_start_line + self.code = compile(source, context.filename, eval_exec) + self.source = source + + def execute(self, context): + # Save __children__ from the local bindings + save_children = context.local_bindings.get('__children__') + # Execute the code with our __children__ in scope + context.local_bindings['__children__'] = self.children + context.local_bindings['__file__'] = self.filename + result = eval(self.code, context.local_bindings) + + if context.local_bindings['__children__'] is not self.children: + raise ValueError("The code is not allowed to mutate __children__") + # Restore the bindings + context.local_bindings['__children__'] = save_children + + # If we got a result, the code was an expression, so append + # its value + if result is not None \ + or (isinstance(result, str) and result != ''): + from numbers import Number, Integral + result_string = None + if isinstance(result, Number) and not isinstance(result, Integral): + result_string = repr(result) + elif isinstance(result, Integral) or isinstance(result, list): + result_string = str(result) + else: + result_string = result + context.append_text( + result_string, self.filename, self.start_line_number) + + def __str__(self, indent=''): + source_lines = re.sub(r'^\n', '', strip_trailing_nl( + self.source), flags=re.MULTILINE).split('\n') + if len(source_lines) == 1: + s = indent + 'Code: {' + source_lines[0] + '}' + else: + s = indent + 'Code:\n' + indent + '{\n' + '\n'.join( + indent + 4 * ' ' + line for line in source_lines + ) + '\n' + indent + '}' + return s + self.format_children(indent) + + +def expand(filename, line_directive=_default_line_directive, **local_bindings): + r"""Return the contents of the given template file, executed with the given + local bindings. + + >>> from tempfile import NamedTemporaryFile + >>> # On Windows, the name of a NamedTemporaryFile cannot be used to open + >>> # the file for a second time if delete=True. Therefore, we have to + >>> # manually handle closing and deleting this file to allow us to open + >>> # the file by its name across all platforms. + >>> f = NamedTemporaryFile(delete=False) + >>> _ = f.write( + ... br'''--- + ... % for i in range(int(x)): + ... a pox on ${i} for epoxy + ... % end + ... ${120 + + ... + ... 3} + ... abc + ... ${"w\nx\nX\ny"} + ... z + ... ''') + >>> f.flush() + >>> result = expand( + ... f.name, + ... line_directive='//#sourceLocation(file: "%(file)s", ' + \ + ... 'line: %(line)d)', + ... x=2 + ... ).replace( + ... '"%s"' % f.name.replace('\\', '/'), '"dummy.file"') + >>> print(result, end='') + //#sourceLocation(file: "dummy.file", line: 1) + --- + //#sourceLocation(file: "dummy.file", line: 3) + a pox on 0 for epoxy + //#sourceLocation(file: "dummy.file", line: 3) + a pox on 1 for epoxy + //#sourceLocation(file: "dummy.file", line: 5) + 123 + //#sourceLocation(file: "dummy.file", line: 8) + abc + w + x + X + y + //#sourceLocation(file: "dummy.file", line: 10) + z + >>> f.close() + >>> os.remove(f.name) + """ + with io.open(filename, encoding='utf-8') as f: + t = parse_template(filename, f.read()) + d = os.getcwd() + os.chdir(os.path.dirname(os.path.abspath(filename))) + try: + return execute_template( + t, line_directive=line_directive, **local_bindings) + finally: + os.chdir(d) + + +def parse_template(filename, text=None): + r"""Return an AST corresponding to the given template file. + + If text is supplied, it is assumed to be the contents of the file, + as a string. + + >>> print(parse_template('dummy.file', text= + ... '''% for x in [1, 2, 3]: + ... % if x == 1: + ... literal1 + ... % elif x > 1: # add output line after this line to fix bug + ... % if x == 2: + ... literal2 + ... % end + ... % end + ... % end + ... ''')) + Block: + [ + Code: + { + for x in [1, 2, 3]: + __children__[0].execute(__context__) + } + [ + Block: + [ + Code: + { + if x == 1: + __children__[0].execute(__context__) + elif x > 1: # add output line after this line to fix bug + __children__[1].execute(__context__) + } + [ + Block: + [ + Literal: + literal1 + ] + Block: + [ + Code: + { + if x == 2: + __children__[0].execute(__context__) + } + [ + Block: + [ + Literal: + literal2 + ] + ] + ] + ] + ] + ] + ] + + >>> print(parse_template( + ... 'dummy.file', + ... text='%for x in range(10):\n% print(x)\n%end\njuicebox')) + Block: + [ + Code: + { + for x in range(10): + __children__[0].execute(__context__) + } + [ + Block: + [ + Code: {print(x)} [] + ] + ] + Literal: + juicebox + ] + + >>> print(parse_template('/dummy.file', text= + ... '''Nothing + ... % if x: + ... % for i in range(3): + ... ${i} + ... % end + ... % else: + ... THIS SHOULD NOT APPEAR IN THE OUTPUT + ... ''')) + Block: + [ + Literal: + Nothing + Code: + { + if x: + __children__[0].execute(__context__) + else: + __children__[1].execute(__context__) + } + [ + Block: + [ + Code: + { + for i in range(3): + __children__[0].execute(__context__) + } + [ + Block: + [ + Code: {(i)} [] + Literal: + + ] + ] + ] + Block: + [ + Literal: + THIS SHOULD NOT APPEAR IN THE OUTPUT + ] + ] + ] + + >>> print(parse_template('dummy.file', text='''% + ... %for x in y: + ... % print(y) + ... ''')) + Block: + [ + Code: + { + for x in y: + __children__[0].execute(__context__) + } + [ + Block: + [ + Code: {print(y)} [] + ] + ] + ] + + >>> print(parse_template('dummy.file', text='''% + ... %if x: + ... % print(y) + ... AAAA + ... %else: + ... BBBB + ... ''')) + Block: + [ + Code: + { + if x: + __children__[0].execute(__context__) + else: + __children__[1].execute(__context__) + } + [ + Block: + [ + Code: {print(y)} [] + Literal: + AAAA + ] + Block: + [ + Literal: + BBBB + ] + ] + ] + + >>> print(parse_template('dummy.file', text='''% + ... %if x: + ... % print(y) + ... AAAA + ... %# This is a comment + ... %else: + ... BBBB + ... ''')) + Block: + [ + Code: + { + if x: + __children__[0].execute(__context__) + # This is a comment + else: + __children__[1].execute(__context__) + } + [ + Block: + [ + Code: {print(y)} [] + Literal: + AAAA + ] + Block: + [ + Literal: + BBBB + ] + ] + ] + + >>> print(parse_template('dummy.file', text='''\ + ... %for x in y: + ... AAAA + ... %if x: + ... BBBB + ... %end + ... CCCC + ... ''')) + Block: + [ + Code: + { + for x in y: + __children__[0].execute(__context__) + } + [ + Block: + [ + Literal: + AAAA + Code: + { + if x: + __children__[0].execute(__context__) + } + [ + Block: + [ + Literal: + BBBB + ] + ] + Literal: + CCCC + ] + ] + ] + """ + return Block(ParseContext(filename, text)) + + +def execute_template( + ast, line_directive=_default_line_directive, **local_bindings): + r"""Return the text generated by executing the given template AST. + + Keyword arguments become local variable bindings in the execution context + + >>> root_directory = os.path.abspath('/') + >>> file_name = (root_directory + 'dummy.file').replace('\\', '/') + >>> ast = parse_template(file_name, text= + ... '''Nothing + ... % if x: + ... % for i in range(3): + ... ${i} + ... % end + ... % else: + ... THIS SHOULD NOT APPEAR IN THE OUTPUT + ... ''') + >>> out = execute_template(ast, + ... line_directive='//#sourceLocation(file: "%(file)s", line: %(line)d)', + ... x=1) + >>> out = out.replace(file_name, "DUMMY-FILE") + >>> print(out, end="") + //#sourceLocation(file: "DUMMY-FILE", line: 1) + Nothing + //#sourceLocation(file: "DUMMY-FILE", line: 4) + 0 + //#sourceLocation(file: "DUMMY-FILE", line: 4) + 1 + //#sourceLocation(file: "DUMMY-FILE", line: 4) + 2 + + >>> ast = parse_template(file_name, text= + ... '''Nothing + ... % a = [] + ... % for x in range(3): + ... % a.append(x) + ... % end + ... ${a} + ... ''') + >>> out = execute_template(ast, + ... line_directive='//#sourceLocation(file: "%(file)s", line: %(line)d)', + ... x=1) + >>> out = out.replace(file_name, "DUMMY-FILE") + >>> print(out, end="") + //#sourceLocation(file: "DUMMY-FILE", line: 1) + Nothing + //#sourceLocation(file: "DUMMY-FILE", line: 6) + [0, 1, 2] + + >>> ast = parse_template(file_name, text= + ... '''Nothing + ... % a = [] + ... % for x in range(3): + ... % a.append(x) + ... % end + ... ${a} + ... ''') + >>> out = execute_template(ast, + ... line_directive='#line %(line)d "%(file)s"', x=1) + >>> out = out.replace(file_name, "DUMMY-FILE") + >>> print(out, end="") + #line 1 "DUMMY-FILE" + Nothing + #line 6 "DUMMY-FILE" + [0, 1, 2] + """ + execution_context = ExecutionContext( + line_directive=line_directive, **local_bindings) + ast.execute(execution_context) + return ''.join(execution_context.result_text) + + +def main(): + import argparse + import sys + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Generate Your Boilerplate!', epilog=''' + A GYB template consists of the following elements: + + - Literal text which is inserted directly into the output + + - %% or $$ in literal text, which insert literal '%' and '$' + symbols respectively. + + - Substitutions of the form ${}. The Python + expression is converted to a string and the result is inserted + into the output. + + - Python code delimited by %{...}%. Typically used to inject + definitions (functions, classes, variable bindings) into the + evaluation context of the template. Common indentation is + stripped, so you can add as much indentation to the beginning + of this code as you like + + - Lines beginning with optional whitespace followed by a single + '%' and Python code. %-lines allow you to nest other + constructs inside them. To close a level of nesting, use the + "%end" construct. + + - Lines beginning with optional whitespace and followed by a + single '%' and the token "end", which close open constructs in + %-lines. + + Example template: + + - Hello - + %{ + x = 42 + def succ(a): + return a+1 + }% + + I can assure you that ${x} < ${succ(x)} + + % if int(y) > 7: + % for i in range(3): + y is greater than seven! + % end + % else: + y is less than or equal to seven + % end + + - The End. - + + When run with "gyb -Dy=9", the output is + + - Hello - + + I can assure you that 42 < 43 + + y is greater than seven! + y is greater than seven! + y is greater than seven! + + - The End. - +''' + ) + parser.add_argument( + '-D', action='append', dest='defines', metavar='NAME=VALUE', + default=[], + help='''Bindings to be set in the template's execution context''') + + parser.add_argument( + 'file', type=str, + help='Path to GYB template file (defaults to stdin)', nargs='?', + default='-') + parser.add_argument( + '-o', dest='target', type=str, + help='Output file (defaults to stdout)', default='-') + parser.add_argument( + '--test', action='store_true', + default=False, help='Run a self-test') + parser.add_argument( + '--verbose-test', action='store_true', + default=False, help='Run a verbose self-test') + parser.add_argument( + '--dump', action='store_true', + default=False, help='Dump the parsed template to stdout') + parser.add_argument( + '--line-directive', + default=_default_line_directive, + help=''' + Line directive format string, which will be + provided 2 substitutions, `%%(line)d` and `%%(file)s`. + + Example: `#sourceLocation(file: "%%(file)s", line: %%(line)d)` + + The default works automatically with the `line-directive` tool, + which see for more information. + ''') + + args = parser.parse_args(sys.argv[1:]) + + if args.test or args.verbose_test: + import doctest + selfmod = sys.modules[__name__] + if doctest.testmod(selfmod, verbose=args.verbose_test or None).failed: + sys.exit(1) + + bindings = dict(x.split('=', 1) for x in args.defines) + if args.file == '-': + ast = parse_template('stdin', sys.stdin.read()) + else: + with io.open(os.path.normpath(args.file), 'r', encoding='utf-8') as f: + ast = parse_template(args.file, f.read()) + if args.dump: + print(ast) + # Allow the template to open files and import .py files relative to its own + # directory + os.chdir(os.path.dirname(os.path.abspath(args.file))) + sys.path = ['.'] + sys.path + + if args.target == '-': + sys.stdout.write(execute_template(ast, args.line_directive, **bindings)) + else: + with io.open(args.target, 'w', encoding='utf-8', newline='\n') as f: + f.write(execute_template(ast, args.line_directive, **bindings)) + + +if __name__ == '__main__': + main() diff --git a/tools/regenerate-sources.sh b/tools/regenerate-sources.sh new file mode 100755 index 0000000..25a5aac --- /dev/null +++ b/tools/regenerate-sources.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# A script to re-build the text field sources from the source template file + +# Stop on error +set -e + +# The path where this script resides +script_path="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" + +# The path for the package (one level up) +package_path="$( cd -- "${script_path}/../" >/dev/null 2>&1 ; pwd -P )" + +pushd . + +# We have to run docbuild in the root of the source package +cd "${package_path}" + +# Generate the NSTextField implementation +python3 ${script_path}/gyb --line-directive '' -D field_type="Text" -o "DSFFloatLabelledTextField.swift" "Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb" +# Generate the NSSecureTextField implementation +python3 ${script_path}/gyb --line-directive '' -D field_type="SecureText" -o "DSFFloatLabelledSecureTextField.swift" "Sources/DSFFloatLabelledTextField/DSFFloatLabelledTextField.swift.gyb" + +popd + +echo "Done!"