├── flux.png ├── flux.icns ├── .gitignore ├── Rajdhani-Regular.otf ├── .editorconfig ├── gon.json ├── requirements.txt ├── entitlements.plist ├── Flux ├── dividerroutine.py ├── UI │ ├── qfontfeatures.py │ ├── qhbshapetrace.py │ ├── GlyphActions.py │ ├── qattachmenteditor.py │ ├── qbufferrenderer.py │ ├── qglyphname.py │ ├── classlist.py │ ├── qshapingdebugger.py │ ├── glyphpredicateeditor.py │ ├── featurelist.py │ ├── lookuplist.py │ └── qruleeditor.py ├── Plugins │ ├── IMatra.py │ ├── __init__.py │ ├── MedialRa.py │ ├── NameBasedFeature.py │ ├── RegexSubstitution.py │ └── Arabic.py ├── variations.py ├── ThirdParty │ ├── HTMLDelegate.py │ ├── QFlowLayout.py │ ├── QGridLayout.py │ ├── QFlowGridLayout.py │ └── qtoaster.py ├── computedroutine.py ├── constants.py ├── project.py └── editor.py ├── README.md ├── flux.py ├── setup.py ├── attic ├── qvharfbuzz.py ├── ttfontinfo.py └── vharfbuzz.py ├── tests └── test_model.py └── .github └── workflows └── buildapp.yaml /flux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simoncozens/flux/HEAD/flux.png -------------------------------------------------------------------------------- /flux.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simoncozens/flux/HEAD/flux.icns -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.fluxml 2 | *.p12 3 | *.glyphs 4 | *.ufo 5 | build/ 6 | dist/ 7 | *.fea 8 | -------------------------------------------------------------------------------- /Rajdhani-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simoncozens/flux/HEAD/Rajdhani-Regular.otf -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | 11 | [*.py] 12 | max_line_length = 119 13 | 14 | -------------------------------------------------------------------------------- /gon.json: -------------------------------------------------------------------------------- 1 | { 2 | "notarize": [{ 3 | "path": "dist/Flux.zip", 4 | "bundle_id": "uk.co.corvelsoftware.flux", 5 | "staple": false 6 | }], 7 | 8 | "apple_id": { 9 | "username": "simon@simon-cozens.org" 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.15.0 2 | lxml 3 | darkdetect 4 | git+git://github.com/simoncozens/glyphsLib@glyphs3-try3#egg=glyphsLib 5 | git+git://github.com/simoncozens/fontfeatures.git#egg=fontfeatures 6 | git+git://github.com/simoncozens/babelfont.git#egg=babelfont 7 | qcrash 8 | Pillow 9 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | com.apple.security.cs.allow-unsigned-executable-memory 12 | 13 | 14 | -------------------------------------------------------------------------------- /Flux/dividerroutine.py: -------------------------------------------------------------------------------- 1 | from fontFeatures import Routine 2 | from lxml import etree 3 | 4 | 5 | class DividerRoutine(Routine): 6 | def __init__(self, **kwargs): 7 | self.comment = None 8 | if "comment" in kwargs: 9 | self.comment = kwargs["comment"] 10 | del kwargs["comment"] 11 | super().__init__(**kwargs) 12 | 13 | @property 14 | def rules(self): 15 | return [] 16 | 17 | @rules.setter 18 | def rules(self, value): 19 | pass 20 | 21 | def toXML(self): 22 | root = etree.Element("routine") 23 | root.attrib["divider"] = "true" 24 | if self.comment: 25 | root.attrib["comment"] = self.comment 26 | return root 27 | 28 | @classmethod 29 | def fromXML(klass, el): 30 | return klass(comment=el.get("comment")) 31 | -------------------------------------------------------------------------------- /Flux/UI/qfontfeatures.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import Qt, QDataStream, QMimeData, QVariant 2 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QWidget, \ 3 | QSplitter, QVBoxLayout, QAbstractItemView, QMenu 4 | from .classlist import GlyphClassList 5 | from .lookuplist import LookupList 6 | from .featurelist import FeatureList 7 | 8 | 9 | class QFontFeaturesPanel(QSplitter): 10 | def __init__(self, project, editor): 11 | self.project = project 12 | self.editor = editor 13 | super(QFontFeaturesPanel, self).__init__() 14 | self.setOrientation(Qt.Vertical) 15 | self.addWidget(GlyphClassList(self.project)) 16 | self.lookuplist = LookupList(self.project, self) 17 | self.addWidget(self.lookuplist) 18 | self.featurelist = FeatureList(self.project, self) 19 | self.addWidget(self.featurelist) 20 | 21 | def update(self): 22 | for i in range(0,self.count()): 23 | self.widget(i).update() 24 | super().update() 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flux: Font Layout UX 2 | 3 | Flux *will be* a font layout editor. Currently this is in 4 | a rapid prototyping ("spike") phase; there are many moving 5 | parts and lots will change. 6 | 7 | Flux relies heavily on my fontFeatures module for 8 | representation of layout rules. Consequently, that module 9 | is *also* rapidly changing to respond to the needs of this 10 | project. All of the edges are bleeding. 11 | 12 | Currently Flux only reads fonts in `.glyphs` format. 13 | 14 | If you want to play: 15 | 16 | ``` 17 | pip3 install -r requirements.txt 18 | python3 flux.py 19 | ``` 20 | 21 | ## Building an app on OS X 22 | 23 | * Ensure that fontFeatures is installed unpacked (i.e. not as an egg) 24 | * Ensure that lxml.etree is built from source and installed 25 | * Clone `py2app` and hack it as per https://github.com/ronaldoussoren/py2app/issues/271#issuecomment-609078700 26 | * python3 setup.py py2app 27 | * rm -rf dist/flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/lib/Qt{WebEngine,Designer,Quick}* 28 | -------------------------------------------------------------------------------- /flux.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | 3 | if "RESOURCEPATH" in os.environ: 4 | sys.path = [os.path.join(os.environ['RESOURCEPATH'], 'lib', 'python3.8', 'lib-dynload')] + sys.path 5 | 6 | import Flux.ucd 7 | 8 | from Flux.project import FluxProject 9 | from Flux.editor import FluxEditor 10 | from PyQt5.QtWidgets import QApplication 11 | 12 | import qcrash.api as qcrash 13 | 14 | app = QApplication(sys.argv) 15 | app.setApplicationName("Flux") 16 | app.setOrganizationDomain("corvelsoftware.co.uk") 17 | # app.setOrganizationName("Corvel Software") 18 | 19 | email = qcrash.backends.EmailBackend('simon@simon-cozens.org', 'flux') 20 | github = qcrash.backends.GithubBackend('simoncozens', 'flux') 21 | qcrash.install_backend(github) 22 | qcrash.install_backend(email) 23 | qcrash.install_except_hook() 24 | 25 | proj = None 26 | if len(sys.argv) > 1: 27 | if sys.argv[1].endswith(".fluxml"): 28 | proj = FluxProject(sys.argv[1]) 29 | else: 30 | proj = FluxProject.new(sys.argv[1]) 31 | f = FluxEditor(proj) 32 | f.show() 33 | 34 | sys.exit(app.exec_()) 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a setup.py script generated by py2applet 3 | 4 | Usage: 5 | python setup.py py2app 6 | """ 7 | 8 | from setuptools import setup 9 | import Flux.Plugins 10 | import os, pkgutil 11 | 12 | APP = ['flux.py'] 13 | DATA_FILES = [] 14 | OPTIONS = { 15 | 'iconfile': 'flux.icns', 16 | 'packages': ['flux','ometa', 'terml', 'fontFeatures', 'babelfont', 'qcrash', 'PIL'], 17 | 'excludes': ['PyQt5.QtDesigner', 'PyQt5.QtNetwork', 'PyQt5.QtOpenGL', 'PyQt5.QtScript', 'PyQt5.QtSql', 'PyQt5.QtTest', 'PyQt5.QtWebKit', 'PyQt5.QtXml', 'PyQt5.phonon', 'PyQt5.QtWebEngine'], 18 | 'plist': { 19 | 'CFBundleIdentifier': 'uk.co.corvelsoftware.Flux', 20 | 'UTExportedTypeDeclarations': [{ 21 | 'UTTypeIdentifier': 'uk.co.corvelsoftware.Flux', 22 | 'UTTypeTagSpecification': { 23 | 'public.filename-extension': [ 'fluxml' ], 24 | }, 25 | 'UTTypeDescription': 'Flux Project File', 26 | 'UTTypeConformsTo': [ 'public.xml' ] 27 | }] 28 | } 29 | } 30 | 31 | setup( 32 | app=APP, 33 | name="Flux", 34 | data_files=DATA_FILES, 35 | options={'py2app': OPTIONS}, 36 | setup_requires=['py2app'], 37 | ) 38 | -------------------------------------------------------------------------------- /Flux/Plugins/IMatra.py: -------------------------------------------------------------------------------- 1 | from fontFeatures.feeLib.IMatra import IMatra as IMatra_FF 2 | from PyQt5.QtWidgets import QLabel, QDialog, QLineEdit, QGroupBox, QCompleter, QFormLayout, QComboBox 3 | from Flux.Plugins import FluxPlugin 4 | from Flux.UI.qglyphname import QGlyphName 5 | 6 | 7 | plugin_name = "IMatra Substitution" 8 | 9 | class Dialog(FluxPlugin): 10 | def createForm(self): 11 | form = QGroupBox("IMatra parameters") 12 | layout = QFormLayout() 13 | 14 | self.consonant_edit = QGlyphName(self.project, multiple=True, allow_classes=True) 15 | self.base_matra = QGlyphName(self.project) 16 | self.variants_edit = QGlyphName(self.project, multiple=True, allow_classes=True) 17 | 18 | layout.addRow(QLabel("Consonants"), self.consonant_edit) 19 | layout.addRow(QLabel("Base I-matra glyph"), self.base_matra) 20 | layout.addRow(QLabel("I-matra variants"), self.variants_edit) 21 | form.setLayout(layout) 22 | return form 23 | 24 | def accept(self): 25 | consonants = self.glyphSelector(self.consonant_edit.text()) 26 | base_matra = self.glyphSelector(self.base_matra.text()) 27 | variants = self.glyphSelector(self.variants_edit.text()) 28 | # Ideally we would serialize this routine as an automatic 29 | # one, but for now let's reify it and return. 30 | routines = IMatra_FF.action(self.feeparser, consonants, base_matra, variants) 31 | self.project.fontfeatures.routines.extend(routines) 32 | routines[0].name = "IMatra" 33 | return super().accept() 34 | -------------------------------------------------------------------------------- /Flux/Plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from fontFeatures.feeLib import FeeParser 2 | from PyQt5.QtWidgets import QLabel, QDialog, QCompleter, QDialogButtonBox, QVBoxLayout 3 | from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QStringListModel, QSettings 4 | import sys 5 | 6 | class FluxPlugin(QDialog): 7 | def __init__(self, project): 8 | super(QDialog, self).__init__() 9 | self.project = project 10 | self.feeparser = FeeParser(project.font) 11 | self.feeparser.fontfeatures = self.project.fontfeatures 12 | self.setWindowTitle(sys.modules[self.__module__].plugin_name) 13 | QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel 14 | self.buttonBox = QDialogButtonBox(QBtn) 15 | self.buttonBox.accepted.connect(self.accept) 16 | self.buttonBox.rejected.connect(self.reject) 17 | self.form = self.createForm() 18 | self.layout = QVBoxLayout() 19 | self.layout.addWidget(self.form) 20 | self.layout.addWidget(self.buttonBox) 21 | self.setLayout(self.layout) 22 | self.settings = QSettings() 23 | geometry = self.settings.value('plugin%sgeometry' % self.__class__.__name__, '') 24 | if geometry: 25 | self.restoreGeometry(geometry) 26 | 27 | def glyphSelector(self, text): 28 | return self.feeparser.parser(text).glyphselector() 29 | 30 | def accept(self): 31 | geometry = self.saveGeometry() 32 | self.settings.setValue('plugin%sgeometry' % self.__class__.__name__, geometry) 33 | print("Saved geometry") 34 | return super().accept() 35 | -------------------------------------------------------------------------------- /Flux/Plugins/MedialRa.py: -------------------------------------------------------------------------------- 1 | from fontFeatures.feeLib.MedialRa import MedialRa as MedialRa_FF 2 | from PyQt5.QtWidgets import QLabel, QDialog, QLineEdit, QGroupBox, QCompleter, QFormLayout, QComboBox 3 | from Flux.Plugins import FluxPlugin 4 | from Flux.UI.qglyphname import QGlyphName 5 | 6 | 7 | plugin_name = "MedialRa Substitution" 8 | 9 | class Dialog(FluxPlugin): 10 | def createForm(self): 11 | form = QGroupBox("MedialRa parameters") 12 | layout = QFormLayout() 13 | 14 | self.consonant_edit = QGlyphName(self.project, multiple=True, allow_classes=True) 15 | self.base_matra = QGlyphName(self.project) 16 | self.variants_edit = QGlyphName(self.project, multiple=True, allow_classes=True) 17 | 18 | layout.addRow(QLabel("Consonants"), self.consonant_edit) 19 | layout.addRow(QLabel("Base Medial Ra glyph"), self.base_matra) 20 | layout.addRow(QLabel("Medial Ra variants"), self.variants_edit) 21 | form.setLayout(layout) 22 | return form 23 | 24 | def accept(self): 25 | consonants = self.glyphSelector(self.consonant_edit.text()) 26 | base_matra = self.glyphSelector(self.base_matra.text()) 27 | variants = self.glyphSelector(self.variants_edit.text()) 28 | # Ideally we would serialize this routine as an automatic 29 | # one, but for now let's reify it and return. 30 | routines = MedialRa_FF.action(self.feeparser, consonants, base_matra, variants) 31 | self.project.fontfeatures.routines.extend(routines) 32 | routines[0].name = "MedialRa" 33 | return super().accept() 34 | -------------------------------------------------------------------------------- /attic/qvharfbuzz.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QPoint, QMargins 2 | from PyQt5.QtGui import QGlyphRun, QPainter, QRawFont 3 | from PyQt5.QtWidgets import QWidget 4 | 5 | 6 | class QVHarfbuzzWidget(QWidget): 7 | def __init__(self, vharfbuzz, size, buf): 8 | self.vharfbuzz = vharfbuzz 9 | self.size = size 10 | self.buf = buf 11 | self.setup_font() 12 | self.margins = QMargins(25, 25, 25, 25) 13 | super(QVHarfbuzzWidget, self).__init__() 14 | 15 | def set_buf(self, buf): 16 | self.buf = buf 17 | self.update() 18 | 19 | def setup_font(self): 20 | rf = QRawFont() 21 | rf.loadFromData(self.vharfbuzz.fontdata, self.size, 0) 22 | self.rf = rf 23 | 24 | def scale_point(self, x, y): 25 | return QPoint( 26 | x / self.vharfbuzz.upem * self.size, y / self.vharfbuzz.upem * self.size 27 | ) 28 | 29 | def paintEvent(self, e): 30 | if not self.buf: 31 | return 32 | qp = QPainter() 33 | qp.begin(self) 34 | g = QGlyphRun() 35 | g.setRawFont(self.rf) 36 | g.setGlyphIndexes([x.codepoint for x in self.buf.glyph_infos]) 37 | pos = (0, 0) 38 | poses = [] 39 | for _p in self.buf.glyph_positions: 40 | p = _p.position 41 | # Y coordinates go down, not up. 42 | poses.append(self.scale_point(pos[0] + p[0], pos[1] - p[1])) 43 | pos = (pos[0] + p[2], pos[1] + p[3]) 44 | 45 | g.setPositions(poses) 46 | qp.drawGlyphRun(e.rect().marginsRemoved(self.margins).bottomLeft(), g) 47 | qp.end() 48 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | from Flux.UI.featurelist import FeatureListModel 2 | from Flux.UI.lookuplist import LookupListModel 3 | from Flux.project import FluxProject 4 | from fontFeatures import FontFeatures, Routine, Substitution 5 | 6 | pytest_plugins = ("pytest-qt",) 7 | 8 | def test_featurelist(qtmodeltester): 9 | proj = FluxProject() 10 | proj.fontfeatures = FontFeatures() 11 | r1 = Routine(name="routine1") 12 | r2 = Routine(name="routine2") 13 | proj.fontfeatures.features["test"] = [r1, r2] 14 | 15 | proj = FluxProject("Hind.fluxml") 16 | 17 | model = FeatureListModel(proj) 18 | 19 | root = model.index(-1,-1) 20 | assert(model.describeIndex(root) == "root of tree") 21 | feature1 = model.index(0,0) 22 | assert(model.describeIndex(feature1) == "feature at row 0") 23 | child1 = model.index(0,0,feature1) 24 | assert(child1.parent() == feature1) 25 | assert(model.index(0,0,feature1) == model.index(0,0,feature1)) 26 | # import code; code.interact(local=locals()) 27 | qtmodeltester.check(model, force_py=True) 28 | qtmodeltester.check(model) 29 | 30 | def test_lookuplist(qtmodeltester): 31 | proj = FluxProject("Hind.fluxml") 32 | # proj = FluxProject() 33 | # proj.fontfeatures = FontFeatures() 34 | # r1 = Routine(name="routine1") 35 | # r1.addRule(Substitution([["a"]], [["b"]])) 36 | # r1.addRule(Substitution([["c"]], [["d"]])) 37 | # r2 = Routine(name="routine2") 38 | # r2.addRule(Substitution([["e"]], [["f"]])) 39 | # proj.fontfeatures.routines = [r1, r2] 40 | 41 | model = LookupListModel(proj) 42 | qtmodeltester.check(model, force_py=True) 43 | qtmodeltester.check(model) 44 | -------------------------------------------------------------------------------- /Flux/variations.py: -------------------------------------------------------------------------------- 1 | from fontFeatures.shaperLib.Buffer import Buffer, BufferItem, _add_value_records 2 | import weakref 3 | 4 | 5 | class VariationAwareBufferItem(BufferItem): 6 | def prep_glyph(self, font): 7 | super().prep_glyph(font) 8 | # # Interpolate width 9 | if not hasattr(self.buffer(), "vf"): 10 | return 11 | vf = self.buffer().vf 12 | if vf: 13 | glyphs = [vf.masters[master][self.glyph] for master in vf.master_order] 14 | widthset = {vf.master_order[i]: glyphs[i].width for i in range(len(vf.masters))} 15 | self.position.xAdvance = vf.interpolate_tuples(widthset, self.buffer().location) 16 | 17 | @classmethod 18 | def new_unicode(klass, codepoint, buffer=None): 19 | self = klass() 20 | self.codepoint = codepoint 21 | self.glyph = None 22 | self.feature_masks = {} 23 | self.buffer = weakref.ref(buffer) 24 | return self 25 | 26 | @classmethod 27 | def new_glyph(klass, glyph, font, buffer=None): 28 | self = klass() 29 | self.codepoint = None 30 | self.glyph = glyph 31 | self.buffer = weakref.ref(buffer) 32 | self.feature_masks = {} 33 | self.prep_glyph(font) 34 | return self 35 | 36 | def add_position(self, vr2): 37 | if not hasattr(self.buffer(), "vf"): 38 | return super().add_position(vr2) 39 | vf = self.buffer().vf 40 | if vf: 41 | vr2 = vr2.get_value_for_location(vf, self.buffer().location) 42 | _add_value_records(self.position, vr2) 43 | 44 | class VariationAwareBuffer(Buffer): 45 | itemclass = VariationAwareBufferItem 46 | 47 | def store_glyphs(self, glyphs): 48 | self.items = [self.itemclass.new_glyph(g, self.font, self) for g in glyphs] 49 | 50 | def store_unicode(self, unistring): 51 | self.items = [self.itemclass.new_unicode(ord(char), self) for char in unistring ] 52 | -------------------------------------------------------------------------------- /Flux/ThirdParty/HTMLDelegate.py: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/30175644/pyqt-listview-with-html-rich-text-delegate-moves-text-bit-out-of-placepic-and-c 2 | 3 | from PyQt5.QtCore import * 4 | from PyQt5.QtWidgets import * 5 | from PyQt5.QtGui import * 6 | import sys 7 | 8 | class HTMLDelegate(QStyledItemDelegate): 9 | def __init__(self, parent=None): 10 | super().__init__() 11 | self.doc = QTextDocument(self) 12 | 13 | def paint(self, painter, option, index): 14 | painter.save() 15 | 16 | options = QStyleOptionViewItem(option) 17 | 18 | self.initStyleOption(options, index) 19 | self.doc.setHtml(options.text) 20 | options.text = "" 21 | 22 | style = QApplication.style() if options.widget is None \ 23 | else options.widget.style() 24 | style.drawControl(QStyle.CE_ItemViewItem, options, painter) 25 | 26 | ctx = QAbstractTextDocumentLayout.PaintContext() 27 | 28 | if option.state & QStyle.State_Selected: 29 | ctx.palette.setColor(QPalette.Text, option.palette.color( 30 | QPalette.Active, QPalette.HighlightedText)) 31 | else: 32 | ctx.palette.setColor(QPalette.Text, option.palette.color( 33 | QPalette.Active, QPalette.Text)) 34 | 35 | textRect = style.subElementRect( 36 | QStyle.SE_ItemViewItemText, options) 37 | 38 | if index.column() != 0: 39 | textRect.adjust(5, 0, 0, 0) 40 | 41 | thefuckyourshitup_constant = 4 42 | margin = (option.rect.height() - options.fontMetrics.height()) // 2 43 | margin = margin - thefuckyourshitup_constant 44 | textRect.setTop(textRect.top() + margin) 45 | 46 | painter.translate(textRect.topLeft()) 47 | painter.setClipRect(textRect.translated(-textRect.topLeft())) 48 | self.doc.documentLayout().draw(painter, ctx) 49 | 50 | painter.restore() 51 | 52 | def sizeHint(self, option, index): 53 | return QSize(self.doc.idealWidth(), self.doc.size().height()) 54 | -------------------------------------------------------------------------------- /Flux/UI/qhbshapetrace.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtGui import QBrush, QColor 2 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem 3 | import uharfbuzz as hb 4 | 5 | 6 | class QHBShapeTrace(QTreeWidget): 7 | def __init__(self, font, text): 8 | self.font = font 9 | self.trace = [] 10 | super(QHBShapeTrace, self).__init__() 11 | self.setColumnCount(2) 12 | self.set_text(text) 13 | 14 | def set_text(self, text): 15 | newtrace = [] 16 | self.clear() 17 | buf = hb.Buffer() 18 | buf.add_str(text) 19 | buf.guess_segment_properties() 20 | buf.set_message_func(self.process_msg) 21 | self.stack = [QTreeWidgetItem(["GSUB", ""])] 22 | self.addTopLevelItem(self.stack[0]) 23 | hb.shape(self.font.vharfbuzz.hbfont, buf) 24 | 25 | def routine_name(self, msg): 26 | lu = int(msg[13:]) 27 | name = "" 28 | # import code; code.interact(local=locals()) 29 | info = self.font.lookup_info(self.stack[0].text(0),lu) 30 | if info.feature: 31 | name = info.feature + ": " 32 | if info.address: 33 | name = name + info.address[0] + ": " 34 | if info.name: 35 | name = name + info.name 36 | else: 37 | name = name + msg[6:] 38 | return name 39 | 40 | def process_msg(self, msg, buf): 41 | buffernow = self.font.vharfbuzz.serialize_buf(buf) 42 | print(msg, buffernow) 43 | if msg.startswith("start table GPOS"): 44 | self.stack = [QTreeWidgetItem(["GPOS", ""])] 45 | self.addTopLevelItem(self.stack[0]) 46 | elif msg.startswith("start lookup "): 47 | c = QTreeWidgetItem([self.routine_name(msg), buffernow]) 48 | self.stack[-1].addChild(c) 49 | self.stack.append(c) 50 | elif msg.startswith("end lookup "): 51 | if self.stack[-1].text(1) == buffernow: 52 | self.stack[-1].setForeground(0, QBrush(QColor(128,128,128,128))) 53 | self.stack[-1].setForeground(1, QBrush(QColor(128,128,128,128))) 54 | else: 55 | self.stack[-1].setText(1, buffernow) 56 | self.stack.pop() 57 | return True 58 | -------------------------------------------------------------------------------- /attic/ttfontinfo.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from fontFeatures.ttLib import unparse 3 | from vharfbuzz import Vharfbuzz 4 | import fontFeatures 5 | from fontTools.ttLib import TTFont 6 | 7 | LookupInfo = namedtuple("LookupInfo", ["name", "language", "feature", "address"]) 8 | 9 | 10 | class TTFontInfo: 11 | def __init__(self, filename): 12 | self.filename = filename 13 | self.font = TTFont(filename) 14 | self.vharfbuzz = Vharfbuzz(filename) 15 | self.fontfeatures = unparse(self.font) 16 | self.setup_lookups() 17 | 18 | def setup_lookups(self): 19 | self._all_lookups = [] 20 | self._lookup_info = {} 21 | self._features = self.fontfeatures.features 22 | for routine in self.fontfeatures.routines: 23 | table, lid = routine.address[0:2] 24 | extra = routine.address[2:] 25 | self._lookup_info[(table, int(lid))] = LookupInfo( 26 | routine.name, None, None, extra 27 | ) 28 | self._all_lookups.append(routine) 29 | for key, routines in self._features.items(): 30 | for routine in routines: 31 | table, lid = routine.address[0:2] 32 | extra = routine.address[2:] 33 | print(key, routine, lid) 34 | self._lookup_info[(table, int(lid))] = LookupInfo( 35 | routine.name, None, key, extra 36 | ) 37 | if routine not in self._all_lookups: 38 | self._all_lookups.append(routine) 39 | for chain in self.fontfeatures.allRules(fontFeatures.Chaining): 40 | for routinelist in chain.lookups: 41 | if not routinelist: 42 | continue 43 | for routine in routinelist: 44 | table, lid = routine.address[0:2] 45 | extra = routine.address[2:] 46 | self._lookup_info[(table, int(lid))] = LookupInfo( 47 | routine.name, None, key, None 48 | ) 49 | if routine not in self._all_lookups: 50 | self._all_lookups.append(routine) 51 | 52 | @property 53 | def glyph_classes(self): 54 | return self.fontfeatures.namedClasses 55 | 56 | @property 57 | def all_lookups(self): 58 | return self._all_lookups 59 | 60 | def lookup_info(self, table, lid): # name, language, feature, address 61 | return self._lookup_info[(table, lid)] 62 | 63 | @property 64 | def features(self): 65 | return self._features 66 | -------------------------------------------------------------------------------- /Flux/computedroutine.py: -------------------------------------------------------------------------------- 1 | from fontFeatures import Routine 2 | from lxml import etree 3 | 4 | 5 | class ComputedRoutine(Routine): 6 | def __init__(self, **kwargs): 7 | self.parameters = {} 8 | self.plugin = "" 9 | self._rules = [] 10 | if "parameters" in kwargs: 11 | self.parameters = kwargs["parameters"] 12 | del kwargs["parameters"] 13 | super().__init__(**kwargs) 14 | 15 | @property 16 | def okay(self): 17 | if hasattr(self, "module"): 18 | return True 19 | assert self.project.editor 20 | return self.plugin in self.project.editor.plugins 21 | 22 | @property 23 | def rules(self): 24 | if not self._rules: 25 | assert self.project 26 | if not self.okay: 27 | return [] 28 | if not hasattr(self, "module"): 29 | mod = self.project.editor.plugins[self.plugin] 30 | else: 31 | mod = self.module 32 | rules = mod.rulesFromComputedRoutine(self) 33 | for r in rules: 34 | r.computed = True 35 | self._rules = rules 36 | return self._rules 37 | 38 | @rules.setter 39 | def rules(self, value): 40 | pass 41 | 42 | def reify(self): 43 | newroutine = Routine(name=self.name) 44 | newroutine.flags = self.flags 45 | newroutine.languages = self.languages 46 | newroutine.rules = self.rules # Magic 47 | for r in newroutine.rules: 48 | delattr(r, "computed") 49 | 50 | newroutine.markFilteringSet = self.markFilteringSet 51 | newroutine.markAttachmentSet = self.markAttachmentSet 52 | return newroutine 53 | 54 | def toXML(self): 55 | root = etree.Element("routine") 56 | root.attrib["computed"] = "true" 57 | root.attrib["plugin"] = self.plugin 58 | if self.flags: 59 | root.attrib["flags"] = str(self.flags) 60 | if self.address: 61 | root.attrib["address"] = str(self.address) 62 | if self.name: 63 | root.attrib["name"] = self.name 64 | for k, v in self.parameters.items(): 65 | param = etree.Element("parameter") 66 | param.attrib["key"] = k 67 | param.attrib["value"] = v 68 | root.append(param) 69 | 70 | return root 71 | 72 | @classmethod 73 | def fromXML(klass, el): 74 | routine = klass( 75 | address=el.get("address"), 76 | name=el.get("name"), 77 | flags=(int(el.get("flags") or 0)), 78 | ) 79 | routine.plugin = el.get("plugin") 80 | for p in el: 81 | routine.parameters[p.attrib["key"]] = p.attrib["value"] 82 | return routine 83 | -------------------------------------------------------------------------------- /.github/workflows/buildapp.yaml: -------------------------------------------------------------------------------- 1 | name: Build application 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macOS-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: apple-actions/import-codesign-certs@v1 12 | with: 13 | p12-file-base64: ${{ secrets.CERTIFICATES_P12 }} 14 | p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }} 15 | - name: Set up gon 16 | run: | 17 | brew tap mitchellh/gon 18 | brew install mitchellh/gon/gon 19 | - name: Set up Python 3.8 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.8 23 | - name: Rebuild lxml from source 24 | run: | 25 | pip install --no-binary :all: lxml 26 | env: 27 | STATIC_DEPS: "true" 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | - name: Run py2app 33 | run: python3 setup.py py2app 34 | - name: Thin package 35 | run: | 36 | rm -rf dist/Flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/lib/Qt{WebEngine,Designer,Quick}* 37 | rm -rf dist/Flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/qml/ 38 | - name: Codesign package 39 | run: | 40 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime `find dist/Flux.app -name '*.so' -or -name '*.dylib' -or -name '*.framework'` || true 41 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime `find dist/Flux.app -type f | grep 'framework/Versions/5/'` || true 42 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime dist/Flux.app 43 | - name: Package app 44 | run: | 45 | ditto -c -k --keepParent "dist/Flux.app" dist/Flux.zip 46 | - name: Notarize app 47 | run: | 48 | gon gon.json 49 | env: 50 | AC_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }} 51 | AC_PROVIDER: ${{ secrets.NOTARIZE_PROVIDER }} 52 | - name: Staple app 53 | run: | 54 | xcrun stapler staple dist/Flux.app 55 | xcrun stapler validate dist/Flux.app 56 | - name: Repackage stapled app 57 | run: | 58 | rm -rf dist/Flux.zip 59 | ditto -c -k --keepParent "dist/Flux.app" dist/Flux.zip 60 | - name: Archive production artifacts 61 | uses: actions/upload-artifact@v2 62 | with: 63 | name: application 64 | path: dist/Flux.zip 65 | - name: Trash Python packages 66 | run: rm -rf /Users/runner/hostedtoolcache/Python/3.8.6/x64/lib/python3.8/site-packages/ 67 | - name: Test it can run 68 | run: | 69 | mkdir test 70 | cd test 71 | unzip ../dist/Flux.zip 72 | ./Flux.app/Contents/MacOS/Flux & 73 | sleep 15 74 | ps auxw | grep Flux.app | grep -v grep 75 | -------------------------------------------------------------------------------- /Flux/ThirdParty/QFlowLayout.py: -------------------------------------------------------------------------------- 1 | # Adapted from https://gist.github.com/Cysu/7461066 2 | from PyQt5 import QtCore, QtGui 3 | from PyQt5.QtWidgets import ( 4 | QSizePolicy, 5 | QLayout 6 | ) 7 | 8 | class QFlowLayout(QLayout): 9 | def __init__(self, parent=None, margin=0, spacing=-1): 10 | super(QFlowLayout, self).__init__(parent) 11 | self.margin = margin 12 | 13 | if parent is not None: 14 | self.setMargin(margin) 15 | 16 | self.setSpacing(spacing) 17 | 18 | self.itemList = [] 19 | 20 | def __del__(self): 21 | item = self.takeAt(0) 22 | while item: 23 | item = self.takeAt(0) 24 | 25 | def addItem(self, item): 26 | self.itemList.append(item) 27 | 28 | def count(self): 29 | return len(self.itemList) 30 | 31 | def itemAt(self, index): 32 | if index >= 0 and index < len(self.itemList): 33 | return self.itemList[index] 34 | 35 | return None 36 | 37 | def takeAt(self, index): 38 | if index >= 0 and index < len(self.itemList): 39 | return self.itemList.pop(index) 40 | 41 | return None 42 | 43 | def expandingDirections(self): 44 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) 45 | 46 | def hasHeightForWidth(self): 47 | return True 48 | 49 | def heightForWidth(self, width): 50 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True) 51 | return height 52 | 53 | def setGeometry(self, rect): 54 | super(QFlowLayout, self).setGeometry(rect) 55 | self._doLayout(rect, False) 56 | 57 | def sizeHint(self): 58 | return self.minimumSize() 59 | 60 | def minimumSize(self): 61 | size = QtCore.QSize() 62 | 63 | for item in self.itemList: 64 | size = size.expandedTo(item.minimumSize()) 65 | 66 | size += QtCore.QSize(2 * self.margin, 2 * self.margin) 67 | return size 68 | 69 | def _doLayout(self, rect, testOnly): 70 | x = rect.x() 71 | y = rect.y() 72 | lineHeight = 0 73 | 74 | for item in self.itemList: 75 | wid = item.widget() 76 | spaceX = self.spacing() + wid.style().layoutSpacing( 77 | QSizePolicy.PushButton, 78 | QSizePolicy.PushButton, 79 | QtCore.Qt.Horizontal) 80 | 81 | spaceY = self.spacing() + wid.style().layoutSpacing( 82 | QSizePolicy.PushButton, 83 | QSizePolicy.PushButton, 84 | QtCore.Qt.Vertical) 85 | 86 | nextX = x + item.sizeHint().width() + spaceX 87 | if nextX - spaceX > rect.right() and lineHeight > 0: 88 | x = rect.x() 89 | y = y + lineHeight + spaceY 90 | nextX = x + item.sizeHint().width() + spaceX 91 | lineHeight = 0 92 | 93 | if not testOnly: 94 | item.setGeometry( 95 | QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint())) 96 | 97 | x = nextX 98 | lineHeight = max(lineHeight, item.sizeHint().height()) 99 | 100 | return y + lineHeight - rect.y() 101 | -------------------------------------------------------------------------------- /Flux/Plugins/NameBasedFeature.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QLabel, 3 | QDialog, 4 | QLineEdit, 5 | QGroupBox, 6 | QCompleter, 7 | QFormLayout, 8 | QComboBox, 9 | QMessageBox, 10 | QCheckBox, 11 | ) 12 | from PyQt5.QtGui import QValidator 13 | from Flux.Plugins import FluxPlugin 14 | import re 15 | import fontFeatures 16 | 17 | 18 | plugin_name = "Name-Based Features" 19 | 20 | 21 | class NameBasedFeature: 22 | def __init__(self, project): 23 | self.project = project 24 | self.glyphnames = self.project.font.keys() 25 | 26 | def matches(self): 27 | return [g for g in self.glyphnames if re.search(self.glyphSuffixRE + "$", g)] 28 | 29 | def applicable(self): 30 | return len(self.matches()) > 0 31 | 32 | def transform(self, right): 33 | left = [re.sub(self.glyphSuffixRE, "", g) for g in right] 34 | return left 35 | 36 | def apply(self): 37 | right = self.matches() 38 | left = self.transform(right) 39 | left2, right2 = [], [] 40 | routine = fontFeatures.Routine(name=self.name.title().replace(" ", "")) 41 | for l, r in zip(left, right): 42 | if l in self.glyphnames and r in self.glyphnames: 43 | routine.addRule(fontFeatures.Substitution([[l]], [[r]])) 44 | self.project.fontfeatures.routines.extend([routine]) 45 | self.project.fontfeatures.addFeature(self.feature, [routine]) 46 | 47 | 48 | class SlashZero(NameBasedFeature): 49 | glyphSuffixRE = ".zero" 50 | feature = "zero" 51 | name = "slash zero" 52 | 53 | class StandardLigature(NameBasedFeature): 54 | glyphSuffixRE = ".liga|^fi$|^fl$|^f_f(_[il])?$" 55 | feature = "liga" 56 | name = "standard ligatures" 57 | 58 | def apply(self): 59 | right = self.matches() 60 | routine = fontFeatures.Routine(name=self.name.title().replace(" ", "")) 61 | for r in right: 62 | left = r.replace(".liga","").split("_") 63 | if r == "fl" or r == "fi": 64 | left = list(r) 65 | if all(l in self.glyphnames for l in left) and r in self.glyphnames: 66 | routine.addRule(fontFeatures.Substitution([ [l] for l in left], [[r]])) 67 | self.project.fontfeatures.routines.extend([routine]) 68 | self.project.fontfeatures.addFeature(self.feature, [routine]) 69 | 70 | class SmallCaps(NameBasedFeature): 71 | glyphSuffixRE = ".sc" 72 | feature = "smcp" 73 | name = "small caps" 74 | 75 | def transform(self, right): 76 | left = super().transform(right) 77 | return [g[0].lower() + g[1:] for g in left] 78 | 79 | 80 | class CapToSmallCaps(NameBasedFeature): 81 | glyphSuffixRE = ".sc" 82 | feature = "c2sc" 83 | name = "caps to small caps" 84 | 85 | 86 | class Dialog(FluxPlugin): 87 | tests = [SlashZero, SmallCaps, CapToSmallCaps, StandardLigature] 88 | 89 | def createForm(self): 90 | form = QGroupBox() 91 | layout = QFormLayout() 92 | 93 | self.boxes = [] 94 | 95 | for testclass in self.tests: 96 | test = testclass(self.project) 97 | if test.applicable(): 98 | box = QCheckBox() 99 | box.setChecked(True) 100 | box.test = test 101 | layout.addRow(QLabel(f"Derive {testclass.name} feature?"), box) 102 | self.boxes.append(box) 103 | else: 104 | layout.addRow( 105 | QLabel( 106 | f"No {testclass.glyphSuffixRE} glyphs found for {testclass.name}" 107 | ) 108 | ) 109 | 110 | form.setLayout(layout) 111 | return form 112 | 113 | def accept(self): 114 | for b in self.boxes: 115 | if b.isChecked(): 116 | b.test.apply() 117 | return super().accept() 118 | -------------------------------------------------------------------------------- /Flux/ThirdParty/QGridLayout.py: -------------------------------------------------------------------------------- 1 | 2 | # Adapted from https://gist.github.com/Cysu/7461066 3 | from PyQt5 import QtCore, QtGui 4 | from PyQt5.QtWidgets import ( 5 | QWidget, 6 | QApplication, 7 | QHBoxLayout, 8 | QLineEdit, 9 | QVBoxLayout, 10 | QPushButton, 11 | QCompleter, 12 | QLabel, 13 | QSizePolicy, 14 | QDialog, 15 | QDialogButtonBox, 16 | QLayout, 17 | QGridLayout, 18 | QStyle 19 | ) 20 | class QFlowGridLayout(QLayout): 21 | def __init__(self, parent=None, margin=0, spacing=-1): 22 | super(QFlowGridLayout, self).__init__(parent) 23 | self.margin = margin 24 | 25 | if parent is not None: 26 | self.setContentsMargins(margin, margin, margin, margin) 27 | 28 | self.setSpacing(spacing) 29 | 30 | self.itemList = [] 31 | 32 | def __del__(self): 33 | item = self.takeAt(0) 34 | while item: 35 | item = self.takeAt(0) 36 | 37 | def addItem(self, item): 38 | self.itemList.append(item) 39 | 40 | def count(self): 41 | return len(self.itemList) 42 | 43 | def itemAt(self, index): 44 | if index >= 0 and index < len(self.itemList): 45 | return self.itemList[index] 46 | 47 | return None 48 | 49 | def takeAt(self, index): 50 | if index >= 0 and index < len(self.itemList): 51 | return self.itemList.pop(index) 52 | 53 | return None 54 | 55 | def expandingDirections(self): 56 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) 57 | 58 | def hasHeightForWidth(self): 59 | return True 60 | 61 | def heightForWidth(self, width): 62 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True) 63 | return height 64 | 65 | def setGeometry(self, rect): 66 | super(QFlowGridLayout, self).setGeometry(rect) 67 | self._doLayout(rect, False) 68 | 69 | def sizeHint(self): 70 | return self.minimumSize() 71 | 72 | def minimumSize(self): 73 | size = QtCore.QSize() 74 | 75 | for item in self.itemList: 76 | size = size.expandedTo(item.minimumSize()) 77 | 78 | size += QtCore.QSize(2 * self.margin, 2 * self.margin) 79 | return size 80 | 81 | def _doLayout(self, rect, testOnly): 82 | x = rect.x() 83 | y = rect.y() 84 | lineHeight = 0 85 | # SC hacks. I am assuming we are a grid, and all spaceX/spaceY is 86 | # the same. So we only do this once. 87 | 88 | # Find first visible item 89 | item = None 90 | for i in self.itemList: 91 | if i.widget().isVisible(): 92 | item = i 93 | break 94 | if not item: 95 | return 0 96 | wid = item.widget() 97 | spaceX = self.spacing() + wid.style().layoutSpacing( 98 | QSizePolicy.PushButton, 99 | QSizePolicy.PushButton, 100 | QtCore.Qt.Horizontal) 101 | 102 | spaceY = self.spacing() + wid.style().layoutSpacing( 103 | QSizePolicy.PushButton, 104 | QSizePolicy.PushButton, 105 | QtCore.Qt.Vertical) 106 | 107 | itemSize = item.sizeHint() 108 | itemWidth = itemSize.width() 109 | itemHeight = itemSize.height() 110 | 111 | for item in self.itemList: 112 | if not item.widget().isVisible(): 113 | continue 114 | 115 | nextX = x + itemWidth + spaceX 116 | if nextX - spaceX > rect.right() and lineHeight > 0: 117 | x = rect.x() 118 | y = y + lineHeight + spaceY 119 | nextX = x + itemWidth + spaceX 120 | lineHeight = 0 121 | 122 | if not testOnly: 123 | item.setGeometry( 124 | QtCore.QRect(QtCore.QPoint(x, y), itemSize)) 125 | 126 | x = nextX 127 | lineHeight = max(lineHeight, itemHeight) 128 | 129 | return y + lineHeight - rect.y() 130 | 131 | 132 | if __name__ == '__main__': 133 | 134 | import sys 135 | 136 | class Window(QWidget): 137 | def __init__(self): 138 | super(Window, self).__init__() 139 | 140 | flowLayout = QFlowGridLayout() 141 | flowLayout.addWidget(QPushButton("Short")) 142 | flowLayout.addWidget(QPushButton("Longer")) 143 | flowLayout.addWidget(QPushButton("Different text")) 144 | flowLayout.addWidget(QPushButton("More text")) 145 | flowLayout.addWidget(QPushButton("Even longer button text")) 146 | self.setLayout(flowLayout) 147 | 148 | self.setWindowTitle("Flow Layout") 149 | 150 | app = QApplication(sys.argv) 151 | mainWin = Window() 152 | mainWin.show() 153 | sys.exit(app.exec_()) 154 | -------------------------------------------------------------------------------- /Flux/ThirdParty/QFlowGridLayout.py: -------------------------------------------------------------------------------- 1 | 2 | # Adapted from https://gist.github.com/Cysu/7461066 3 | from PyQt5 import QtCore, QtGui 4 | from PyQt5.QtWidgets import ( 5 | QWidget, 6 | QApplication, 7 | QHBoxLayout, 8 | QLineEdit, 9 | QVBoxLayout, 10 | QPushButton, 11 | QCompleter, 12 | QLabel, 13 | QSizePolicy, 14 | QDialog, 15 | QDialogButtonBox, 16 | QLayout, 17 | QGridLayout, 18 | QStyle 19 | ) 20 | class QFlowGridLayout(QLayout): 21 | def __init__(self, parent=None, margin=0, spacing=-1): 22 | super(QFlowGridLayout, self).__init__(parent) 23 | self.margin = margin 24 | 25 | if parent is not None: 26 | self.setContentsMargins(margin, margin, margin, margin) 27 | 28 | self.setSpacing(spacing) 29 | 30 | self.itemList = [] 31 | 32 | def __del__(self): 33 | item = self.takeAt(0) 34 | while item: 35 | item = self.takeAt(0) 36 | 37 | def addItem(self, item): 38 | self.itemList.append(item) 39 | 40 | def count(self): 41 | return len(self.itemList) 42 | 43 | def itemAt(self, index): 44 | if index >= 0 and index < len(self.itemList): 45 | return self.itemList[index] 46 | 47 | return None 48 | 49 | def takeAt(self, index): 50 | if index >= 0 and index < len(self.itemList): 51 | return self.itemList.pop(index) 52 | 53 | return None 54 | 55 | def expandingDirections(self): 56 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) 57 | 58 | def hasHeightForWidth(self): 59 | return True 60 | 61 | def heightForWidth(self, width): 62 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True) 63 | return height 64 | 65 | def setGeometry(self, rect): 66 | super(QFlowGridLayout, self).setGeometry(rect) 67 | self._doLayout(rect, False) 68 | 69 | def sizeHint(self): 70 | return self.minimumSize() 71 | 72 | def minimumSize(self): 73 | size = QtCore.QSize() 74 | 75 | for item in self.itemList: 76 | size = size.expandedTo(item.minimumSize()) 77 | 78 | size += QtCore.QSize(2 * self.margin, 2 * self.margin) 79 | return size 80 | 81 | def _doLayout(self, rect, testOnly): 82 | x = rect.x() 83 | y = rect.y() 84 | lineHeight = 0 85 | # SC hacks. I am assuming we are a grid, and all spaceX/spaceY is 86 | # the same. So we only do this once. 87 | 88 | # Find first visible item 89 | item = None 90 | for i in self.itemList: 91 | if i.widget().isVisible(): 92 | item = i 93 | break 94 | if not item: 95 | return 0 96 | wid = item.widget() 97 | spaceX = self.spacing() + wid.style().layoutSpacing( 98 | QSizePolicy.PushButton, 99 | QSizePolicy.PushButton, 100 | QtCore.Qt.Horizontal) 101 | 102 | spaceY = self.spacing() + wid.style().layoutSpacing( 103 | QSizePolicy.PushButton, 104 | QSizePolicy.PushButton, 105 | QtCore.Qt.Vertical) 106 | 107 | itemSize = item.sizeHint() 108 | itemWidth = itemSize.width() 109 | itemHeight = itemSize.height() 110 | 111 | for item in self.itemList: 112 | if not item.widget().isVisible(): 113 | continue 114 | 115 | nextX = x + itemWidth + spaceX 116 | if nextX - spaceX > rect.right() and lineHeight > 0: 117 | x = rect.x() 118 | y = y + lineHeight + spaceY 119 | nextX = x + itemWidth + spaceX 120 | lineHeight = 0 121 | 122 | if not testOnly: 123 | item.setGeometry( 124 | QtCore.QRect(QtCore.QPoint(x, y), itemSize)) 125 | 126 | x = nextX 127 | lineHeight = max(lineHeight, itemHeight) 128 | 129 | return y + lineHeight - rect.y() 130 | 131 | 132 | if __name__ == '__main__': 133 | 134 | import sys 135 | 136 | class Window(QWidget): 137 | def __init__(self): 138 | super(Window, self).__init__() 139 | 140 | flowLayout = QFlowGridLayout() 141 | flowLayout.addWidget(QPushButton("Short")) 142 | flowLayout.addWidget(QPushButton("Longer")) 143 | flowLayout.addWidget(QPushButton("Different text")) 144 | flowLayout.addWidget(QPushButton("More text")) 145 | flowLayout.addWidget(QPushButton("Even longer button text")) 146 | self.setLayout(flowLayout) 147 | 148 | self.setWindowTitle("Flow Layout") 149 | 150 | app = QApplication(sys.argv) 151 | mainWin = Window() 152 | mainWin.show() 153 | sys.exit(app.exec_()) 154 | -------------------------------------------------------------------------------- /Flux/Plugins/RegexSubstitution.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QLabel, 3 | QDialog, 4 | QLineEdit, 5 | QGroupBox, 6 | QCompleter, 7 | QFormLayout, 8 | QComboBox, 9 | QMessageBox, 10 | QCheckBox, 11 | QVBoxLayout, 12 | QTextEdit, 13 | QWidget 14 | ) 15 | from PyQt5.QtGui import QValidator 16 | from Flux.Plugins import FluxPlugin 17 | from Flux.UI.qglyphname import QGlyphName 18 | import re 19 | import fontFeatures 20 | from PyQt5.QtCore import Qt 21 | import sys 22 | from Flux.computedroutine import ComputedRoutine 23 | 24 | 25 | plugin_name = "Regular Expression Substitution" 26 | 27 | 28 | class REValidator(QValidator): 29 | def __init__(self): 30 | super().__init__() 31 | 32 | def validate(self, s, pos): 33 | try: 34 | re.compile(s) 35 | except Exception as e: 36 | return (QValidator.Intermediate, s, pos) 37 | return (QValidator.Acceptable, s, pos) 38 | 39 | def fixup(self, s): 40 | # Trim multiple spaces? 41 | pass 42 | 43 | class Dialog(FluxPlugin): 44 | 45 | def __init__(self, project): 46 | self.project = project 47 | self.glyphnames = self.project.font.keys() 48 | self.routine = None 49 | super().__init__(project) 50 | 51 | def createForm(self): 52 | window = QWidget() 53 | window_layout = QVBoxLayout(window) 54 | 55 | before = QWidget() 56 | before_layout = QFormLayout(before) 57 | 58 | self.routine_name = QLineEdit() 59 | before_layout.addRow(QLabel(f"Routine name"), self.routine_name) 60 | 61 | self.before = QGlyphName(self.project, allow_classes=True, multiple=True) 62 | before_layout.addRow(QLabel(f"Match glyphs before"), self.before) 63 | window_layout.addWidget(before) 64 | 65 | form = QGroupBox() 66 | form_layout = QFormLayout(form) 67 | 68 | self.filter = QLineEdit() 69 | self.filter.setPlaceholderText("Regular expression") 70 | self.filter.setValidator(REValidator()) 71 | 72 | self.match = QLineEdit() 73 | self.match.setPlaceholderText("Regular expression") 74 | self.match.setValidator(REValidator()) 75 | 76 | self.replace = QLineEdit() 77 | 78 | form_layout.addRow(QLabel(f"Match glyphs"), self.filter) 79 | form_layout.addRow(QLabel(f"Replace ..."), self.match) 80 | form_layout.addRow(QLabel(f"With ..."), self.replace) 81 | window_layout.addWidget(form) 82 | 83 | after = QWidget() 84 | after_layout = QFormLayout(after) 85 | self.after = QGlyphName(self.project, allow_classes=True, multiple=True) 86 | after_layout.addRow(QLabel(f"Match glyphs after"), self.after) 87 | window_layout.addWidget(after) 88 | 89 | self.preview = QTextEdit() 90 | window_layout.addWidget(self.preview) 91 | 92 | self.before.changed.connect(self.recompute) 93 | self.filter.textChanged.connect(self.recompute) 94 | self.match.textChanged.connect(self.recompute) 95 | self.replace.textChanged.connect(self.recompute) 96 | self.after.changed.connect(self.recompute) 97 | 98 | return window 99 | 100 | def parameters(self): 101 | return { 102 | "routine": self.routine_name.text(), 103 | "before": self.before.text(), 104 | "filter": self.filter.text(), 105 | "match": self.match.text(), 106 | "replace": self.replace.text(), 107 | "after": self.after.text(), 108 | } 109 | 110 | def recompute(self): 111 | if not self.filter.hasAcceptableInput() or not self.match.hasAcceptableInput(): 112 | return 113 | p = self.parameters() 114 | self.routine = ComputedRoutine(name=p["routine"], parameters = p) 115 | self.routine.plugin = __name__ 116 | self.routine.project = self.project 117 | self.routine.module = sys.modules[__name__] 118 | self.preview.setText(self.routine.asFea()) 119 | 120 | 121 | def accept(self): 122 | self.project.fontfeatures.routines.extend([self.routine]) 123 | return super().accept() 124 | 125 | # Disable enter 126 | def keyPressEvent(self, evt): 127 | if evt.key() == Qt.Key_Enter or evt.key() == Qt.Key_Return: 128 | return 129 | return super().keyPressEvent(evt) 130 | 131 | 132 | def rulesFromComputedRoutine(routine): 133 | p = routine.parameters 134 | glyphnames = routine.project.font.keys() 135 | rules = [] 136 | for g in glyphnames: 137 | if not re.search(p["filter"], g): 138 | continue 139 | try: 140 | new = re.sub(p["match"], p["replace"], g) 141 | except: 142 | continue 143 | if new not in glyphnames: 144 | continue 145 | sub = fontFeatures.Substitution( [[g]], [[new]]) 146 | # XXX classes 147 | if p["before"]: 148 | sub.input = [ [glyph] for glyph in p["before"].split() ] + sub.input 149 | if p["after"]: 150 | sub.input.extend([ [glyph] for glyph in p["after"].split() ]) 151 | rules.append(sub) 152 | return rules 153 | -------------------------------------------------------------------------------- /Flux/constants.py: -------------------------------------------------------------------------------- 1 | 2 | FEATURE_DESCRIPTIONS = { 3 | 'aalt': "Access All Alternates", 4 | 'abvf': "Above-base Forms", 5 | 'abvm': "Above-base Mark Positioning", 6 | 'abvs': "Above-base Substitutions", 7 | 'afrc': "Alternative Fractions", 8 | 'akhn': "Akhands", 9 | 'blwf': "Below-base Forms", 10 | 'blwm': "Below-base Mark Positioning", 11 | 'blws': "Below-base Substitutions", 12 | 'calt': "Contextual Alternates", 13 | 'case': "Case-Sensitive Forms", 14 | 'ccmp': "Glyph Composition / Decomposition", 15 | 'cfar': "Conjunct Form After Ro", 16 | 'chws': "Contextual Half-width Spacing", 17 | 'cjct': "Conjunct Forms", 18 | 'clig': "Contextual Ligatures", 19 | 'cpct': "Centered CJK Punctuation", 20 | 'cpsp': "Capital Spacing", 21 | 'cswh': "Contextual Swash", 22 | 'curs': "Cursive Positioning", 23 | 'c2pc': "Petite Capitals From Capitals", 24 | 'c2sc': "Small Capitals From Capitals", 25 | 'dist': "Distances", 26 | 'dlig': "Discretionary Ligatures", 27 | 'dnom': "Denominators", 28 | 'dtls': "Dotless Forms", 29 | 'expt': "Expert Forms", 30 | 'falt': "Final Glyph on Line Alternates", 31 | 'fin2': "Terminal Forms #2", 32 | 'fin3': "Terminal Forms #3", 33 | 'fina': "Terminal Forms", 34 | 'flac': "Flattened accent forms", 35 | 'frac': "Fractions", 36 | 'fwid': "Full Widths", 37 | 'half': "Half Forms", 38 | 'haln': "Halant Forms", 39 | 'halt': "Alternate Half Widths", 40 | 'hist': "Historical Forms", 41 | 'hkna': "Horizontal Kana Alternates", 42 | 'hlig': "Historical Ligatures", 43 | 'hngl': "Hangul", 44 | 'hojo': "Hojo Kanji Forms (JIS X 0212-1990 Kanji Forms)", 45 | 'hwid': "Half Widths", 46 | 'init': "Initial Forms", 47 | 'isol': "Isolated Forms", 48 | 'ital': "Italics", 49 | 'jalt': "Justification Alternates", 50 | 'jp78': "JIS78 Forms", 51 | 'jp83': "JIS83 Forms", 52 | 'jp90': "JIS90 Forms", 53 | 'jp04': "JIS2004 Forms", 54 | 'kern': "Kerning", 55 | 'lfbd': "Left Bounds", 56 | 'liga': "Standard Ligatures", 57 | 'ljmo': "Leading Jamo Forms", 58 | 'lnum': "Lining Figures", 59 | 'locl': "Localized Forms", 60 | 'ltra': "Left-to-right alternates", 61 | 'ltrm': "Left-to-right mirrored forms", 62 | 'mark': "Mark Positioning", 63 | 'med2': "Medial Forms #2", 64 | 'medi': "Medial Forms", 65 | 'mgrk': "Mathematical Greek", 66 | 'mkmk': "Mark to Mark Positioning", 67 | 'mset': "Mark Positioning via Substitution", 68 | 'nalt': "Alternate Annotation Forms", 69 | 'nlck': "NLC Kanji Forms", 70 | 'nukt': "Nukta Forms", 71 | 'numr': "Numerators", 72 | 'onum': "Oldstyle Figures", 73 | 'opbd': "Optical Bounds", 74 | 'ordn': "Ordinals", 75 | 'ornm': "Ornaments", 76 | 'palt': "Proportional Alternate Widths", 77 | 'pcap': "Petite Capitals", 78 | 'pkna': "Proportional Kana", 79 | 'pnum': "Proportional Figures", 80 | 'pref': "Pre-Base Forms", 81 | 'pres': "Pre-base Substitutions", 82 | 'pstf': "Post-base Forms", 83 | 'psts': "Post-base Substitutions", 84 | 'pwid': "Proportional Widths", 85 | 'qwid': "Quarter Widths", 86 | 'rand': "Randomize", 87 | 'rclt': "Required Contextual Alternates", 88 | 'rkrf': "Rakar Forms", 89 | 'rlig': "Required Ligatures", 90 | 'rphf': "Reph Forms", 91 | 'rtbd': "Right Bounds", 92 | 'rtla': "Right-to-left alternates", 93 | 'rtlm': "Right-to-left mirrored forms", 94 | 'ruby': "Ruby Notation Forms", 95 | 'rvrn': "Required Variation Alternates", 96 | 'salt': "Stylistic Alternates", 97 | 'sinf': "Scientific Inferiors", 98 | 'size': "Optical size", 99 | 'smcp': "Small Capitals", 100 | 'smpl': "Simplified Forms", 101 | 'ss01': "Stylistic Set 1", 102 | 'ss02': "Stylistic Set 2", 103 | 'ss03': "Stylistic Set 3", 104 | 'ss04': "Stylistic Set 4", 105 | 'ss05': "Stylistic Set 5", 106 | 'ss06': "Stylistic Set 6", 107 | 'ss07': "Stylistic Set 7", 108 | 'ss08': "Stylistic Set 8", 109 | 'ss09': "Stylistic Set 9", 110 | 'ss10': "Stylistic Set 10", 111 | 'ss11': "Stylistic Set 11", 112 | 'ss12': "Stylistic Set 12", 113 | 'ss13': "Stylistic Set 13", 114 | 'ss14': "Stylistic Set 14", 115 | 'ss15': "Stylistic Set 15", 116 | 'ss16': "Stylistic Set 16", 117 | 'ss17': "Stylistic Set 17", 118 | 'ss18': "Stylistic Set 18", 119 | 'ss19': "Stylistic Set 19", 120 | 'ss20': "Stylistic Set 20", 121 | 'ssty': "Math script style alternates", 122 | 'stch': "Stretching Glyph Decomposition", 123 | 'subs': "Subscript", 124 | 'sups': "Superscript", 125 | 'swsh': "Swash", 126 | 'titl': "Titling", 127 | 'tjmo': "Trailing Jamo Forms", 128 | 'tnam': "Traditional Name Forms", 129 | 'tnum': "Tabular Figures", 130 | 'trad': "Traditional Forms", 131 | 'twid': "Third Widths", 132 | 'unic': "Unicase", 133 | 'valt': "Alternate Vertical Metrics", 134 | 'vatu': "Vattu Variants", 135 | 'vchw': "Vertical Contextual Half-width Spacing", 136 | 'vert': "Vertical Writing", 137 | 'vhal': "Alternate Vertical Half Metrics", 138 | 'vjmo': "Vowel Jamo Forms", 139 | 'vkna': "Vertical Kana Alternates", 140 | 'vkrn': "Vertical Kerning", 141 | 'vpal': "Proportional Alternate Vertical Metrics", 142 | 'vrt2': "Vertical Alternates and Rotation", 143 | 'vrtr': "Vertical Alternates for Rotation", 144 | 'zero': "Slashed Zero", 145 | } 146 | 147 | for i in range(1,100): 148 | FEATURE_DESCRIPTIONS["cv%02i" % i] = "Character variant "+str(i) 149 | -------------------------------------------------------------------------------- /Flux/UI/GlyphActions.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from lxml import etree 3 | from Flux.UI.qglyphname import QGlyphPicker, QGlyphBox 4 | from PyQt5.QtWidgets import ( 5 | QWidget, 6 | QDialog, 7 | QFormLayout, 8 | QSpinBox, 9 | QApplication, 10 | QVBoxLayout, 11 | QLabel, 12 | QDialogButtonBox, 13 | QComboBox, 14 | ) 15 | from PyQt5.QtCore import Qt 16 | from typing import Optional 17 | 18 | 19 | @dataclass 20 | class GlyphAction: 21 | glyph: str 22 | width: Optional[int] = None 23 | category: Optional[str] = None 24 | duplicate_from: Optional[str] = None 25 | 26 | def toXML(self): 27 | root = etree.Element("glyphaction") 28 | root.attrib["glyph"] = self.glyph 29 | if self.duplicate_from: 30 | root.attrib["duplicate_from"] = self.duplicate_from 31 | if self.width is not None: 32 | root.attrib["width"] = str(self.width) 33 | if self.category: 34 | root.attrib["category"] = self.category 35 | return root 36 | 37 | @classmethod 38 | def fromXML(klass, el): 39 | return klass( 40 | glyph=el.get("glyph"), 41 | duplicate_from=el.get("duplicate_from"), 42 | width=int(el.get("width")), 43 | category=el.get("category"), 44 | ) 45 | 46 | def perform(self, font): 47 | if self.duplicate_from: 48 | font[self.glyph] = font[self.duplicate_from].copy() 49 | if self.width is not None: 50 | font[self.glyph].width = self.width 51 | if self.category: 52 | font[self.glyph].set_category(self.category) 53 | 54 | def doesSomething(self, font): 55 | effect = self.duplicate_from is not None 56 | if self.width is not None and self.width != font[self.glyph].width: 57 | effect = True 58 | if self.category is not None and self.category != font[self.glyph].category: 59 | effect = True 60 | return effect 61 | 62 | 63 | class QGlyphActionDialog(QDialog): 64 | def __init__(self, project, glyphname): 65 | super(QDialog, self).__init__() 66 | v_box_1 = QVBoxLayout() 67 | self.project = project 68 | self.glyph = self.project.font[glyphname] 69 | 70 | self.formWidget = QWidget() 71 | self.formLayout = QFormLayout() 72 | self.formWidget.setLayout(self.formLayout) 73 | 74 | self.widthLine = QSpinBox() 75 | self.widthLine.setMinimum(0) 76 | self.widthLine.setMaximum(2000) 77 | self.widthLine.setValue(self.glyph.width) 78 | 79 | self.categoryCB = QComboBox() 80 | items = ["base", "ligature", "mark"] 81 | self.categoryCB.addItems(items) 82 | if self.glyph.category in items: 83 | self.categoryCB.setCurrentIndex(items.index(self.glyph.category)) 84 | 85 | self.formLayout.addRow(QLabel(glyphname)) 86 | self.formLayout.addRow(QLabel("Width"), self.widthLine) 87 | self.formLayout.addRow(QLabel("Category"), self.categoryCB) 88 | 89 | v_box_1.addWidget(self.formWidget) 90 | 91 | buttons = QDialogButtonBox( 92 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self 93 | ) 94 | buttons.accepted.connect(self.accept) 95 | buttons.rejected.connect(self.reject) 96 | v_box_1.addWidget(buttons) 97 | self.setLayout(v_box_1) 98 | 99 | def accept(self): 100 | self.action = self.project.glyphactions.get( 101 | self.glyph.name, GlyphAction(glyph=self.glyph.name) 102 | ) 103 | newWidth = self.widthLine.value() 104 | if newWidth != self.glyph.width: 105 | self.action.width = newWidth 106 | newCategory = self.categoryCB.currentText() 107 | if newCategory != self.glyph.category: 108 | self.action.category = newCategory 109 | return super().accept() 110 | 111 | 112 | class QGlyphActionBox(QGlyphBox): 113 | def mouseDoubleClickEvent(self, event): 114 | dialog = QGlyphActionDialog(self.parent.project, self.glyph) 115 | result = dialog.exec_() 116 | if result: 117 | action = dialog.action 118 | if action.doesSomething(self.parent.project.font): 119 | print(etree.tostring(action.toXML())) 120 | action.perform(self.parent.project.font) 121 | self.parent.project.glyphactions[action.glyph] = action 122 | 123 | 124 | class QGlyphActionPicker(QGlyphPicker): 125 | def setupGrid(self): 126 | self.clearLayout(self.qgrid) 127 | for g in self.project.font.keys(): 128 | w = QGlyphActionBox(self, g) 129 | self.widgets[g] = w 130 | 131 | @classmethod 132 | def pickGlyph(self, project): 133 | dialog = QGlyphActionPicker(project) 134 | result = dialog.exec_() 135 | if dialog.selected: 136 | return dialog.selected.glyph 137 | 138 | 139 | if __name__ == "__main__": 140 | from Flux.project import FluxProject 141 | import sys 142 | 143 | app = 0 144 | if QApplication.instance(): 145 | app = QApplication.instance() 146 | else: 147 | app = QApplication(sys.argv) 148 | proj = FluxProject("qalam.fluxml") 149 | QGlyphActionPicker.pickGlyph(proj) 150 | sys.exit(app.exec_()) 151 | -------------------------------------------------------------------------------- /Flux/UI/qattachmenteditor.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QWidget, 3 | QApplication, 4 | QVBoxLayout, 5 | QLabel, 6 | QComboBox, 7 | QListWidget, 8 | QCheckBox 9 | ) 10 | from PyQt5.QtCore import pyqtSlot 11 | from fontFeatures.shaperLib.Shaper import Shaper 12 | from fontFeatures.shaperLib.Buffer import Buffer 13 | from .qruleeditor import QRuleEditor 14 | from fontFeatures import Attachment 15 | import sys 16 | 17 | class QAttachmentEditor(QRuleEditor): 18 | @pyqtSlot() 19 | def changeRepresentativeString(self): 20 | l = self.sender() 21 | glyph = l.currentItem().text() 22 | if l.role == "base": 23 | self.representative_string[0] = glyph 24 | else: 25 | self.representative_string[1] = glyph 26 | 27 | self.resetBuffer() 28 | 29 | @pyqtSlot() 30 | def updateRule(self): 31 | combo = self.sender() 32 | if combo.role == "mark": 33 | self.rule.mark_name = combo.currentText() 34 | self.rule.marks = self.project.fontfeatures.anchors[self.rule.mark_name] 35 | else: 36 | self.rule.base_name = combo.currentText() 37 | self.rule.bases = self.project.fontfeatures.anchors[self.rule.base_name] 38 | self.arrangeSlots() 39 | self.representative_string = self.makeRepresentativeString() 40 | self.resetBuffer() 41 | 42 | def makeFeatureButtons(self): 43 | self.clearLayout(self.featureButtonLayout) 44 | for f in self.availableFeatures: 45 | self.selectedFeatures.append(f) 46 | featureButton = QCheckBox(f) 47 | featureButton.setChecked(False) 48 | featureButton.stateChanged.connect(self.resetBuffer) 49 | self.featureButtonLayout.addWidget(featureButton) 50 | 51 | def makeASlot(self, anchorname, title): 52 | slot = QWidget() 53 | slotLayout = QVBoxLayout() 54 | slot.setLayout(slotLayout) 55 | 56 | label = QLabel(title[0].upper() + title[1:]) 57 | slotLayout.addWidget(label) 58 | 59 | anchorChooser = QComboBox() 60 | anchors = list(sorted(self.project.fontfeatures.anchors.keys())) 61 | for a in anchors: 62 | anchorChooser.addItem(a) 63 | if anchorname in anchors: 64 | anchorChooser.setCurrentIndex(anchors.index(anchorname)) 65 | anchorChooser.role = title 66 | anchorChooser.currentIndexChanged.connect(self.updateRule) 67 | slotLayout.addWidget(anchorChooser) 68 | 69 | slotLayout.addStretch() 70 | 71 | glyphList = QListWidget() 72 | if anchorname in self.project.fontfeatures.anchors: 73 | for g in self.project.fontfeatures.anchors[anchorname]: 74 | glyphList.addItem(g) 75 | glyphList.role = title 76 | glyphList.currentItemChanged.connect(self.changeRepresentativeString) 77 | slotLayout.addWidget(glyphList) 78 | return slot 79 | 80 | def resetBuffer(self): 81 | # We're not going to display the feature code because it's horrible. 82 | before = self.makeBuffer("before") 83 | if before and len(before.items) == 2: 84 | before.items[0].color = (255,120,120) 85 | before.items[-1].color = (120,255,120) 86 | if self.rule and self.rule.base_name and self.rule.mark_name: 87 | before.items[0].anchor = self.rule.bases[self.representative_string[0]] 88 | before.items[1].anchor = self.rule.marks[self.representative_string[1]] 89 | self.outputview_before.set_buf(before) 90 | self.outputview_after.set_buf(self.makeBuffer("after")) 91 | 92 | 93 | def arrangeSlots(self): 94 | self.clearLayout(self.slotview) 95 | if not self.rule: 96 | return 97 | 98 | self.slotview.addWidget(self.makeASlot(self.rule.base_name, "base")) 99 | self.slotview.addStretch() 100 | self.slotview.addWidget(self.makeASlot(self.rule.mark_name, "mark")) 101 | 102 | def makeRepresentativeString(self): 103 | inputglyphs = [] 104 | if not self.rule or not self.rule.bases or not self.rule.marks: 105 | return inputglyphs 106 | 107 | inputglyphs = [ list(self.rule.bases.keys())[0], list(self.rule.marks.keys())[0] ] 108 | 109 | # We use this representative string to guess information about 110 | # how the *real* shaping process will take place; buffer direction 111 | # and script, and hence choice of complex shaper, and hence from 112 | # that choice of features to be processed. 113 | unicodes = [self.project.font.codepointForGlyph(x) for x in inputglyphs] 114 | unicodes = [x for x in unicodes if x] 115 | tounicodes = "".join(map (chr, unicodes)) 116 | bufferForGuessing = Buffer(self.project.font, unicodes = tounicodes) 117 | self.buffer_direction = bufferForGuessing.direction 118 | self.buffer_script = bufferForGuessing.script 119 | # print("Guessed buffer direction ", self.buffer_direction) 120 | # print("Guessed buffer script ", self.buffer_script) 121 | shaper = Shaper(self.project.fontfeatures, self.project.font) 122 | shaper.execute(bufferForGuessing) 123 | self.availableFeatures = [] 124 | for stage in shaper.stages: 125 | if not isinstance(stage, list): 126 | continue 127 | for f in stage: 128 | if f not in self.availableFeatures and f in self.project.fontfeatures.features: 129 | self.availableFeatures.append(f) 130 | self.makeFeatureButtons() 131 | 132 | return inputglyphs 133 | 134 | if __name__ == "__main__": 135 | from Flux.project import FluxProject 136 | 137 | app = 0 138 | if QApplication.instance(): 139 | app = QApplication.instance() 140 | else: 141 | app = QApplication(sys.argv) 142 | 143 | w = QWidget() 144 | w.resize(510, 210) 145 | v_box_1 = QVBoxLayout() 146 | 147 | proj = FluxProject("qalam.fluxml") 148 | proj.fontfeatures.features["mark"] = [proj.fontfeatures.routines[2]] 149 | proj.fontfeatures.features["curs"] = [proj.fontfeatures.routines[1]] 150 | 151 | rule = Attachment( 152 | "top", "_top", proj.fontfeatures.anchors["top"], proj.fontfeatures.anchors["_top"] 153 | ) 154 | v_box_1.addWidget(QAttachmentEditor(proj, None, rule)) 155 | 156 | w.setLayout(v_box_1) 157 | 158 | w.show() 159 | sys.exit(app.exec_()) 160 | -------------------------------------------------------------------------------- /attic/vharfbuzz.py: -------------------------------------------------------------------------------- 1 | """A user-friendlier way to use Harfbuzz in Python.""" 2 | 3 | import uharfbuzz as hb 4 | from fontTools.ttLib import TTFont 5 | import re 6 | 7 | 8 | class Vharfbuzz: 9 | def __init__(self, filename): 10 | """Opens a font file and gets ready to shape text.""" 11 | self.filename = filename 12 | with open(self.filename, "rb") as fontfile: 13 | self.fontdata = fontfile.read() 14 | self.ttfont = TTFont(filename) 15 | self.glyphOrder = self.ttfont.getGlyphOrder() 16 | self.prepare_shaper() 17 | self.shapers = None 18 | self.drawfuncs = None 19 | 20 | def prepare_shaper(self): 21 | face = hb.Face(self.fontdata) 22 | font = hb.Font(face) 23 | upem = face.upem 24 | self.upem = upem 25 | font.scale = (upem, upem) 26 | hb.ot_font_set_funcs(font) 27 | self.hbfont = font 28 | 29 | def make_message_handling_function(self, buf, onchange): 30 | self.history = {"GSUB": [], "GPOS": []} 31 | self.lastLookupID = None 32 | def handle_message(msg, buf2): 33 | print(msg) 34 | m = re.match("start lookup (\\d+)", msg) 35 | if m: 36 | lookupid = int(m[1]) 37 | self.history[self.stage].append(self.serialize_buf(buf2)) 38 | 39 | m = re.match("end lookup (\\d+)", msg) 40 | if m: 41 | lookupid = int(m[1]) 42 | # if self.serialize_buf(buf2) != self.history[self.stage][-1]: 43 | onchange(self, self.stage, lookupid, self._copy_buf(buf2)) 44 | self.history[self.stage].pop() 45 | if msg.startswith("start GPOS stage"): 46 | self.stage = "GPOS" 47 | return True 48 | 49 | return handle_message 50 | 51 | def shape(self, text, onchange=None): 52 | """Shapes a text 53 | 54 | This shapes a piece of text, return a uharfbuzz `Buffer` object. 55 | 56 | Additionally, if an `onchange` function is provided, this will be called 57 | every time the buffer changes *during* shaping, with the following arguments: 58 | 59 | - ``self``: the vharfbuzz object. 60 | - ``stage``: either "GSUB" or "GPOS" 61 | - ``lookupid``: the current lookup ID 62 | - ``buffer``: a copy of the buffer as a list of lists (glyphname, cluster, position) 63 | """ 64 | 65 | self.prepare_shaper() 66 | buf = hb.Buffer() 67 | buf.add_str(text) 68 | buf.guess_segment_properties() 69 | self.stage = "GSUB" 70 | if onchange: 71 | f = self.make_message_handling_function(buf, onchange) 72 | buf.set_message_func(f) 73 | hb.shape(self.hbfont, buf, shapers=self.shapers) 74 | self.stage = "GPOS" 75 | return buf 76 | 77 | def _copy_buf(self, buf): 78 | # Or at least the bits we care about 79 | outs = [] 80 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions): 81 | l = [self.glyphOrder[info.codepoint], info.cluster] 82 | if self.stage == "GPOS": 83 | l.append(pos.position) 84 | else: 85 | l.append(None) 86 | outs.append(l) 87 | return outs 88 | 89 | def serialize_buf(self, buf): 90 | """Returns the contents of the given buffer in a string format similar to 91 | that used by hb-shape.""" 92 | outs = [] 93 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions): 94 | outs.append("%s=%i" % (self.glyphOrder[info.codepoint], info.cluster)) 95 | if self.stage == "GPOS": 96 | outs[-1] = outs[-1] + "+%i" % (pos.position[2]) 97 | if self.stage == "GPOS" and (pos.position[0] != 0 or pos.position[1] != 0): 98 | outs[-1] = outs[-1] + "@<%i,%i>" % (pos.position[0], pos.position[1]) 99 | return "|".join(outs) 100 | 101 | def setup_svg_draw_funcs(self): 102 | if self.drawfuncs: 103 | return 104 | 105 | def move_to(x, y, c): 106 | c["output_string"] = c["output_string"] + f"M{x},{y}" 107 | 108 | def line_to(x, y, c): 109 | c["output_string"] = c["output_string"] + f"L{x},{y}" 110 | 111 | def cubic_to(c1x, c1y, c2x, c2y, x, y, c): 112 | c["output_string"] = ( 113 | c["output_string"] + f"C{c1x},{c1y} {c2x},{c2y} {x},{y}" 114 | ) 115 | 116 | def quadratic_to(c1x, c1y, x, y, c): 117 | c["output_string"] = c["output_string"] + f"Q{c1x},{c1y} {x},{y}" 118 | 119 | def close_path(c): 120 | c["output_string"] = c["output_string"] + "Z" 121 | 122 | self.drawfuncs = hb.DrawFuncs.create() 123 | self.drawfuncs.set_move_to_func(move_to) 124 | self.drawfuncs.set_line_to_func(line_to) 125 | self.drawfuncs.set_cubic_to_func(cubic_to) 126 | self.drawfuncs.set_quadratic_to_func(quadratic_to) 127 | self.drawfuncs.set_close_path_func(close_path) 128 | 129 | def glyph_to_svg_path(self, gid): 130 | if not hasattr(hb, "DrawFuncs"): 131 | raise ValueError( 132 | "glyph_to_svg_path requires uharfbuzz with draw function support" 133 | ) 134 | 135 | self.setup_svg_draw_funcs() 136 | container = {"output_string": ""} 137 | self.drawfuncs.draw_glyph(self.hbfont, gid, container) 138 | return container["output_string"] 139 | 140 | def buf_to_svg(self, buf): 141 | x_cursor = 0 142 | y_cursor = 0 143 | paths = [] 144 | svg = "" 145 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions): 146 | glyph_path = self.glyph_to_svg_path(info.codepoint) 147 | dx, dy = pos.position[0], pos.position[1] 148 | p = ( 149 | f'\n' 151 | ) 152 | svg += p 153 | x_cursor += pos.position[2] 154 | y_cursor += pos.position[3] 155 | 156 | svg = ( 157 | ( 158 | f'\n' 160 | ) 161 | + svg 162 | + "\n" 163 | ) 164 | return svg 165 | 166 | 167 | # v = Vharfbuzz("/Users/simon/Library/Fonts/SourceSansPro-Regular.otf") 168 | # buf = v.shape("ABCj") 169 | # svg = v.buf_to_svg(buf) 170 | # import cairosvg 171 | # cairosvg.svg2png(bytestring=svg, write_to="foo.png") 172 | -------------------------------------------------------------------------------- /Flux/UI/qbufferrenderer.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QPoint, QMargins, Qt, QRectF 2 | from PyQt5.QtGui import QGlyphRun, QPainter, QRawFont, QColor, QTransform, QPainterPath, QPen 3 | from PyQt5.QtWidgets import QWidget, QGraphicsScene, QGraphicsPathItem, QGraphicsView 4 | import glyphsLib 5 | import darkdetect 6 | 7 | inkcolor = (0,0,0) 8 | if darkdetect.isDark(): 9 | inkcolor = (255,255,255) 10 | 11 | class QBufferRenderer(QGraphicsView): 12 | def __init__(self, project, buf=None): 13 | super(QBufferRenderer, self).__init__() 14 | 15 | self.project = project 16 | self.buf = buf 17 | self.location = None 18 | self.margins = QMargins(25, 25, 25, 25) 19 | self.setRenderHint(QPainter.Antialiasing) 20 | self.setRenderHint(QPainter.HighQualityAntialiasing) 21 | self.scene = QGraphicsScene(self) 22 | self.setScene(self.scene) 23 | self.set_scene_from_buf() 24 | 25 | def set_scene_from_buf(self): 26 | self.scene.clear() 27 | xcursor = 0 28 | if self.buf is not None and len(self.buf.items) > 0: 29 | items = self.buf.items 30 | if self.buf.direction == "RTL": 31 | items = list(reversed(items)) 32 | for g in items: 33 | color = inkcolor 34 | if hasattr(g, "color"): 35 | color = g.color 36 | glyph = g.glyph 37 | if hasattr(g, "anchor"): 38 | self.drawCross(self.scene, xcursor + g.anchor[0], g.anchor[1], color) 39 | if not glyph in self.project.font: 40 | continue 41 | self.drawGlyph(self.scene, glyph, xcursor + (g.position.xPlacement or 0), (g.position.yPlacement or 0), color) 42 | # else: 43 | xcursor = xcursor + g.position.xAdvance 44 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio) 45 | 46 | def getGlyphContours(self, glyphname): 47 | if self.location: 48 | vf = self.project.variations 49 | interpolated_glyph = self.interpolate(glyphname) 50 | paths = list(interpolated_glyph.contours) 51 | else: 52 | paths = list(self.project.font[glyphname].contours) 53 | return paths 54 | 55 | def decomposedPaths(self, glyphname, item=None): 56 | paths = self.getGlyphContours(glyphname) 57 | for ix, c in enumerate(self.project.font[glyphname].components): 58 | 59 | glyph = c.baseGlyph 60 | componentPaths = [x.copy() for x in self.getGlyphContours(glyph)] 61 | if self.location: 62 | transformation = self.interpolate_component_transformation(glyphname, ix) 63 | else: 64 | transformation = c.transformation 65 | for cp in componentPaths: 66 | cp.transformBy(transformation) 67 | paths.append(cp) 68 | return paths 69 | 70 | def drawCross(self, scene, x, y, color): 71 | path = QPainterPath() 72 | path.moveTo(x-50, y) 73 | path.lineTo(x+50, y) 74 | path.moveTo(x, y-50) 75 | path.lineTo(x, y+50) 76 | line = QGraphicsPathItem() 77 | p = QPen( QColor(*color) ) 78 | p.setWidth(5) 79 | line.setPen(p ) 80 | line.setPath(path) 81 | reflect = QTransform(1,0,0,-1,0,0) 82 | line.setTransform(reflect) 83 | scene.addItem(line) 84 | 85 | def set_location(self, location): 86 | self.location = self.project.variations.normalize(location) 87 | self.set_scene_from_buf() 88 | 89 | def interpolate(self, glyphname): 90 | vf = self.project.variations 91 | glyphs = [vf.masters[master][glyphname] for master in vf.master_order] 92 | glyph = self.project.font[glyphname] 93 | for pid, paths in enumerate(zip(*[g.contours for g in glyphs])): 94 | for pointid,points in enumerate(zip(*[p.points for p in paths])): 95 | target_point = glyph.contours[pid].points[pointid] 96 | pointset = {vf.master_order[i]: (points[i].x,points[i].y) for i in range(len(vf.masters))} 97 | (interpolated) = vf.interpolate_tuples(pointset, self.location, normalized=True) 98 | target_point.x, target_point.y = interpolated 99 | return glyph 100 | 101 | def interpolate_component_transformation(self, glyphname, ix): 102 | vf = self.project.variations 103 | transformations = { 104 | master: vf.masters[master][glyphname].components[ix].transformation 105 | for master in vf.master_order 106 | } 107 | return vf.interpolate_tuples(transformations, self.location, normalized=True) 108 | 109 | def drawGlyph(self, scene, glyph, offsetX=0, offsetY=0, color=(255,255,255)): 110 | path = QPainterPath() 111 | path.setFillRule(Qt.WindingFill) 112 | for c in self.decomposedPaths(glyph): 113 | segs = c.segments 114 | path.moveTo(segs[-1].points[-1].x, segs[-1].points[-1].y) 115 | for seg in segs: 116 | tuples = [(a.x, a.y) for a in seg.points] 117 | flattuples = list(sum(tuples,())) 118 | if len(tuples) == 2: 119 | path.quadTo(*flattuples) 120 | elif len(tuples) == 3: 121 | path.cubicTo(*flattuples) 122 | else: 123 | path.lineTo(*flattuples) 124 | 125 | line = QGraphicsPathItem() 126 | line.setBrush( QColor(*color) ) 127 | p = QPen() 128 | p.setStyle(Qt.NoPen) 129 | line.setPen(p) 130 | line.setPath(path) 131 | reflect = QTransform(1,0,0,-1,0,0) 132 | reflect.translate(offsetX, offsetY) 133 | # print(f"Drawing {glyph} at offset {offsetX} {offsetY}") 134 | line.setTransform(reflect) 135 | scene.addItem(line) 136 | 137 | def set_buf(self, buf): 138 | self.buf = buf 139 | self.set_scene_from_buf() 140 | 141 | def resizeEvent(self, e): 142 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio) 143 | 144 | if __name__ == "__main__": 145 | from Flux.project import FluxProject 146 | from PyQt5.QtWidgets import QApplication, QVBoxLayout 147 | from fontFeatures.shaperLib.Buffer import Buffer 148 | import sys 149 | 150 | app = 0 151 | if QApplication.instance(): 152 | app = QApplication.instance() 153 | else: 154 | app = QApplication(sys.argv) 155 | 156 | w = QWidget() 157 | w.resize(510, 210) 158 | v_box_1 = QVBoxLayout() 159 | 160 | proj = FluxProject.new("Rajdhani.glyphs") 161 | buf = Buffer(proj.font, unicodes = "ABC") 162 | buf.map_to_glyphs() 163 | v_box_1.addWidget(QBufferRenderer(proj,buf)) 164 | 165 | w.setLayout(v_box_1) 166 | 167 | w.show() 168 | sys.exit(app.exec_()) 169 | -------------------------------------------------------------------------------- /Flux/ThirdParty/qtoaster.py: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/59251823/is-there-an-equivalent-of-toastr-for-pyqt 2 | 3 | from PyQt5 import QtCore, QtWidgets, QtGui 4 | 5 | class QToaster(QtWidgets.QFrame): 6 | closed = QtCore.pyqtSignal() 7 | 8 | def __init__(self, *args, **kwargs): 9 | super(QToaster, self).__init__(*args, **kwargs) 10 | QtWidgets.QHBoxLayout(self) 11 | 12 | self.setSizePolicy(QtWidgets.QSizePolicy.Maximum, 13 | QtWidgets.QSizePolicy.Maximum) 14 | 15 | self.setStyleSheet(''' 16 | QToaster { 17 | border: 1px solid black; 18 | border-radius: 4px; 19 | background: palette(window); 20 | } 21 | ''') 22 | # alternatively: 23 | # self.setAutoFillBackground(True) 24 | # self.setFrameShape(self.Box) 25 | 26 | self.timer = QtCore.QTimer(singleShot=True, timeout=self.hide) 27 | 28 | if self.parent(): 29 | self.opacityEffect = QtWidgets.QGraphicsOpacityEffect(opacity=0) 30 | self.setGraphicsEffect(self.opacityEffect) 31 | self.opacityAni = QtCore.QPropertyAnimation(self.opacityEffect, b'opacity') 32 | # we have a parent, install an eventFilter so that when it's resized 33 | # the notification will be correctly moved to the right corner 34 | self.parent().installEventFilter(self) 35 | else: 36 | # there's no parent, use the window opacity property, assuming that 37 | # the window manager supports it; if it doesn't, this won'd do 38 | # anything (besides making the hiding a bit longer by half a second) 39 | self.opacityAni = QtCore.QPropertyAnimation(self, b'windowOpacity') 40 | self.opacityAni.setStartValue(0.) 41 | self.opacityAni.setEndValue(1.) 42 | self.opacityAni.setDuration(100) 43 | self.opacityAni.finished.connect(self.checkClosed) 44 | 45 | self.corner = QtCore.Qt.TopLeftCorner 46 | self.margin = 10 47 | 48 | def checkClosed(self): 49 | # if we have been fading out, we're closing the notification 50 | if self.opacityAni.direction() == self.opacityAni.Backward: 51 | self.close() 52 | 53 | def restore(self): 54 | # this is a "helper function", that can be called from mouseEnterEvent 55 | # and when the parent widget is resized. We will not close the 56 | # notification if the mouse is in or the parent is resized 57 | self.timer.stop() 58 | # also, stop the animation if it's fading out... 59 | self.opacityAni.stop() 60 | # ...and restore the opacity 61 | if self.parent(): 62 | self.opacityEffect.setOpacity(1) 63 | else: 64 | self.setWindowOpacity(1) 65 | 66 | def hide(self): 67 | # start hiding 68 | self.opacityAni.setDirection(self.opacityAni.Backward) 69 | self.opacityAni.setDuration(500) 70 | self.opacityAni.start() 71 | 72 | def eventFilter(self, source, event): 73 | if source == self.parent() and event.type() == QtCore.QEvent.Resize: 74 | self.opacityAni.stop() 75 | parentRect = self.parent().rect() 76 | geo = self.geometry() 77 | if self.corner == QtCore.Qt.TopLeftCorner: 78 | geo.moveTopLeft( 79 | parentRect.topLeft() + QtCore.QPoint(self.margin, self.margin)) 80 | elif self.corner == QtCore.Qt.TopRightCorner: 81 | geo.moveTopRight( 82 | parentRect.topRight() + QtCore.QPoint(-self.margin, self.margin)) 83 | elif self.corner == QtCore.Qt.BottomRightCorner: 84 | geo.moveBottomRight( 85 | parentRect.bottomRight() + QtCore.QPoint(-self.margin, -self.margin)) 86 | else: 87 | geo.moveBottomLeft( 88 | parentRect.bottomLeft() + QtCore.QPoint(self.margin, -self.margin)) 89 | self.setGeometry(geo) 90 | self.restore() 91 | self.timer.start() 92 | return super(QToaster, self).eventFilter(source, event) 93 | 94 | def enterEvent(self, event): 95 | self.restore() 96 | 97 | def leaveEvent(self, event): 98 | self.timer.start() 99 | 100 | def closeEvent(self, event): 101 | # we don't need the notification anymore, delete it! 102 | self.deleteLater() 103 | 104 | def resizeEvent(self, event): 105 | super(QToaster, self).resizeEvent(event) 106 | # if you don't set a stylesheet, you don't need any of the following! 107 | if not self.parent(): 108 | # there's no parent, so we need to update the mask 109 | path = QtGui.QPainterPath() 110 | path.addRoundedRect(QtCore.QRectF(self.rect()).translated(-.5, -.5), 4, 4) 111 | self.setMask(QtGui.QRegion(path.toFillPolygon(QtGui.QTransform()).toPolygon())) 112 | else: 113 | self.clearMask() 114 | 115 | @staticmethod 116 | def showMessage(parent, message, 117 | icon=QtWidgets.QStyle.SP_MessageBoxInformation, 118 | corner=QtCore.Qt.TopLeftCorner, margin=10, closable=True, 119 | timeout=5000, desktop=False, parentWindow=True): 120 | 121 | if parent and parentWindow: 122 | parent = parent.window() 123 | 124 | if not parent or desktop: 125 | self = QToaster(None) 126 | self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint | 127 | QtCore.Qt.BypassWindowManagerHint) 128 | # This is a dirty hack! 129 | # parentless objects are garbage collected, so the widget will be 130 | # deleted as soon as the function that calls it returns, but if an 131 | # object is referenced to *any* other object it will not, at least 132 | # for PyQt (I didn't test it to a deeper level) 133 | self.__self = self 134 | 135 | currentScreen = QtWidgets.QApplication.primaryScreen() 136 | if parent and parent.window().geometry().size().isValid(): 137 | # the notification is to be shown on the desktop, but there is a 138 | # parent that is (theoretically) visible and mapped, we'll try to 139 | # use its geometry as a reference to guess which desktop shows 140 | # most of its area; if the parent is not a top level window, use 141 | # that as a reference 142 | reference = parent.window().geometry() 143 | else: 144 | # the parent has not been mapped yet, let's use the cursor as a 145 | # reference for the screen 146 | reference = QtCore.QRect( 147 | QtGui.QCursor.pos() - QtCore.QPoint(1, 1), 148 | QtCore.QSize(3, 3)) 149 | maxArea = 0 150 | for screen in QtWidgets.QApplication.screens(): 151 | intersected = screen.geometry().intersected(reference) 152 | area = intersected.width() * intersected.height() 153 | if area > maxArea: 154 | maxArea = area 155 | currentScreen = screen 156 | parentRect = currentScreen.availableGeometry() 157 | else: 158 | self = QToaster(parent) 159 | parentRect = parent.rect() 160 | 161 | self.timer.setInterval(timeout) 162 | 163 | # use Qt standard icon pixmaps; see: 164 | # https://doc.qt.io/qt-5/qstyle.html#StandardPixmap-enum 165 | if isinstance(icon, QtWidgets.QStyle.StandardPixmap): 166 | labelIcon = QtWidgets.QLabel() 167 | self.layout().addWidget(labelIcon) 168 | icon = self.style().standardIcon(icon) 169 | size = self.style().pixelMetric(QtWidgets.QStyle.PM_SmallIconSize) 170 | labelIcon.setPixmap(icon.pixmap(size)) 171 | 172 | self.label = QtWidgets.QLabel(message) 173 | self.layout().addWidget(self.label) 174 | 175 | if closable: 176 | self.closeButton = QtWidgets.QToolButton() 177 | self.layout().addWidget(self.closeButton) 178 | closeIcon = self.style().standardIcon( 179 | QtWidgets.QStyle.SP_TitleBarCloseButton) 180 | self.closeButton.setIcon(closeIcon) 181 | self.closeButton.setAutoRaise(True) 182 | self.closeButton.clicked.connect(self.close) 183 | 184 | self.timer.start() 185 | 186 | # raise the widget and adjust its size to the minimum 187 | self.raise_() 188 | self.adjustSize() 189 | 190 | self.corner = corner 191 | self.margin = margin 192 | 193 | geo = self.geometry() 194 | # now the widget should have the correct size hints, let's move it to the 195 | # right place 196 | if corner == QtCore.Qt.TopLeftCorner: 197 | geo.moveTopLeft( 198 | parentRect.topLeft() + QtCore.QPoint(margin, margin)) 199 | elif corner == QtCore.Qt.TopRightCorner: 200 | geo.moveTopRight( 201 | parentRect.topRight() + QtCore.QPoint(-margin, margin)) 202 | elif corner == QtCore.Qt.BottomRightCorner: 203 | geo.moveBottomRight( 204 | parentRect.bottomRight() + QtCore.QPoint(-margin, -margin)) 205 | else: 206 | geo.moveBottomLeft( 207 | parentRect.bottomLeft() + QtCore.QPoint(margin, -margin)) 208 | 209 | self.setGeometry(geo) 210 | self.show() 211 | self.opacityAni.start() 212 | -------------------------------------------------------------------------------- /Flux/Plugins/Arabic.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QLabel, 3 | QDialog, 4 | QLineEdit, 5 | QGroupBox, 6 | QCompleter, 7 | QFormLayout, 8 | QComboBox, 9 | QMessageBox, 10 | QCheckBox 11 | ) 12 | from PyQt5.QtGui import QValidator 13 | from Flux.Plugins import FluxPlugin 14 | import re 15 | import fontFeatures 16 | 17 | 18 | plugin_name = "Arabic Positionals" 19 | 20 | 21 | class REValidator(QValidator): 22 | def __init__(self): 23 | super().__init__() 24 | 25 | def validate(self, s, pos): 26 | try: 27 | re.compile(s) 28 | except Exception as e: 29 | return (QValidator.Invalid, s, pos) 30 | return (QValidator.Acceptable, s, pos) 31 | 32 | def fixup(self, s): 33 | # Trim multiple spaces? 34 | pass 35 | 36 | 37 | class Dialog(FluxPlugin): 38 | def createForm(self): 39 | form = QGroupBox("Arabic parameters") 40 | layout = QFormLayout() 41 | naming, regexps = self.detect_naming_scheme() 42 | if naming: 43 | message = f"It looks like you're using the {naming} naming scheme. " 44 | message = message + "If that's not correct, p" 45 | else: 46 | message = "P" 47 | regexps = {"isol": "", "init": "", "medi": "", "fina": ""} 48 | message = ( 49 | message 50 | + "lease enter regular expressions below which will match the appropriate glyph classes" 51 | ) 52 | label = QLabel(message) 53 | label.setWordWrap(True) 54 | layout.addRow(label) 55 | 56 | self.isol_re = QLineEdit() 57 | self.isol_re.setText(regexps["isol"]) 58 | self.isol_re.setValidator(REValidator()) 59 | 60 | self.init_re = QLineEdit() 61 | self.init_re.setText(regexps["init"]) 62 | self.init_re.setValidator(REValidator()) 63 | 64 | self.medi_re = QLineEdit() 65 | self.medi_re.setText(regexps["medi"]) 66 | self.medi_re.setValidator(REValidator()) 67 | 68 | self.fina_re = QLineEdit() 69 | self.fina_re.setText(regexps["fina"]) 70 | self.fina_re.setValidator(REValidator()) 71 | 72 | layout.addRow(QLabel("Base glyphs"), self.isol_re) 73 | layout.addRow(QLabel("Initial glyphs"), self.init_re) 74 | layout.addRow(QLabel("Medial glyphs"), self.medi_re) 75 | layout.addRow(QLabel("Final glyphs"), self.fina_re) 76 | 77 | self.doCursive = QCheckBox() 78 | self.doCursive.setChecked(True) 79 | 80 | layout.addRow(QLabel("Do cursive attachment?"), self.doCursive) 81 | form.setLayout(layout) 82 | return form 83 | 84 | def accept(self): 85 | glyphnames = self.project.font.keys() 86 | isol_re = self.isol_re.text() 87 | init_re = self.init_re.text() 88 | medi_re = self.medi_re.text() 89 | fina_re = self.fina_re.text() 90 | 91 | init_class = [] 92 | medi_class = [] 93 | fina_class = [] 94 | init_rules = fontFeatures.Routine(name="Init") 95 | medi_rules = fontFeatures.Routine(name="Medi") 96 | fina_rules = fontFeatures.Routine(name="Fina") 97 | # We know these are valid REs 98 | arabic_glyphs = [ 99 | g 100 | for g in glyphnames 101 | if re.search(init_re, g) or re.search(medi_re, g) or re.search(fina_re, g) 102 | ] 103 | for g in glyphnames: 104 | m = re.search(isol_re, g) 105 | if not m: 106 | continue 107 | if m.groups(): 108 | base_name = g.replace(m[1], "") 109 | else: 110 | base_name = g 111 | for g2 in arabic_glyphs: 112 | m = re.search(init_re, g2) 113 | if not m or not m.groups(): 114 | continue 115 | base_init = g2.replace(m[1], "") 116 | if base_init == base_name: 117 | init_class.append(g2) 118 | init_rules.addRule(fontFeatures.Substitution([[g]], [[g2]])) 119 | break 120 | 121 | for g2 in arabic_glyphs: 122 | m = re.search(medi_re, g2) 123 | if not m or not m.groups(): 124 | continue 125 | base_medi = g2.replace(m[1], "") 126 | if base_medi == base_name: 127 | medi_class.append(g2) 128 | medi_rules.addRule(fontFeatures.Substitution([[g]], [[g2]])) 129 | break 130 | 131 | for g2 in arabic_glyphs: 132 | m = re.search(fina_re, g2) 133 | if not m or not m.groups(): 134 | continue 135 | base_fina = g2.replace(m[1], "") 136 | if base_fina == base_name: 137 | fina_class.append(g2) 138 | fina_rules.addRule(fontFeatures.Substitution([[g]], [[g2]])) 139 | break 140 | 141 | warnings = [] 142 | if len(init_class) < 10 or len(init_class) > len(glyphnames) / 2: 143 | warnings.append( 144 | f"Init regexp '{init_re} matched a surprising number of glyphs ({len(init_class)})" 145 | ) 146 | if len(medi_class) < 10 or len(medi_class) > len(glyphnames) / 2: 147 | warnings.append( 148 | f"Medi regexp '{medi_re} matched a surprising number of glyphs ({len(medi_class)})" 149 | ) 150 | if len(fina_class) < 10 or len(fina_class) > len(glyphnames) / 2: 151 | warnings.append( 152 | f"Fina regexp '{fina_re} matched a surprising number of glyphs ({len(fina_class)})" 153 | ) 154 | 155 | if len(warnings) and self.show_warnings(warnings) == QMessageBox.Cancel: 156 | return 157 | 158 | self.project.fontfeatures.routines.extend([init_rules, medi_rules, fina_rules]) 159 | self.project.fontfeatures.addFeature("init", [init_rules]) 160 | self.project.fontfeatures.addFeature("medi", [medi_rules]) 161 | self.project.fontfeatures.addFeature("fina", [fina_rules]) 162 | if not "init" in self.project.glyphclasses: 163 | self.project.glyphclasses["init"] = { 164 | "type": "automatic", 165 | "predicates": [ 166 | {"type": "name", "comparator": "matches", "value": init_re} 167 | ], 168 | } 169 | if not "medi" in self.project.glyphclasses: 170 | self.project.glyphclasses["medi"] = { 171 | "type": "automatic", 172 | "predicates": [ 173 | {"type": "name", "comparator": "matches", "value": medi_re} 174 | ], 175 | } 176 | if not "fina" in self.project.glyphclasses: 177 | self.project.glyphclasses["fina"] = { 178 | "type": "automatic", 179 | "predicates": [ 180 | {"type": "name", "comparator": "matches", "value": fina_re} 181 | ], 182 | } 183 | 184 | if self.doCursive.isChecked(): 185 | exitdict = {} 186 | entrydict = {} 187 | for g in glyphnames: 188 | anchors = self.project.font[g].anchors 189 | if not anchors: 190 | continue 191 | entry = [a for a in anchors if a.name == "entry"] 192 | exit = [a for a in anchors if a.name == "exit"] 193 | if len(entry): 194 | entrydict[g] = (entry[0].x, entry[0].y) 195 | if len(exit): 196 | exitdict[g] = (exit[0].x, exit[0].y) 197 | s = fontFeatures.Attachment( 198 | base_name="entry", 199 | mark_name="exit", 200 | bases=entrydict, 201 | marks=exitdict, 202 | ) 203 | r = fontFeatures.Routine(name="CursiveAttachment", rules=[s]) 204 | self.project.fontfeatures.routines.extend([r]) 205 | self.project.fontfeatures.addFeature("curs", [r]) 206 | 207 | return super().accept() 208 | 209 | def detect_naming_scheme(self): 210 | glyphnames = self.project.font.keys() 211 | schemas = { 212 | "Glyphs": { 213 | "isol": "-ar$", 214 | "init": "-ar(.init)$", 215 | "medi": "-ar(.medi)$", 216 | "fina": "-ar(.fina)", 217 | }, 218 | "Qalmi": { 219 | "isol": "(u1)$", 220 | "init": "(i1)$", 221 | "medi": "(m1)$", 222 | "fina": "(f1)$", 223 | }, 224 | } 225 | for schema_name, res in schemas.items(): 226 | match_isols = len([g for g in glyphnames if re.search(res["isol"], g)]) 227 | match_inits = len([g for g in glyphnames if re.search(res["init"], g)]) 228 | match_medis = len([g for g in glyphnames if re.search(res["medi"], g)]) 229 | match_finas = len([g for g in glyphnames if re.search(res["fina"], g)]) 230 | if ( 231 | match_isols > 1 232 | and match_inits > 1 233 | and match_medis > 1 234 | and match_finas > 1 235 | ): 236 | return schema_name, res 237 | return None, None 238 | 239 | def show_warnings(self, warnings): 240 | msg = QMessageBox() 241 | msg.setIcon(QMessageBox.Warning) 242 | 243 | msg.setText("\n".join(warnings)) 244 | msg.setWindowTitle("Arabic Positionals") 245 | msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) 246 | 247 | return msg.exec_() 248 | -------------------------------------------------------------------------------- /Flux/UI/qglyphname.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QWidget, 3 | QApplication, 4 | QHBoxLayout, 5 | QLineEdit, 6 | QVBoxLayout, 7 | QPushButton, 8 | QCompleter, 9 | QLabel, 10 | QDialog, 11 | QDialogButtonBox, 12 | QScrollArea, 13 | QGridLayout, 14 | QStyle, 15 | QFrame, 16 | QTextEdit, 17 | 18 | ) 19 | from Flux.ThirdParty.QFlowGridLayout import QFlowGridLayout 20 | from fontFeatures.shaperLib.Buffer import Buffer 21 | from Flux.UI.qbufferrenderer import QBufferRenderer 22 | from PyQt5.QtCore import Qt, pyqtSignal, QStringListModel, QMargins 23 | from PyQt5.QtGui import QValidator, QTextOption 24 | 25 | import darkdetect 26 | 27 | if darkdetect.isDark(): 28 | selected_style = "background-color: #322b2b;" 29 | deselected_style = "background-color: #322b2b;" 30 | else: 31 | selected_style = "background-color: #322b2b;" 32 | 33 | 34 | class GlyphNameValidator(QValidator): 35 | def __init__(self, parent): 36 | super().__init__() 37 | self.glyphSet = parent.project.font.keys() 38 | self.classSet = parent.project.glyphclasses.keys() 39 | self.multiple = parent.multiple 40 | self.allow_classes = parent.allow_classes 41 | 42 | def validate(self, s, pos): 43 | if s in self.glyphSet: 44 | return (QValidator.Acceptable, s, pos) 45 | if not self.multiple and " " in s: 46 | print("Invalid (space)") 47 | return (QValidator.Invalid, s, pos) 48 | if not self.allow_classes and "@" in s: 49 | print("Invalid (class)") 50 | return (QValidator.Invalid, s, pos) 51 | # XXX Other things not acceptable in glyphs here? 52 | if self.multiple: 53 | allOk = True 54 | for g in s.split(): 55 | if g[0] == "@" and g[1:] not in self.classSet: 56 | allOk = False 57 | elif g[0] != "@" and g not in self.glyphSet: 58 | allOk = False 59 | if allOk: 60 | return (QValidator.Acceptable, s, pos) 61 | if self.allow_classes and s[1:] in self.classSet: # And not multiple 62 | return (QValidator.Acceptable, s, pos) 63 | 64 | return (QValidator.Intermediate, s, pos) 65 | 66 | def fixup(self, s): 67 | # Trim multiple spaces? 68 | pass 69 | 70 | class QGlyphBox(QWidget): 71 | def __init__(self, parent, glyph): 72 | super().__init__() 73 | wlayout = QVBoxLayout() 74 | self.setLayout(wlayout) 75 | self.parent = parent 76 | self.glyph = glyph 77 | buf = Buffer(self.parent.project.font, glyphs = [glyph]) 78 | renderer = QBufferRenderer(self.parent.project, buf) 79 | wlayout.addWidget(renderer) 80 | label = QTextEdit(glyph) 81 | label.setReadOnly(True) 82 | label.setFrameStyle(QFrame.NoFrame) 83 | label.setAlignment(Qt.AlignCenter) 84 | label.setLineWrapMode(QTextEdit.WidgetWidth) 85 | label.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere) 86 | wlayout.addWidget(label) 87 | renderer.resizeEvent(None) 88 | # self.setContentsMargins(QMargins(25, 25, 25, 25)) 89 | self.setMaximumSize(100,100) 90 | 91 | def mousePressEvent(self, e): 92 | if self.parent.selected: 93 | self.parent.selected.deselect() 94 | self.select() 95 | 96 | def deselect(self): 97 | self.setStyleSheet("") 98 | print("Deselect called") 99 | 100 | def select(self): 101 | self.setStyleSheet(selected_style) 102 | self.parent.selected = self 103 | 104 | def hasHeightForWidth(self): 105 | return True 106 | 107 | def heightForWidth(self, w): 108 | return w 109 | 110 | class QGlyphPicker(QDialog): 111 | def __init__(self, project): 112 | super(QGlyphPicker, self).__init__() 113 | self.project = project 114 | self.selected = None 115 | v_box_1 = QVBoxLayout() 116 | self.qgrid = QFlowGridLayout() 117 | 118 | self.qgridWidget = QWidget() 119 | self.qgridWidget.setLayout(self.qgrid) 120 | 121 | self.scroll = QScrollArea() 122 | self.scroll.setWidget(self.qgridWidget) 123 | self.scroll.setWidgetResizable(True) 124 | 125 | v_box_1.addWidget(self.scroll) 126 | 127 | self.searchbar = QLineEdit() 128 | v_box_1.addWidget(self.searchbar) 129 | self.searchbar.textChanged.connect(self.filterGrid) 130 | 131 | buttons = QDialogButtonBox( 132 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, 133 | Qt.Horizontal, self) 134 | buttons.accepted.connect(self.accept) 135 | buttons.rejected.connect(self.reject) 136 | v_box_1.addWidget(buttons) 137 | self.setLayout(v_box_1) 138 | self.widgets = {} 139 | self.setupGrid() 140 | self.drawGrid() 141 | 142 | def setupGrid(self): 143 | self.clearLayout(self.qgrid) 144 | for g in self.project.font.keys(): 145 | w = QGlyphBox(self, g) 146 | self.widgets[g] = w 147 | 148 | def drawGrid(self): 149 | for g in self.project.font.keys(): 150 | self.qgrid.addWidget(self.widgets[g]) 151 | 152 | def filterGrid(self): 153 | t = self.searchbar.text() 154 | for g in self.project.font.keys(): 155 | v = True 156 | if t and t not in g: 157 | v = False 158 | self.widgets[g].setVisible(v) 159 | 160 | def clearLayout(self, layout): 161 | if layout is not None: 162 | while layout.count(): 163 | item = layout.takeAt(0) 164 | widget = item.widget() 165 | if widget is not None: 166 | widget.deleteLater() 167 | else: 168 | self.clearLayout(item.layout()) 169 | 170 | @classmethod 171 | def pickGlyph(self, project): 172 | dialog = QGlyphPicker(project) 173 | result = dialog.exec_() 174 | if dialog.selected: 175 | return dialog.selected.glyph 176 | 177 | class MultiCompleter(QCompleter): 178 | 179 | def __init__(self, parent=None): 180 | super(MultiCompleter, self).__init__(parent) 181 | 182 | self.setCaseSensitivity(Qt.CaseInsensitive) 183 | self.setCompletionMode(QCompleter.PopupCompletion) 184 | self.setWrapAround(False) 185 | 186 | # Add texts instead of replace 187 | def pathFromIndex(self, index): 188 | path = QCompleter.pathFromIndex(self, index) 189 | 190 | lst = str(self.widget().text()).split(' ') 191 | 192 | if len(lst) > 1: 193 | path = '%s %s' % (' '.join(lst[:-1]), path) 194 | 195 | return path 196 | 197 | # Add operator to separate between texts 198 | def splitPath(self, path): 199 | path = str(path.split(' ')[-1]).lstrip(' ') 200 | return [path] 201 | 202 | class QGlyphName(QWidget): 203 | changed = pyqtSignal() 204 | 205 | def __init__(self, project, multiple = False, allow_classes = False, parent=None): 206 | self.project = project 207 | self.multiple = multiple 208 | self.allow_classes = allow_classes 209 | super(QGlyphName, self).__init__(parent) 210 | self.layout = QHBoxLayout() 211 | self.setLayout(self.layout) 212 | 213 | self.glyphline = QLineEdit() 214 | self.completermodel = QStringListModel() 215 | if self.allow_classes: 216 | self.completermodel.setStringList(list(self.project.font.keys()) + ["@"+x for x in self.project.glyphclasses.keys()]) 217 | else: 218 | self.completermodel.setStringList(self.project.font.keys()) 219 | 220 | self.glyphline.setValidator(GlyphNameValidator(self)) 221 | 222 | if multiple: 223 | self.completer = MultiCompleter() 224 | else: 225 | self.completer = QCompleter() 226 | self.completer.setModel(self.completermodel) 227 | self.glyphline.setCompleter(self.completer) 228 | self.glyphline.textChanged.connect(lambda: self.changed.emit()) 229 | self.glyphline.owner = self 230 | if self.allow_classes: 231 | self.setAcceptDrops(True) 232 | 233 | self.layout.addWidget(self.glyphline) 234 | 235 | self.glyphPickerButton = QPushButton("") 236 | self.glyphPickerButton.setIcon(self.style().standardIcon(QStyle.SP_FileDialogContentsView)) 237 | self.glyphPickerButton.clicked.connect(self.launchGlyphPicker) 238 | 239 | self.layout.addWidget(self.glyphPickerButton) 240 | 241 | def appendText(self, text): 242 | if self.glyphline.text(): 243 | self.glyphline.setText(self.glyphline.text() + " " + text) 244 | else: 245 | self.glyphline.setText(text) 246 | 247 | def text(self): 248 | return self.glyphline.text() 249 | 250 | def setText(self, f): 251 | g = self.glyphline.setText(f) 252 | return g 253 | 254 | def dropEvent(self, event): 255 | data = event.mimeData() 256 | if not data.hasText() or not data.text().startswith("@"): 257 | event.reject() 258 | return 259 | self.appendText(data.text()) 260 | self.changed.emit() 261 | if not self.multiple: 262 | self.glyphline.returnPressed.emit() 263 | 264 | @property 265 | def returnPressed(self): 266 | self.changed.emit() 267 | print("Emit changed") 268 | return self.glyphline.returnPressed 269 | 270 | def launchGlyphPicker(self): 271 | result = QGlyphPicker.pickGlyph(self.project) 272 | if result: 273 | self.appendText(result) 274 | 275 | 276 | if __name__ == "__main__": 277 | from Flux.project import FluxProject 278 | import sys 279 | 280 | app = 0 281 | if QApplication.instance(): 282 | app = QApplication.instance() 283 | else: 284 | app = QApplication(sys.argv) 285 | 286 | w = QWidget() 287 | w.resize(510, 210) 288 | 289 | proj = FluxProject("qalam.fluxml") 290 | foo = QGlyphName(proj) 291 | foo.setParent(w) 292 | 293 | w.show() 294 | sys.exit(app.exec_()) 295 | -------------------------------------------------------------------------------- /Flux/UI/classlist.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import ( 2 | Qt, 3 | pyqtSlot, 4 | QModelIndex, 5 | QAbstractTableModel, 6 | QItemSelectionModel, 7 | QMimeData, 8 | QRect 9 | ) 10 | from PyQt5.QtWidgets import QTreeView, QMenu, QStyledItemDelegate, QLineEdit 11 | from .glyphpredicateeditor import AutomatedGlyphClassDialog, GlyphClassPredicateTester, GlyphClassPredicate 12 | from .qglyphname import QGlyphName 13 | 14 | class GlyphNameDelegate(QStyledItemDelegate): 15 | def __init__(self, tree): 16 | super().__init__() 17 | self.project = tree.project 18 | self.model = tree.model() 19 | 20 | def createEditor(self, parent, option, index): 21 | # Check if index is actually non-computed class 22 | if self.model.isAutomatic(index) or index.column() == 0: 23 | return super().createEditor(parent, option, index) 24 | editor = QGlyphName(self.project, multiple = True, allow_classes = True) 25 | editor.setParent(parent) 26 | editor.setAttribute(Qt.WA_TranslucentBackground, False) 27 | editor.setAttribute(Qt.WA_OpaquePaintEvent, True) 28 | editor.layout.setContentsMargins(0,0,0,0) 29 | # editor = QLineEdit(parent) 30 | return editor 31 | 32 | def updateEditorGeometry(self, editor, option, index): 33 | r = QRect(option.rect) 34 | if editor.windowFlags() & Qt.Popup and editor.parent() is not None: 35 | r.setTopLeft(editor.parent().mapToGlobal(r.topLeft())) 36 | sizeHint = editor.sizeHint() 37 | 38 | if (r.width()" 97 | if role == Qt.ToolTipRole: 98 | if index.column() == 1 and self.isAutomatic(index): 99 | predicates = [GlyphClassPredicate(d) for d in self.getPredicates(index)] 100 | return " ".join(self.tester.test_all(predicates)) 101 | return None 102 | 103 | 104 | def mimeData(self, indexes): 105 | mimedata = QMimeData() 106 | name = self.order[indexes[0].row()] 107 | mimedata.setText("@"+name) 108 | return mimedata 109 | 110 | def headerData(self, section, orientation, role=Qt.DisplayRole): 111 | """ Set the headers to be displayed. """ 112 | if role != Qt.DisplayRole: 113 | return None 114 | 115 | if orientation == Qt.Horizontal: 116 | if section == 0: 117 | return "Name" 118 | elif section == 1: 119 | return "Contents" 120 | return None 121 | 122 | def insertRows(self, position, item=None, rows=1, index=QModelIndex()): 123 | """ Insert a row into the model. """ 124 | self.beginInsertRows(QModelIndex(), position, position + rows - 1) 125 | 126 | if "" not in self.order: 127 | self.order.append("") 128 | self.glyphclasses[""] = {"type": "manual", "contents": []} 129 | 130 | self.endInsertRows() 131 | return True 132 | 133 | def appendRow(self): 134 | self.insertRows(len(self.order)) 135 | return self.index(len(self.order) - 1, 0) 136 | 137 | def removeRows(self, indexes): 138 | positions = [i.row() for i in indexes if i.column() == 0] 139 | # print(positions) 140 | for i in reversed(sorted(positions)): 141 | self.removeRow(i) 142 | 143 | self.order = sorted(list(self.glyphclasses.keys())) 144 | # print("Computing order", self.order) 145 | 146 | def removeRow(self, position, rows=1, index=QModelIndex()): 147 | """ Remove a row from the model. """ 148 | self.beginRemoveRows(QModelIndex(), position, position + rows - 1) 149 | assert rows == 1 150 | del self.glyphclasses[self.order[position]] 151 | # print("Deleting %s" % self.order[position]) 152 | self.endRemoveRows() 153 | return True 154 | 155 | def setData(self, index, value, role=Qt.EditRole): 156 | """ Adjust the data (set it to ) depending on the given 157 | index and role. 158 | """ 159 | if role != Qt.EditRole: 160 | return False 161 | 162 | if index.isValid() and 0 <= index.row() < len(self.order): 163 | name = self.order[index.row()] 164 | if index.column() == 0 and name != value: 165 | self.glyphclasses[value] = self.glyphclasses[name] 166 | del self.glyphclasses[name] 167 | elif index.column() == 1: 168 | self.glyphclasses[name]["contents"] = value.split(" ") 169 | else: 170 | return False 171 | 172 | self.order = sorted(list(self.glyphclasses.keys())) 173 | self.dataChanged.emit(index, index) 174 | return True 175 | 176 | return False 177 | 178 | def flags(self, index): 179 | """ Set the item flags at the given index. Seems like we're 180 | implementing this function just to see how it's done, as we 181 | manually adjust each tableView to have NoEditTriggers. 182 | """ 183 | if not index.isValid(): 184 | return Qt.ItemIsEnabled 185 | flag = Qt.ItemFlags(QAbstractTableModel.flags(self, index)) 186 | flag = flag | Qt.ItemIsDragEnabled 187 | if index.column() == 0: 188 | return flag | Qt.ItemIsEditable 189 | 190 | name = self.order[index.row()] 191 | if self.glyphclasses[name]["type"] == "automatic": 192 | return flag 193 | else: 194 | return flag | Qt.ItemIsEditable 195 | 196 | 197 | class GlyphClassList(QTreeView): 198 | def __init__(self, project): 199 | super(QTreeView, self).__init__() 200 | self.project = project 201 | self.setModel(GlyphClassModel(self.project)) 202 | self.setContextMenuPolicy(Qt.CustomContextMenu) 203 | self.setItemDelegate(GlyphNameDelegate(self)) 204 | self.setDragEnabled(True) 205 | self.customContextMenuRequested.connect(self.contextMenu) 206 | self.doubleClicked.connect(self.doubleClickHandler) 207 | 208 | def contextMenu(self, position): 209 | indexes = self.selectedIndexes() 210 | menu = QMenu() 211 | if len(indexes) > 0: 212 | menu.addAction("Delete class", self.deleteClass) 213 | menu.addAction("Add class", self.addClass) 214 | menu.addAction("Add computed class", self.addComputedClass) 215 | menu.exec_(self.viewport().mapToGlobal(position)) 216 | 217 | def doubleClickHandler(self, index): 218 | if self.model().isAutomatic(index) and index.column() == 1: 219 | predicates, result = AutomatedGlyphClassDialog.editDefinition( 220 | self.project, self.model().getPredicates(index) 221 | ) 222 | if result: 223 | self.model().setPredicates(index, predicates) 224 | 225 | def update(self, index=QModelIndex()): 226 | # print(self.project.glyphclasses) 227 | self.model().order = list(sorted(self.project.glyphclasses.keys())) 228 | if index.isValid(): 229 | self.model().dataChanged.emit(index, index) 230 | else: 231 | self.model().beginResetModel() 232 | self.model().dataChanged.emit(index, index) 233 | self.model().endResetModel() 234 | super().update() 235 | 236 | @pyqtSlot() 237 | def deleteClass(self): 238 | self.model().removeRows(self.selectedIndexes()) 239 | 240 | @pyqtSlot() 241 | def addClass(self): 242 | index = self.model().appendRow() 243 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect) 244 | self.edit(index) 245 | 246 | @pyqtSlot() 247 | def addComputedClass(self): 248 | index = self.model().appendRow() 249 | self.model().glyphclasses[""] = {"type": "automatic"} 250 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect) 251 | self.edit(index) 252 | 253 | -------------------------------------------------------------------------------- /Flux/project.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | from fontFeatures import FontFeatures, Routine, Substitution 3 | from babelfont import Babelfont 4 | from fontFeatures.feaLib import FeaUnparser 5 | from fontTools.feaLib.builder import Builder 6 | from fontTools.ttLib import TTFont 7 | from fontFeatures.ttLib import unparse 8 | from Flux.computedroutine import ComputedRoutine 9 | from Flux.dividerroutine import DividerRoutine 10 | from io import StringIO as UnicodeIO 11 | from Flux.UI.GlyphActions import GlyphAction 12 | from Flux.UI.glyphpredicateeditor import GlyphClassPredicateTester, GlyphClassPredicate 13 | from babelfont.variablefont import VariableFont 14 | import os 15 | 16 | class FluxProject: 17 | 18 | @classmethod 19 | def new(klass, fontfile, editor=None): 20 | self = FluxProject() 21 | self.fontfeatures = FontFeatures() 22 | self.fontfile = fontfile 23 | self.editor = editor 24 | if not self._load_fontfile(): 25 | return 26 | self.glyphclasses = {} 27 | self.glyphactions = {} 28 | self.debuggingText = "" 29 | self.filename = None 30 | 31 | if self.fontfile.endswith(".ttf") or self.fontfile.endswith(".otf"): 32 | self._load_features_binary() 33 | else: 34 | self._load_features_source() 35 | 36 | for groupname, contents in self.font.groups.items(): 37 | self.glyphclasses[groupname] = { 38 | "type": "manual", 39 | "contents": contents 40 | } 41 | self.fontfeatures.namedClasses.forceput(groupname, tuple(contents)) 42 | # Load up the anchors too 43 | self._load_anchors() 44 | return self 45 | 46 | def __init__(self, file=None): 47 | if not file: 48 | return 49 | self.filename = file 50 | self.xml = etree.parse(file).getroot() 51 | dirname = os.path.dirname(file) 52 | self.fontfile = os.path.join(dirname,self.xml.find("source").get("file")) 53 | self.fontfeatures = FontFeatures() 54 | if not self._load_fontfile(): 55 | return 56 | self.glyphactions = {} 57 | self.xmlToFontFeatures() 58 | text = self.xml.find("debuggingText") 59 | if text is not None: 60 | self.debuggingText = text.text 61 | else: 62 | self.debuggingText = "" 63 | 64 | self.glyphclasses = {} # Will sync to fontFeatures when building 65 | # XXX will it? 66 | 67 | glyphclasses = self.xml.find("glyphclasses") 68 | if glyphclasses is not None: 69 | for c in glyphclasses: 70 | thisclass = self.glyphclasses[c.get("name")] = {} 71 | if c.get("automatic") == "true": 72 | thisclass["type"] = "automatic" 73 | thisclass["predicates"] = [ dict(p.items()) for p in c.findall("predicate") ] 74 | self.fontfeatures.namedClasses[c.get("name")] = tuple(GlyphClassPredicateTester(self).test_all([ 75 | GlyphClassPredicate(x) for x in thisclass["predicates"] 76 | ])) 77 | else: 78 | thisclass["type"] = "manual" 79 | thisclass["contents"] = [g.text for g in c] 80 | self.fontfeatures.namedClasses[c.get("name")] = tuple([g.text for g in c]) 81 | 82 | # The font file is the authoritative source of the anchors, so load them 83 | # from the font file on load, in case they have changed. 84 | self._load_anchors() 85 | self._load_glyphactions() 86 | 87 | def _load_fontfile(self): 88 | try: 89 | if self.fontfile.endswith(".ufo") or self.fontfile.endswith("tf"): 90 | # Single master workflow 91 | self.font = Babelfont.open(self.fontfile) 92 | self.variations = None 93 | else: 94 | self.variations = VariableFont(self.fontfile) 95 | # We need a "scratch copy" because we will be trashing the 96 | # glyph data with our interpolations 97 | if len(self.variations.masters.keys()) == 1: 98 | self.font = list(self.variations.masters.values())[0] 99 | self.variations = None 100 | else: 101 | firstmaster = self.variations.designspace.sources[0].path 102 | if firstmaster: 103 | self.font = Babelfont.open(firstmaster) 104 | else: # Glyphs, fontlab? 105 | self.font = Babelfont.open(self.fontfile) 106 | except Exception as e: 107 | if self.editor: 108 | self.editor.showError("Couldn't open %s: %s" % (self.fontfile, e)) 109 | else: 110 | raise e 111 | return False 112 | return True 113 | 114 | def _load_anchors(self): 115 | for g in self.font: 116 | for a in g.anchors: 117 | if not a.name in self.fontfeatures.anchors: 118 | self.fontfeatures.anchors[a.name] = {} 119 | self.fontfeatures.anchors[a.name][g.name] = (a.x, a.y) 120 | 121 | def _load_glyphactions(self): 122 | glyphactions = self.xml.find("glyphactions") 123 | if not glyphactions: 124 | return 125 | for xmlaction in glyphactions: 126 | g = GlyphAction.fromXML(xmlaction) 127 | self.glyphactions[g.glyph] = g 128 | g.perform(self.font) 129 | 130 | def _slotArray(self, el): 131 | return [[g.text for g in slot.findall("glyph")] for slot in list(el)] 132 | 133 | def xmlToFontFeatures(self): 134 | routines = {} 135 | warnings = [] 136 | for xmlroutine in self.xml.find("routines"): 137 | if "computed" in xmlroutine.attrib: 138 | r = ComputedRoutine.fromXML(xmlroutine) 139 | r.project = self 140 | elif "divider" in xmlroutine.attrib: 141 | r = DividerRoutine.fromXML(xmlroutine) 142 | else: 143 | r = Routine.fromXML(xmlroutine) 144 | routines[r.name] = r 145 | self.fontfeatures.routines.append(r) 146 | for xmlfeature in self.xml.find("features"): 147 | # Temporary until we refactor fontfeatures 148 | featurename = xmlfeature.get("name") 149 | self.fontfeatures.features[featurename] = [] 150 | for r in xmlfeature: 151 | routinename = r.get("name") 152 | if routinename in routines: 153 | self.fontfeatures.addFeature(featurename, [routines[routinename]]) 154 | else: 155 | warnings.append("Lost routine %s referenced in feature %s" % (routinename, featurename)) 156 | return warnings # We don't do anything with them yet 157 | 158 | def save(self, filename=None): 159 | if not filename: 160 | filename = self.filename 161 | flux = etree.Element("flux") 162 | etree.SubElement(flux, "source").set("file", self.fontfile) 163 | etree.SubElement(flux, "debuggingText").text = self.debuggingText 164 | glyphclasses = etree.SubElement(flux, "glyphclasses") 165 | for k,v in self.glyphclasses.items(): 166 | self.serializeGlyphClass(glyphclasses, k, v) 167 | # Plugins 168 | 169 | # Features 170 | features = etree.SubElement(flux, "features") 171 | for k,v in self.fontfeatures.features.items(): 172 | f = etree.SubElement(features, "feature") 173 | f.set("name", k) 174 | for routine in v: 175 | etree.SubElement(f, "routine").set("name", routine.name) 176 | # Routines 177 | routines = etree.SubElement(flux, "routines") 178 | for r in self.fontfeatures.routines: 179 | routines.append(r.toXML()) 180 | 181 | # Glyph actions 182 | if self.glyphactions: 183 | f = etree.SubElement(flux, "glyphactions") 184 | for ga in self.glyphactions.values(): 185 | f.append(ga.toXML()) 186 | 187 | et = etree.ElementTree(flux) 188 | with open(filename, "wb") as out: 189 | et.write(out, pretty_print=True) 190 | 191 | 192 | def serializeGlyphClass(self, element, name, value): 193 | c = etree.SubElement(element, "class") 194 | c.set("name", name) 195 | if value["type"] == "automatic": 196 | c.set("automatic", "true") 197 | for pred in value["predicates"]: 198 | pred_xml = etree.SubElement(c, "predicate") 199 | for k, v in pred.items(): 200 | pred_xml.set(k, v) 201 | else: 202 | c.set("automatic", "false") 203 | for glyph in value["contents"]: 204 | etree.SubElement(c, "glyph").text = glyph 205 | return c 206 | 207 | def saveFEA(self, filename): 208 | try: 209 | asfea = self.fontfeatures.asFea() 210 | with open(filename, "w") as out: 211 | out.write(asfea) 212 | return None 213 | except Exception as e: 214 | return str(e) 215 | 216 | def loadFEA(self, filename): 217 | unparsed = FeaUnparser(open(filename,"r")) 218 | self.fontfeatures = unparsed.ff 219 | 220 | def _load_features_binary(self): 221 | tt = TTFont(self.fontfile) 222 | self.fontfeatures = unparse(tt) 223 | print(self.fontfeatures.features) 224 | 225 | def _load_features_source(self): 226 | if self.font.features and self.font.features.text: 227 | try: 228 | unparsed = FeaUnparser(self.font.features.text) 229 | self.fontfeatures = unparsed.ff 230 | except Exception as e: 231 | print("Could not load feature file: %s" % e) 232 | 233 | def saveOTF(self, filename): 234 | try: 235 | self.font.save(filename) 236 | ttfont = TTFont(filename) 237 | featurefile = UnicodeIO(self.fontfeatures.asFea()) 238 | builder = Builder(ttfont, featurefile) 239 | catmap = { "base": 1, "ligature": 2, "mark": 3, "component": 4 } 240 | for g in self.font: 241 | if g.category in catmap: 242 | builder.setGlyphClass_(None, g.name, catmap[g.category]) 243 | builder.build() 244 | ttfont.save(filename) 245 | except Exception as e: 246 | print(e) 247 | return str(e) 248 | -------------------------------------------------------------------------------- /Flux/UI/qshapingdebugger.py: -------------------------------------------------------------------------------- 1 | from .qbufferrenderer import QBufferRenderer 2 | from PyQt5.QtWidgets import ( 3 | QSplitter, 4 | QLineEdit, 5 | QLabel, 6 | QTableWidget, 7 | QTableWidgetItem, 8 | QAbstractItemView, 9 | QSizePolicy, 10 | QHeaderView, 11 | QFormLayout, 12 | QVBoxLayout, 13 | QHBoxLayout, 14 | QSlider, 15 | QGroupBox, 16 | QCheckBox, 17 | QWidget, 18 | ) 19 | from PyQt5.QtCore import Qt 20 | from Flux.ThirdParty.QFlowLayout import QFlowLayout 21 | from fontFeatures.shaperLib.Shaper import Shaper 22 | from fontFeatures.shaperLib.BaseShaper import BaseShaper 23 | from Flux.variations import VariationAwareBuffer, VariationAwareBufferItem 24 | from copy import copy, deepcopy 25 | import re 26 | 27 | 28 | valid_glyph_name_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-*:^|~" 29 | 30 | class QShapingDebugger(QSplitter): 31 | def __init__(self, editor, project): 32 | self.editor = editor 33 | self.project = project 34 | super(QSplitter, self).__init__() 35 | self.text = self.project.debuggingText or self.getReasonableTextForFont(self.project.font) 36 | 37 | # First box: Text and features 38 | self.firstbox = QWidget() 39 | self.firstboxLayout = QVBoxLayout() 40 | self.firstbox.setLayout(self.firstboxLayout) 41 | 42 | textbox = QLineEdit() 43 | textbox.setText(self.text) 44 | textbox.setMaximumHeight(textbox.height()) 45 | textbox.textChanged[str].connect(self.textChanged) 46 | 47 | self.featuregroup = QGroupBox("Features") 48 | self.featuregrouplayout = QFlowLayout() 49 | self.featuregroup.setLayout(self.featuregrouplayout) 50 | self.features = {} 51 | self.fillFeatureGroup() 52 | self.firstboxLayout.addWidget(textbox) 53 | self.firstboxLayout.addWidget(self.featuregroup) 54 | 55 | # Second box: Variations 56 | self.secondbox = QWidget() 57 | self.secondboxLayout = QHBoxLayout() 58 | self.secondbox.setLayout(self.secondboxLayout) 59 | self.sliders = [] 60 | 61 | if self.project.variations: 62 | for axis in self.project.variations.designspace.axes: 63 | self.secondboxLayout.addWidget(QLabel(axis.name)) 64 | slider = QSlider(0x01) 65 | slider.name = axis.name 66 | slider.setMinimum(axis.map_forward(axis.minimum)) 67 | slider.setMaximum(axis.map_forward(axis.maximum)) 68 | self.sliders.append(slider) 69 | 70 | slider.valueChanged.connect(self.shapeText) 71 | self.secondboxLayout.addWidget(slider) 72 | # Third box: Output and renderer 73 | self.thirdbox = QWidget() 74 | self.thirdboxLayout = QVBoxLayout() 75 | self.thirdbox.setLayout(self.thirdboxLayout) 76 | 77 | self.shaperOutput = QLabel() 78 | self.shaperOutput.setWordWrap(True) 79 | sp = self.shaperOutput.sizePolicy() 80 | sp.setVerticalPolicy(QSizePolicy.Maximum) 81 | self.shaperOutput.setSizePolicy(sp) 82 | 83 | self.qbr = QBufferRenderer(project, VariationAwareBuffer(self.project.font)) 84 | sp = self.thirdbox.sizePolicy() 85 | sp.setHorizontalPolicy(QSizePolicy.Maximum) 86 | sp.setVerticalPolicy(QSizePolicy.MinimumExpanding) 87 | self.thirdbox.setSizePolicy(sp) 88 | 89 | self.thirdboxLayout.addWidget(self.shaperOutput) 90 | self.thirdboxLayout.addWidget(self.qbr) 91 | 92 | # Third box: message table 93 | self.messageTable = QTableWidget() 94 | self.messageTable.setColumnCount(2) 95 | self.messageTable.verticalHeader().setVisible(False) 96 | self.messageTable.setHorizontalHeaderLabels(["message", "buffer"]) 97 | header = self.messageTable.horizontalHeader() 98 | headerWidth = self.messageTable.viewport().size().width() 99 | header.resizeSection(0, headerWidth * 2 / 3) 100 | header.setStretchLastSection(True) 101 | self.messageTable.setSelectionBehavior(QAbstractItemView.SelectRows) 102 | self.messageTable.selectionModel().selectionChanged.connect( 103 | self.renderPartialTrace 104 | ) 105 | 106 | self.setOrientation(Qt.Vertical) 107 | self.addWidget(self.firstbox) 108 | if self.project.variations: 109 | self.addWidget(self.secondbox) 110 | self.addWidget(self.thirdbox) 111 | self.addWidget(self.messageTable) 112 | self.fullBuffer = None 113 | self.lastBuffer = None 114 | self.shapeText() 115 | 116 | def clearLayout(self, layout): 117 | if layout is not None: 118 | while layout.count(): 119 | item = layout.takeAt(0) 120 | widget = item.widget() 121 | if widget is not None: 122 | widget.deleteLater() 123 | else: 124 | self.clearLayout(item.layout()) 125 | 126 | def fillFeatureGroup(self): 127 | prev = self.features 128 | fkeys = self.project.fontfeatures.features.keys() 129 | self.clearLayout(self.featuregrouplayout) 130 | self.features = {} 131 | for k in fkeys: 132 | box = self.features[k] = QCheckBox(k) 133 | box.setTristate() 134 | if k in prev: 135 | box.setCheckState(prev[k].checkState()) 136 | else: 137 | box.setCheckState(Qt.PartiallyChecked) 138 | box.stateChanged.connect(self.shapeText) 139 | self.featuregrouplayout.addWidget(box) 140 | 141 | def update(self): 142 | self.fillFeatureGroup() 143 | self.shapeText() 144 | 145 | def buildBuffer(self): 146 | buf = VariationAwareBuffer(self.project.font) 147 | t = self.text 148 | i = 0 149 | while i < len(t): 150 | if t[i] == "/": # Start of glyph name 151 | i = i + 1 152 | glyphname = "" 153 | while i < len(t) and t[i] in valid_glyph_name_chars: 154 | glyphname += t[i] 155 | i = i + 1 156 | if len(glyphname) and glyphname in self.project.font: 157 | item = VariationAwareBufferItem.new_glyph(glyphname, self.project.font, buf) 158 | item.codepoint = self.project.font.codepointForGlyph(glyphname) 159 | buf.items.append(item) 160 | else: 161 | buf.items.extend([VariationAwareBufferItem.new_unicode(ord(x), buf) for x in "/"+glyphname]) 162 | else: 163 | item = VariationAwareBufferItem.new_unicode(ord(t[i]), buf) 164 | i = i + 1 165 | buf.items.append(item) 166 | buf.guess_segment_properties() 167 | return buf 168 | 169 | def shapeText(self): 170 | features = [] 171 | for k, box in self.features.items(): 172 | if box.checkState() == Qt.PartiallyChecked: 173 | continue 174 | features.append({"tag": k, "value": box.isChecked()}) 175 | 176 | buf = self.buildBuffer() 177 | 178 | self.messageTable.setRowCount(0) 179 | if not self.text: 180 | buf.clear_mask() 181 | self.qbr.set_buf(buf) 182 | self.fullBuffer = buf 183 | self.shaperOutput.setText(buf.serialize()) 184 | return 185 | self.messageTable.clearSelection() 186 | self.lastBuffer = None 187 | self.skipped = [] 188 | self.partialBuffers = {} 189 | shaper = Shaper( 190 | self.project.fontfeatures, 191 | self.project.font, 192 | message_function=self.addToTable, 193 | ) 194 | self.prep_shaper(shaper, buf, features) 195 | shaper.execute(buf, features=features) 196 | 197 | self.qbr.set_buf(buf) 198 | self.fullBuffer = buf 199 | self.shaperOutput.setText(buf.serialize()) 200 | 201 | def prep_shaper(self, shaper, buf, features): 202 | if not self.sliders: 203 | return 204 | buf.vf = self.project.variations 205 | loc = { slider.name: slider.value() for slider in self.sliders } 206 | buf.location = loc 207 | 208 | self.qbr.set_location(loc) 209 | 210 | 211 | def addToTable(self, msg, buffer=None, serialize_options=None): 212 | if msg.startswith("Before"): 213 | return 214 | if not buffer: # Easy one 215 | rowPosition = self.messageTable.rowCount() 216 | self.messageTable.insertRow(rowPosition) 217 | message_item = QTableWidgetItem(msg) 218 | self.messageTable.setItem(rowPosition, 0, message_item) 219 | return 220 | 221 | # Urgh 222 | b = BaseShaper(None, None, buffer) 223 | for i in range(0,len(buffer.items)): 224 | b.propagate_attachment_offsets(i) 225 | 226 | ser = buffer.serialize(additional=serialize_options) 227 | 228 | if self.lastBuffer == ser: 229 | m = re.match(r"After (\w+ \(\w+\))", msg) 230 | if m: 231 | self.skipped.append(m[1]) 232 | return 233 | elif self.skipped: 234 | rowPosition = self.messageTable.rowCount() 235 | self.messageTable.insertRow(rowPosition) 236 | message_item = QTableWidgetItem( 237 | "Routines executed but had no effect: %s" % ",".join(self.skipped) 238 | ) 239 | self.messageTable.setItem(rowPosition, 0, message_item) 240 | self.skipped = [] 241 | self.lastBuffer = ser 242 | rowPosition = self.messageTable.rowCount() 243 | self.messageTable.insertRow(rowPosition) 244 | message_item = QTableWidgetItem(msg) 245 | self.messageTable.setItem(rowPosition, 0, message_item) 246 | self.partialBuffers[rowPosition] = (copy(buffer), msg) 247 | self.partialBuffers[rowPosition][0].items = deepcopy(buffer.items) 248 | buffer_item = QTableWidgetItem(ser) 249 | self.messageTable.setItem(rowPosition, 1, buffer_item) 250 | 251 | def renderPartialTrace(self): 252 | indexes = self.messageTable.selectedIndexes() 253 | if len(indexes) != 2: 254 | return 255 | row = indexes[0].row() 256 | if row in self.partialBuffers: 257 | buf, msg = self.partialBuffers[row] 258 | self.qbr.set_buf(buf) 259 | m = re.match(r"After (\w+) \((\w+)\)", msg) 260 | if m and self.editor: 261 | routine, feature = m[1], m[2] 262 | self.editor.fontfeaturespanel.lookuplist.highlight(routine) 263 | self.editor.fontfeaturespanel.featurelist.highlight(feature, routine) 264 | 265 | # else: 266 | # self.qbr.set_buf(self.fullBuffer) 267 | 268 | def textChanged(self, text): 269 | self.text = text 270 | self.project.debuggingText = text 271 | self.shapeText() 272 | 273 | def getReasonableTextForFont(self, font): 274 | text = "" 275 | if font.glyphForCodepoint(0x627, fallback=False): # Arabic 276 | text = text + "ابج " 277 | if font.glyphForCodepoint(0x915, fallback=False): # Devanagari 278 | text = text + "कचण " 279 | if font.glyphForCodepoint(0x61, fallback=False): # Latin 280 | text = text + "abc " 281 | return text.strip() 282 | -------------------------------------------------------------------------------- /Flux/editor.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtWidgets import ( 3 | QWidget, 4 | QVBoxLayout, 5 | QApplication, 6 | QHBoxLayout, 7 | QStackedWidget, 8 | QMenuBar, 9 | QAction, 10 | QFileDialog, 11 | QSplitter, 12 | QMessageBox 13 | ) 14 | from PyQt5.QtCore import Qt, QSettings, QStandardPaths 15 | from Flux.UI.qfontfeatures import QFontFeaturesPanel 16 | from Flux.UI.qshapingdebugger import QShapingDebugger 17 | from Flux.UI.qruleeditor import QRuleEditor 18 | from Flux.UI.qattachmenteditor import QAttachmentEditor 19 | from Flux.project import FluxProject 20 | from Flux.ThirdParty.qtoaster import QToaster 21 | import Flux.Plugins 22 | import os.path, pkgutil, sys 23 | from functools import partial 24 | from Flux.UI.GlyphActions import QGlyphActionPicker 25 | 26 | 27 | class FluxEditor(QSplitter): 28 | def __init__(self, proj): 29 | super(QSplitter, self).__init__() 30 | self.settings = QSettings() 31 | geometry = self.settings.value('mainwindowgeometry', '') 32 | if geometry: 33 | self.restoreGeometry(geometry) 34 | 35 | self.mainMenu = QMenuBar(self) 36 | self.project = proj 37 | if not proj: 38 | self.openFluxOrFont() # Exits if there still isn't one 39 | self.project.editor = self 40 | self.loadPlugins() 41 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile)) 42 | self.setupFileMenu() 43 | self.setupEditMenu() 44 | self.setupPluginMenu() 45 | self.left = QWidget() 46 | self.right = QWidget() 47 | self.rebuild_ui() 48 | 49 | def rebuild_ui(self): 50 | self.v_box_1 = QVBoxLayout() 51 | self.fontfeaturespanel = QFontFeaturesPanel(self.project, self) 52 | self.v_box_1.addWidget(self.fontfeaturespanel) 53 | 54 | self.setOrientation(Qt.Horizontal) 55 | 56 | self.v_box_2 = QVBoxLayout() 57 | self.stack = QStackedWidget() 58 | self.shapingDebugger = QShapingDebugger(self, self.project) 59 | self.ruleEditor = QRuleEditor(self.project, self, None) 60 | self.attachmentEditor = QAttachmentEditor(self.project, self, None) 61 | self.stack.addWidget(self.shapingDebugger) 62 | self.stack.addWidget(self.ruleEditor) 63 | self.stack.addWidget(self.attachmentEditor) 64 | self.v_box_2.addWidget(self.stack) 65 | 66 | if self.left.layout(): 67 | QWidget().setLayout(self.left.layout()) 68 | QWidget().setLayout(self.right.layout()) 69 | self.left.setLayout(self.v_box_1) 70 | self.right.setLayout(self.v_box_2) 71 | self.addWidget(self.left) 72 | self.addWidget(self.right) 73 | 74 | def loadPlugins(self): 75 | pluginpath = os.path.dirname(Flux.Plugins.__file__) 76 | if hasattr(sys, "frozen"): 77 | pluginpath = "lib/python3.8/flux/Plugins" 78 | pluginpath2 = os.path.join(QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0], "Plugins") 79 | plugin_loaders = pkgutil.iter_modules([pluginpath, pluginpath2]) 80 | self.plugins = {} 81 | for loader, module_name, is_pkg in plugin_loaders: 82 | if is_pkg: 83 | continue 84 | _module = loader.find_module(module_name).load_module(module_name) 85 | _module.module_name = module_name 86 | self.plugins[module_name] = _module 87 | 88 | 89 | def setupFileMenu(self): 90 | openFile = QAction("&New Project", self) 91 | openFile.setShortcut("Ctrl+N") 92 | openFile.setStatusTip("New Project") 93 | openFile.triggered.connect(self.newProject) 94 | 95 | self.saveFile = QAction("&Save", self) 96 | self.saveFile.setShortcut("Ctrl+S") 97 | self.saveFile.setStatusTip("Save") 98 | self.saveFile.triggered.connect(self.file_save) 99 | if not hasattr(self.project, "filename"): 100 | self.saveFile.setEnabled(False) 101 | 102 | saveAsFile = QAction("&Save As...", self) 103 | saveAsFile.setStatusTip("Save As...") 104 | saveAsFile.triggered.connect(self.file_save_as) 105 | 106 | importFea = QAction("Import FEA", self) 107 | importFea.triggered.connect(self.importFEA) 108 | 109 | exportFea = QAction("Export FEA", self) 110 | exportFea.triggered.connect(self.exportFEA) 111 | 112 | exportOtf = QAction("Export OTF", self) 113 | exportOtf.triggered.connect(self.exportOTF) 114 | 115 | fileMenu = self.mainMenu.addMenu("&File") 116 | fileMenu.addAction(openFile) 117 | fileMenu.addAction(self.saveFile) 118 | fileMenu.addAction(saveAsFile) 119 | fileMenu.addSeparator() 120 | fileMenu.addAction(importFea) 121 | fileMenu.addSeparator() 122 | fileMenu.addAction(exportFea) 123 | fileMenu.addAction(exportOtf) 124 | 125 | def setupEditMenu(self): 126 | editMenu = self.mainMenu.addMenu("&Edit") 127 | glyphedit = QAction("Glyph editor", self) 128 | glyphedit.triggered.connect(lambda: QGlyphActionPicker.pickGlyph(self.project)) 129 | editMenu.addAction(glyphedit) 130 | 131 | def setupPluginMenu(self): 132 | pluginMenu = self.mainMenu.addMenu("&Plugins") 133 | for plugin in self.plugins.values(): 134 | p = QAction(plugin.plugin_name, self) 135 | p.triggered.connect(partial(self.runPlugin, plugin)) 136 | pluginMenu.addAction(p) 137 | pluginMenu.addSeparator() 138 | dummy = QAction("Reload plugins", self) 139 | pluginMenu.addAction(dummy) 140 | 141 | def runPlugin(self,plugin): 142 | print(plugin.plugin_name) 143 | dialog = plugin.Dialog(self.project) 144 | result = dialog.exec_() 145 | if result: 146 | # Update everything 147 | self.update() 148 | 149 | def newProject(self): 150 | if self.project and self.isWindowModified(): 151 | # Offer chance to save 152 | pass 153 | # Open the glyphs file 154 | glyphs = QFileDialog.getOpenFileName( 155 | self, "Open font file", filter="Font file (*.glyphs *.ufo *.otf *.ttf *.designspace)" 156 | ) 157 | if not glyphs: 158 | return 159 | self.project = FluxProject.new(glyphs[0], editor=self) 160 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile)) 161 | self.rebuild_ui() 162 | 163 | def openFluxOrFont(self): # Exits if there still isn't one 164 | msg = QMessageBox() 165 | msg.setIcon(QMessageBox.Information) 166 | 167 | msg.setText("Please open a font file or a .fluxml file to get started") 168 | msg.setWindowTitle("Flux") 169 | msg.setStandardButtons(QMessageBox.Ok) 170 | msg.exec_() 171 | 172 | filename = QFileDialog.getOpenFileName( 173 | self, "Open Flux file", filter="Flux or font file (*.glyphs *.ufo *.otf *.ttf *.designspace *.fluxml)" 174 | ) 175 | if not filename or not filename[0]: 176 | if not self.project: 177 | sys.exit(0) 178 | return 179 | if filename[0].endswith(".fluxml"): 180 | self.project = FluxProject(filename[0], editor=self) 181 | else: 182 | self.project = FluxProject.new(filename[0], editor=self) 183 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile)) 184 | 185 | def file_save_as(self): 186 | filename = QFileDialog.getSaveFileName( 187 | self, "Save File", filter="Flux projects (*.fluxml)" 188 | ) 189 | if filename and filename[0]: 190 | self.project.filename = filename[0] 191 | self.project.save(filename[0]) 192 | QToaster.showMessage(self, "Saved successfully", desktop=True) 193 | self.setWindowTitle("Flux - %s" % (self.project.filename or "New Project")) 194 | self.saveFile.setEnabled(True) 195 | self.setWindowModified(False) 196 | 197 | def file_save(self): 198 | if not self.project.filename: 199 | return self.file_save_as() 200 | self.project.save(self.project.filename) 201 | QToaster.showMessage(self, "Saved successfully", desktop=True) 202 | self.setWindowModified(False) 203 | 204 | def importFEA(self): 205 | filename = QFileDialog.getOpenFileName( 206 | self, "Open File", filter="AFDKO feature file (*.fea)" 207 | ) 208 | if not filename: 209 | return 210 | res = self.project.loadFEA(filename[0]) 211 | if res is None: 212 | QToaster.showMessage(self, "Imported successfully", desktop=True, parentWindow=False) 213 | self.update() 214 | else: 215 | QToaster.showMessage(self, "Failed to import: " + res, desktop=True) 216 | 217 | def exportFEA(self): 218 | filename = QFileDialog.getSaveFileName( 219 | self, "Save File", filter="AFDKO feature file (*.fea)" 220 | ) 221 | if not filename: 222 | return 223 | res = self.project.saveFEA(filename[0]) 224 | if res is None: 225 | QToaster.showMessage(self, "Saved successfully", desktop=True) 226 | else: 227 | QToaster.showMessage(self, "Failed to save: " + res, desktop=True) 228 | 229 | def exportOTF(self): 230 | filename = QFileDialog.getSaveFileName( 231 | self, "Save File", filter="OpenType Font (*.ttf)" 232 | ) 233 | if not filename: 234 | return 235 | res = self.project.saveOTF(filename[0]) 236 | if res is None: 237 | QToaster.showMessage(self, "Saved successfully", desktop=True) 238 | else: 239 | QToaster.showMessage(self, "Failed to save: " + res, desktop=True) 240 | 241 | def update(self): 242 | self.fontfeaturespanel.update() 243 | self.shapingDebugger.update() 244 | super().update() 245 | 246 | def reshape(self): 247 | self.shapingDebugger.update() 248 | 249 | def showRuleEditor(self, rule, index=None): 250 | self.ruleEditor.setRule(rule, index) 251 | self.stack.setCurrentIndex(1) 252 | pass 253 | 254 | def showAttachmentEditor(self, rule, index=None): 255 | self.attachmentEditor.setRule(rule, index) 256 | self.stack.setCurrentIndex(2) 257 | pass 258 | 259 | def showDebugger(self): 260 | self.stack.setCurrentIndex(0) 261 | pass 262 | 263 | def closeEvent(self, event): 264 | geometry = self.saveGeometry() 265 | self.settings.setValue('mainwindowgeometry', geometry) 266 | if not self.isWindowModified(): 267 | event.accept() 268 | return 269 | quit_msg = "You have unsaved changes. Are you sure you want to exit the program?" 270 | reply = QMessageBox.question(self, 'Message', 271 | quit_msg, QMessageBox.Yes, QMessageBox.No) 272 | 273 | if reply == QMessageBox.Yes: 274 | event.accept() 275 | else: 276 | event.ignore() 277 | 278 | def showError(self, message): 279 | msg = QMessageBox() 280 | msg.setIcon(QMessageBox.Critical) 281 | 282 | msg.setText("Something went wrong") 283 | msg.setInformativeText(message) 284 | msg.setWindowTitle("Flux") 285 | msg.setStandardButtons(QMessageBox.Ok) 286 | msg.exec_() 287 | 288 | -------------------------------------------------------------------------------- /Flux/UI/glyphpredicateeditor.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import Qt, pyqtSlot, QModelIndex, QAbstractTableModel, QItemSelectionModel, pyqtSignal 2 | from PyQt5.QtGui import QStandardItem, QStandardItemModel 3 | from PyQt5.QtWidgets import QTreeView, QMenu,QComboBox, QHBoxLayout, QVBoxLayout, QPushButton, QLineEdit, QLabel, QDialog, QTextEdit, QDialogButtonBox 4 | import re 5 | from glyphtools import get_glyph_metrics 6 | 7 | 8 | PREDICATE_TYPES = { 9 | "Name": {"textbox": True, "comparator": False}, 10 | "Is member of": {"textbox": False, "comparator": False}, 11 | "Is category": {"textbox": False, "comparator": False}, 12 | "width": {"textbox": True, "comparator": True}, 13 | "height": {"textbox": True, "comparator": True}, 14 | "depth": {"textbox": True, "comparator": True}, 15 | "xMax": {"textbox": True, "comparator": True}, 16 | "yMax": {"textbox": True, "comparator": True}, 17 | "xMin": {"textbox": True, "comparator": True}, 18 | "yMin": {"textbox": True, "comparator": True}, 19 | "rise": {"textbox": True, "comparator": True}, 20 | "Has anchor": {"textbox": True, "comparator": False} 21 | } 22 | 23 | class GlyphClassPredicate: 24 | def __init__(self, predicate_dict = {}): 25 | self.comparator = None 26 | self.combiner = None 27 | self.value = None 28 | self.metric = None 29 | if "type" in predicate_dict: 30 | self.type = predicate_dict["type"] 31 | if "comparator" in predicate_dict: 32 | self.comparator = predicate_dict["comparator"] 33 | if "value" in predicate_dict: 34 | self.value = predicate_dict["value"] 35 | if "metric" in predicate_dict: # Metric, really 36 | self.metric = predicate_dict["metric"] 37 | if "combiner" in predicate_dict: 38 | self.combiner = predicate_dict["combiner"] 39 | 40 | def test(self, glyphset, font, infocache): 41 | matches = [] 42 | if self.type == "Name": 43 | # print(self.comparator, self.value) 44 | if self.comparator == "begins": 45 | matches = [x for x in glyphset if x.startswith(self.value)] 46 | elif self.comparator == "ends": 47 | matches = [x for x in glyphset if x.endswith(self.value)] 48 | elif self.comparator == "matches": 49 | try: 50 | matches = [x for x in glyphset if re.search(self.value,x)] 51 | except Exception as e: 52 | matches = [] 53 | 54 | # XXX HasAnchor 55 | # XXX Is member of 56 | # XXX Is Category 57 | 58 | if self.metric: 59 | matches = [] 60 | try: 61 | for g in glyphset: 62 | if g not in infocache: 63 | infocache[g] = { "metrics": get_glyph_metrics(font, g) } 64 | 65 | got = infocache[g]["metrics"][self.type] 66 | expected, comp = int(self.value), self.comparator 67 | if (comp == ">" and got > expected) or (comp == "<" and got < expected) or (comp == "=" and got == expected) or (comp == "<=" and got <= expected) or (comp == ">=" and got >= expected): 68 | matches.append(g) 69 | except Exception as e: 70 | print(e) 71 | pass 72 | 73 | return matches 74 | 75 | def to_dict(self): 76 | d = { "type": self.type } 77 | if self.comparator: 78 | d["comparator"] = self.comparator 79 | if self.combiner: 80 | d["combiner"] = self.combiner 81 | if self.value is not None: 82 | d["value"] = self.value 83 | if self.metric: 84 | d["metric"] = self.metric 85 | return d 86 | 87 | class GlyphClassPredicateTester: 88 | def __init__(self, project): 89 | self.project = project 90 | self.infocache = {} 91 | self.allGlyphs = self.project.font.keys() 92 | 93 | def test_all(self, predicates): 94 | matches = self.allGlyphs 95 | if len(predicates) > 0: 96 | matches = predicates[0].test(matches, self.project.font, self.infocache) 97 | for p in predicates[1:]: 98 | if p.combiner == "and": 99 | # Narrow down existing set 100 | matches = p.test(matches, self.project.font, self.infocache) 101 | else: 102 | thisPredicateMatches = p.test(self.allGlyphs, self.project.font, self.infocache) 103 | matches = set(matches) | set(thisPredicateMatches) 104 | return matches 105 | 106 | class GlyphClassPredicateRow(QHBoxLayout): 107 | changed = pyqtSignal() 108 | 109 | def __init__(self, editor, predicate = None): 110 | super(QHBoxLayout, self).__init__() 111 | # print("Initializing with ", arguments) 112 | self.editor = editor 113 | self.project = editor.project 114 | self.matches = [] 115 | self.predicate = predicate 116 | self.predicateType = QComboBox() 117 | for n in PREDICATE_TYPES.keys(): 118 | self.predicateType.addItem(n) 119 | self.predicateType.currentIndexChanged.connect(self.maybeChangeType) 120 | self.combiner = QComboBox() 121 | self.combiner.addItem("and") 122 | self.combiner.addItem("or") 123 | self.combiner.currentIndexChanged.connect(self.changed.emit) 124 | self.addWidget(self.combiner) 125 | self.addWidget(QLabel("Glyph")) 126 | self.addWidget(self.predicateType) 127 | self.changed.connect(self.serialize) 128 | self.changed.connect(lambda :self.editor.changed.emit()) 129 | self.reset() 130 | 131 | def maybeChangeType(self): 132 | if self.predicateType.currentText() != self.predicate.type: 133 | self.predicate.type = self.predicateType.currentText() 134 | self.reset() 135 | 136 | def reset(self): # Call this when the type changes 137 | for i in reversed(range(3,self.count())): # We keep the combo boxes 138 | if self.itemAt(i).widget(): 139 | self.itemAt(i).widget().setParent(None) 140 | 141 | typeIx = self.predicateType.findText(self.predicate.type) 142 | if typeIx != 1: 143 | self.predicateType.setCurrentIndex(typeIx) 144 | # print(self.arguments["type"]) 145 | if self.predicate.type == "Name": 146 | self.nameCB = QComboBox() 147 | self.nameCB.addItems(["begins","ends","matches"]) 148 | if self.predicate.comparator: 149 | ix = self.nameCB.findText(self.predicate.comparator) 150 | if ix != 1: 151 | self.nameCB.setCurrentIndex(ix) 152 | self.addWidget(self.nameCB) 153 | self.nameCB.currentIndexChanged.connect(self.changed.emit) 154 | 155 | if self.predicate.type == "Is category": 156 | self.categoryCB = QComboBox() 157 | self.categoryCB.addItems(["base","ligature","mark","component"]) 158 | if self.arguments.value: 159 | ix = self.categoryCB.findText(self.predicate.value) 160 | if ix != 1: 161 | self.categoryCB.setCurrentIndex(ix) 162 | 163 | self.addWidget(self.categoryCB) 164 | self.categoryCB.currentIndexChanged.connect(self.changed.emit) 165 | 166 | if PREDICATE_TYPES[self.predicate.type]["comparator"]: 167 | self.comparator = QComboBox() 168 | self.comparator.addItems(["<","<=","=", ">=", ">"]) 169 | if self.predicate.comparator: 170 | ix = self.comparator.findText(self.predicate.comparator) 171 | if ix != 1: 172 | self.comparator.setCurrentIndex(ix) 173 | self.comparator.currentIndexChanged.connect(self.changed.emit) 174 | self.addWidget(self.comparator) 175 | 176 | if PREDICATE_TYPES[self.predicate.type]["textbox"]: 177 | self.textBox = QLineEdit() 178 | if self.predicate.value: 179 | self.textBox.setText(self.predicate.value) 180 | elif PREDICATE_TYPES[self.predicate.type]["comparator"]: 181 | self.textBox.setText("200") 182 | self.textBox.textChanged.connect(self.changed.emit) 183 | self.addWidget(self.textBox) 184 | self.addStretch(999) 185 | self.plus = QPushButton("+") 186 | self.addWidget(self.plus) 187 | self.plus.clicked.connect(self.editor.addRow) 188 | self.minus = QPushButton("-") 189 | self.addWidget(self.minus) 190 | self.changed.emit() 191 | 192 | def serialize(self): 193 | self.predicate = GlyphClassPredicate() 194 | self.predicate.type = self.predicateType.currentText() 195 | self.predicate.combiner = self.combiner.currentText() 196 | if PREDICATE_TYPES[self.predicate.type]["textbox"]: 197 | self.predicate.value = self.textBox.text() 198 | if self.predicate.type == "Name": 199 | self.predicate.comparator = self.nameCB.currentText() 200 | elif self.predicate.type == "Is member of": 201 | #self.predicate.class = self.classCB.currentText() 202 | pass 203 | elif self.predicate.type == "Has anchor": 204 | #self.predicate.anchor = self.anchorCB.currentText() 205 | pass 206 | elif self.predicate.type == "Is category": 207 | self.predicate.category = self.categoryCB.currentText() 208 | pass 209 | 210 | if PREDICATE_TYPES[self.predicate.type]["comparator"]: 211 | self.predicate.metric = self.predicate.type 212 | self.predicate.comparator = self.comparator.currentText() 213 | # print(self.arguments) 214 | 215 | class AutomatedGlyphClassDialog(QDialog): 216 | def __init__(self, font, predicates = []): 217 | super(QDialog, self).__init__() 218 | self.font = font 219 | v_box_1 = QVBoxLayout() 220 | self.gpe = GlyphClassPredicateEditor(font, predicates) 221 | v_box_1.addLayout(self.gpe) 222 | self.qte = QTextEdit() 223 | v_box_1.addWidget(self.qte) 224 | buttons = QDialogButtonBox( 225 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, 226 | Qt.Horizontal, self) 227 | buttons.accepted.connect(self.accept) 228 | buttons.rejected.connect(self.reject) 229 | v_box_1.addWidget(buttons) 230 | self.setLayout(v_box_1) 231 | self.gpe.changed.connect(self.update) 232 | self.update() 233 | 234 | def update(self): 235 | self.qte.setText(" ".join(sorted(self.gpe.matches))) 236 | 237 | def getPredicateRows(self): 238 | return self.gpe.predicateRows 239 | 240 | @staticmethod 241 | def editDefinition(project, predicates = []): 242 | dialog = AutomatedGlyphClassDialog(project, [ GlyphClassPredicate(p) for p in predicates]) 243 | result = dialog.exec_() 244 | predicaterows = dialog.getPredicateRows() 245 | for x in predicaterows: 246 | x.serialize() 247 | predicates = [ x.predicate.to_dict() for x in predicaterows ] 248 | print(predicates) 249 | return (predicates, result == QDialog.Accepted) 250 | 251 | class GlyphClassPredicateEditor(QVBoxLayout): 252 | changed = pyqtSignal() 253 | 254 | def __init__(self, project, existingpredicates = []): 255 | super(QVBoxLayout, self).__init__() 256 | self.project = project 257 | predicateItems = [] 258 | if len(existingpredicates) == 0: 259 | predicateItems.append(GlyphClassPredicateRow(self, GlyphClassPredicate({"type": "Name"}))) 260 | for p in existingpredicates: 261 | predicateItems.append(GlyphClassPredicateRow(self, p)) 262 | for p in predicateItems: 263 | self.addLayout(p) 264 | predicateItems[-1].minus.setEnabled(False) 265 | predicateItems[0].combiner.hide() 266 | self.matches = [] 267 | self.tester = GlyphClassPredicateTester(self.project) 268 | self.changed.connect(self.testAll) 269 | self.testAll() 270 | 271 | def addRow(self): 272 | self.addLayout(GlyphClassPredicateRow(self, GlyphClassPredicate({"type": "Name"}))) 273 | 274 | def testAll(self): 275 | self.matches = self.tester.test_all([x.predicate for x in self.predicateRows]) 276 | 277 | @property 278 | def predicateRows(self): 279 | return [self.itemAt(i) for i in range(self.count())] 280 | -------------------------------------------------------------------------------- /Flux/UI/featurelist.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QWidget, 3 | QApplication, 4 | QVBoxLayout, 5 | QTreeView, 6 | QMenu, 7 | QAbstractItemView, 8 | ) 9 | from PyQt5.QtCore import ( 10 | QAbstractItemModel, 11 | QItemSelectionModel, 12 | QModelIndex, 13 | Qt, 14 | pyqtSlot, 15 | QByteArray, 16 | QDataStream, 17 | QIODevice, 18 | QMimeData, 19 | QVariant, 20 | ) 21 | from PyQt5.QtGui import QDrag 22 | import sys 23 | from fontFeatures import Routine 24 | from Flux.project import FluxProject 25 | from Flux.ThirdParty.HTMLDelegate import HTMLDelegate 26 | from Flux.constants import FEATURE_DESCRIPTIONS 27 | 28 | 29 | class FeatureList(QTreeView): 30 | def __init__(self, project, parent): 31 | super(QTreeView, self).__init__() 32 | self.project = project 33 | self.parent = parent 34 | self.setItemDelegate(HTMLDelegate()) 35 | self.setModel(FeatureListModel(project)) 36 | self.setContextMenuPolicy(Qt.CustomContextMenu) 37 | self.setSelectionBehavior(self.SelectRows) 38 | self.setDragEnabled(True) 39 | self.setAcceptDrops(True) 40 | self.setDropIndicatorShown(True) 41 | self.setDragDropMode(QAbstractItemView.DragDrop) 42 | self.customContextMenuRequested.connect(self.contextMenu) 43 | self.doubleClicked.connect(self.doubleClickHandler) 44 | self.model().dataChanged.connect(self.reshape) 45 | 46 | def highlight(self, feature, routine=None): 47 | self.collapseAll() 48 | featureRow = list(self.project.fontfeatures.features.keys()).index(feature) 49 | index = self.model().index(featureRow, 0) 50 | if featureRow: 51 | self.scrollTo(self.model().index(featureRow + 1, 0)) 52 | self.setCurrentIndex(index) 53 | self.setExpanded(index, True) 54 | if routine: 55 | routines = [x.name for x in self.project.fontfeatures.features[feature]] 56 | routineRow = routines.index(routine) 57 | index = self.model().index(routineRow, 0, index) 58 | self.scrollTo(self.model().index(featureRow + 1, 0)) 59 | self.setCurrentIndex(index) 60 | self.setExpanded(index, True) 61 | pass 62 | 63 | def reshape(self): 64 | self.parent.editor.reshape() 65 | 66 | def update(self): 67 | self.model().beginResetModel() 68 | self.model().endResetModel() 69 | super().update() 70 | 71 | def decode_data(self, bytearray): 72 | 73 | data = [] 74 | item = {} 75 | 76 | ds = QDataStream(bytearray) 77 | while not ds.atEnd(): 78 | 79 | row = ds.readInt32() 80 | column = ds.readInt32() 81 | 82 | map_items = ds.readInt32() 83 | for i in range(map_items): 84 | 85 | key = ds.readInt32() 86 | 87 | value = QVariant() 88 | ds >> value 89 | item[Qt.ItemDataRole(key)] = value.value() 90 | 91 | data.append(item) 92 | 93 | return data 94 | 95 | def dragEnterEvent(self, event): 96 | if ( 97 | event.mimeData().hasFormat("application/x-qabstractitemmodeldatalist") 98 | and event.source() != self 99 | ): 100 | self.showDropIndicator() 101 | event.acceptProposedAction() 102 | else: 103 | return super().dragEnterEvent(event) 104 | 105 | def dragMoveEvent(self, event): 106 | if ( 107 | event.mimeData().hasFormat("application/x-qabstractitemmodeldatalist") 108 | and event.source() != self 109 | ): 110 | self.showDropIndicator() 111 | event.acceptProposedAction() 112 | else: 113 | return super().dragMoveEvent(event) 114 | 115 | def dropEvent(self, event): 116 | data = event.mimeData() 117 | if event.source() == self: 118 | print("Local") 119 | event.setDropAction(Qt.MoveAction) 120 | 121 | return super(QTreeView, self).dropEvent(event) 122 | print("Foreign") 123 | if data.hasFormat("application/x-qabstractitemmodeldatalist"): 124 | ba = data.data("application/x-qabstractitemmodeldatalist") 125 | data_items = self.decode_data(ba) 126 | routineName = data_items[0][0] 127 | routine = self.model().routineCalled(routineName) 128 | insertPos = event.pos() 129 | destination = self.indexAt(event.pos()) 130 | if self.model().indexIsFeature(destination): 131 | # Easy-peasy 132 | destFeature = list(self.project.fontfeatures.features.keys())[ 133 | destination.row() 134 | ] 135 | routineList = self.project.fontfeatures.features[destFeature] 136 | print(f"Dropping {routineName} to end of {destFeature}") 137 | self.project.fontfeatures.features[destFeature].append(routine) 138 | self.model().dataChanged.emit(destination, destination) 139 | self.update() 140 | self.setExpanded(destination, True) 141 | self.parent.editor.setWindowModified(True) 142 | elif self.model().indexIsRoutine(destination): 143 | destParent = self.model().parent(destination) 144 | print( 145 | "Parent destination for this drop is: ", 146 | destParent.row(), 147 | destParent.column(), 148 | destParent.internalPointer(), 149 | ) 150 | print("Destination inside parent is ", destination.row()) 151 | self.model().insertRows(destination.row(), 1, destParent) 152 | self.model().dataChanged.emit(destination, destination) 153 | self.model().setData(destination, routineName) 154 | self.parent.editor.setWindowModified(True) 155 | else: 156 | event.reject() 157 | return 158 | event.accept() 159 | 160 | def contextMenu(self, position): 161 | indexes = self.selectedIndexes() 162 | menu = QMenu() 163 | menu.addAction("Add feature", self.addFeature) 164 | if len(indexes) > 0: 165 | if self.model().indexIsRoutine(indexes[0]): 166 | menu.addAction("Delete routine", self.deleteItem) 167 | else: 168 | menu.addAction("Delete feature", self.deleteItem) 169 | menu.exec_(self.viewport().mapToGlobal(position)) 170 | 171 | def doubleClickHandler(self, index): 172 | pass 173 | 174 | @pyqtSlot() 175 | def addFeature(self): 176 | index = self.model().appendRow() 177 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect) 178 | self.parent.editor.setWindowModified(True) 179 | self.edit(index) 180 | 181 | @pyqtSlot() 182 | def deleteItem(self): 183 | # Check if routine is in use 184 | self.model().removeRowWithIndex(self.selectedIndexes()[0]) 185 | self.parent.editor.setWindowModified(True) 186 | self.parent.editor.update() 187 | 188 | @pyqtSlot() 189 | def deleteRoutine(self): 190 | # Check if routine is in use 191 | self.model().removeRowWithIndex(self.selectedIndexes()[0]) 192 | self.parent.editor.setWindowModified(True) 193 | self.parent.editor.update() 194 | 195 | 196 | class FeatureListModel(QAbstractItemModel): 197 | def __init__(self, proj, parent=None): 198 | super(FeatureListModel, self).__init__(parent) 199 | self.project = proj 200 | self.rootIndex = QModelIndex() 201 | self.retained_objects = {} 202 | 203 | # Horrific hack to avoid GC bug 204 | def makeSingleton(self, row): 205 | if not row in self.retained_objects: 206 | self.retained_objects[row] = {"row": row} 207 | return self.retained_objects[row] 208 | 209 | def headerData(self, section, orientation, role): 210 | if role != Qt.DisplayRole: 211 | return None 212 | return "Features" 213 | 214 | def rowCount(self, index=QModelIndex()): 215 | # print("Getting row count at index ", self.describeIndex(index)) 216 | if index.row() == -1: 217 | return len(self.project.fontfeatures.features.keys()) 218 | if index.isValid(): 219 | item = index.internalPointer() 220 | if isinstance(item, list): 221 | return len(item) 222 | return 0 223 | 224 | def getFeatureNameAtRow(self, row): 225 | keys = list(self.project.fontfeatures.features.keys()) 226 | return str(keys[row]) 227 | 228 | def getRoutinesAtRow(self, row): 229 | values = list(self.project.fontfeatures.features.values()) 230 | return values[row] 231 | 232 | def columnCount(self, index=QModelIndex()): 233 | return 1 234 | 235 | def describeIndex(self, index): 236 | if not index.isValid(): 237 | return "root of tree" 238 | if self.indexIsFeature(index): 239 | frow = index.row() 240 | return f"feature at row {frow}" 241 | else: 242 | parentrow = index.internalPointer()["row"] 243 | return f"Item {index.row()} of feature at row {parentrow}" 244 | 245 | def parent(self, index): 246 | if not index.isValid(): 247 | return QModelIndex() 248 | if isinstance(index.internalPointer(), list): 249 | return QModelIndex() 250 | else: 251 | row = index.internalPointer()["row"] 252 | return self.index(row, 0) 253 | return index.internalPointer() 254 | 255 | def index(self, row, column, parent=QModelIndex()): 256 | """ Returns the index of the item in the model specified by the given row, column and parent index """ 257 | if not self.hasIndex(row, column, parent): 258 | return QModelIndex() 259 | if not parent.isValid(): 260 | ix = self.createIndex(row, column, self.getRoutinesAtRow(row)) 261 | else: 262 | ix = self.createIndex(row, column, self.makeSingleton(parent.row())) 263 | return ix 264 | 265 | def change_key(self, old, new): 266 | for _ in range(len(self.project.fontfeatures.features)): 267 | k, v = self.project.fontfeatures.features.popitem(False) 268 | self.project.fontfeatures.features[new if old == k else k] = v 269 | 270 | def setData(self, index, value, role=Qt.EditRole): 271 | print("Set data called", index, index.row(), index.column()) 272 | print( 273 | "Parent ", 274 | self.parent(index), 275 | self.parent(index).row(), 276 | self.parent(index).column(), 277 | ) 278 | print("Role was", role) 279 | print("Index was " + self.describeIndex(index)) 280 | print("Value was ", value) 281 | print("Internal pointer", index.internalPointer()) 282 | if self.indexIsFeature(index): 283 | print("Renaming a feature", index.internalPointer()) 284 | self.dataChanged.emit(index, index) 285 | self.change_key( 286 | list(self.project.fontfeatures.features.keys())[index.row()], value 287 | ) 288 | return True 289 | else: 290 | routines = self.getRoutinesAtRow(index.internalPointer()["row"]) 291 | print( 292 | "Internal pointer of parent before set", 293 | self.parent(index).internalPointer(), 294 | ) 295 | print( 296 | "Setting routine", 297 | index.row(), 298 | index.column(), 299 | routines, 300 | self.parent(index), 301 | ) 302 | routines[index.row()] = self.routineCalled(value) 303 | self.dataChanged.emit(index, index) 304 | print( 305 | "Internal pointer of parent now", self.parent(index).internalPointer() 306 | ) 307 | return True 308 | 309 | def routineCalled(self, value): 310 | return ([x for x in self.project.fontfeatures.routines if x.name == value])[0] 311 | 312 | def indexIsRoutine(self, index): 313 | return index.isValid() and isinstance(index.internalPointer(), dict) 314 | 315 | def getRoutine(self, index): 316 | routines = self.getRoutinesAtRow(index.internalPointer()["row"]) 317 | return routines[index.row()] 318 | 319 | def indexIsFeature(self, index): 320 | return index.isValid() and isinstance(index.internalPointer(), list) 321 | 322 | def data(self, index, role=Qt.DisplayRole): 323 | if not index.isValid(): 324 | return None 325 | if role == Qt.DisplayRole or role == Qt.EditRole: 326 | if self.indexIsFeature(index): 327 | featureName = self.getFeatureNameAtRow(index.row()) 328 | if role == Qt.EditRole: 329 | return featureName 330 | featureDescription = FEATURE_DESCRIPTIONS.get(featureName, "") 331 | return f'{featureName} {featureDescription}' 332 | else: 333 | routine = self.getRoutine(index) 334 | if routine: 335 | return routine.name 336 | else: 337 | return "" 338 | return None 339 | 340 | def flags(self, index): 341 | if not index.isValid(): 342 | return Qt.NoItemFlags 343 | flag = Qt.ItemFlags(QAbstractItemModel.flags(self, index)) 344 | if self.indexIsFeature(index): 345 | flag = ( 346 | flag | Qt.ItemIsEditable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled 347 | ) 348 | return flag | Qt.ItemIsDragEnabled 349 | 350 | def insertRows(self, row, count, parent=QModelIndex()): 351 | """ Insert a row into the model. """ 352 | print("Inserting a row at row ", row) 353 | print("Parent= ", self.describeIndex(parent)) 354 | self.beginInsertRows(parent, row, row + count) 355 | if not parent.isValid(): 356 | self.project.fontfeatures.features[""] = [] 357 | else: 358 | parent.internalPointer().insert(row, None) 359 | print("Internal pointer of parent is now", parent.internalPointer()) 360 | self.endInsertRows() 361 | return True 362 | 363 | def appendRow(self): 364 | self.insertRows(len(self.project.fontfeatures.features.keys()), 1) 365 | return self.index(len(self.project.fontfeatures.features.keys()) - 1, 0) 366 | 367 | def removeRows(self, row, count, parent): 368 | assert count == 1 369 | index = self.index(row, 0, parent) 370 | return self.removeRowWithIndex(index) 371 | 372 | def removeRowWithIndex(self, index): 373 | print("Remove row called", index) 374 | self.beginRemoveRows(self.parent(index), index.row(), index.row()) 375 | if self.indexIsFeature(index): 376 | key = list(self.project.fontfeatures.features.keys())[index.row()] 377 | del self.project.fontfeatures.features[key] 378 | else: 379 | routineList = self.getRoutinesAtRow(index.internalPointer()["row"]) 380 | del routineList[index.row()] 381 | self.endRemoveRows() 382 | return True 383 | 384 | def addRule(self, ix, rule): 385 | # lookup = ix.internalPointer() 386 | # self.beginInsertRows(ix, ix.row(), ix.row()) 387 | # lookup.rules.append(rule) 388 | # self.endInsertRows() 389 | return True 390 | 391 | def supportedDropActions(self): 392 | return Qt.MoveAction # | Qt.CopyAction 393 | 394 | 395 | if __name__ == "__main__": 396 | app = 0 397 | if QApplication.instance(): 398 | app = QApplication.instance() 399 | else: 400 | app = QApplication(sys.argv) 401 | 402 | w = QWidget() 403 | w.resize(510, 210) 404 | v_box_1 = QVBoxLayout() 405 | 406 | proj = FluxProject("qalam.fluxml") 407 | tree = FeatureList(proj, None) 408 | v_box_1.addWidget(tree) 409 | 410 | w.setLayout(v_box_1) 411 | 412 | w.show() 413 | sys.exit(app.exec_()) 414 | -------------------------------------------------------------------------------- /Flux/UI/lookuplist.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QWidget, 3 | QApplication, 4 | QVBoxLayout, 5 | QTreeView, 6 | QMenu, 7 | QAbstractItemView, 8 | QCheckBox, 9 | QDialog, 10 | QDialogButtonBox, 11 | QLabel, 12 | QLineEdit, 13 | QMessageBox 14 | ) 15 | from PyQt5.QtCore import ( 16 | QAbstractItemModel, 17 | QItemSelectionModel, 18 | QModelIndex, 19 | Qt, 20 | pyqtSlot, 21 | QMimeData 22 | ) 23 | from PyQt5.QtGui import QValidator 24 | import sys 25 | from fontFeatures import ( 26 | Routine, 27 | Attachment, 28 | Substitution, 29 | Positioning, 30 | Chaining, 31 | ValueRecord, 32 | Rule 33 | ) 34 | from Flux.project import FluxProject 35 | import re 36 | from Flux.ThirdParty.HTMLDelegate import HTMLDelegate 37 | from Flux.computedroutine import ComputedRoutine 38 | from Flux.dividerroutine import DividerRoutine 39 | 40 | 41 | class FeatureValidator(QValidator): 42 | def validate(self, s, pos): 43 | if re.search(r"[^a-z0-9]", s) or len(s) > 4: 44 | return (QValidator.Invalid, s, pos) 45 | if len(s) < 4: 46 | return (QValidator.Intermediate, s, pos) 47 | return (QValidator.Acceptable, s, pos) 48 | 49 | 50 | class LookupFlagEditor(QDialog): 51 | 52 | simpleChecks = [ 53 | (0x02, "Ignore Base Glyphs"), 54 | (0x08, "Ignore Mark Glyphs"), 55 | (0x04, "Ignore Ligatures"), 56 | (0x01, "Cursive last glyph on baseline"), 57 | ] 58 | 59 | def __init__(self, routine): 60 | super(QDialog, self).__init__() 61 | self.checkboxes = [] 62 | self.routine = routine 63 | self.flags = routine.flags 64 | layout = QVBoxLayout() 65 | for flagbit, description in self.simpleChecks: 66 | cb = QCheckBox(description) 67 | cb.flagbit = flagbit 68 | cb.stateChanged.connect(self.toggleBit) 69 | if routine.flags & flagbit: 70 | cb.setCheckState(Qt.Checked) 71 | layout.addWidget(cb) 72 | 73 | buttons = QDialogButtonBox( 74 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self 75 | ) 76 | buttons.accepted.connect(self.accept) 77 | buttons.rejected.connect(self.reject) 78 | layout.addWidget(buttons) 79 | self.setLayout(layout) 80 | 81 | @pyqtSlot() 82 | def toggleBit(self): 83 | cb = self.sender() 84 | self.flags = self.flags ^ cb.flagbit 85 | 86 | def accept(self): 87 | self.routine.flags = self.flags 88 | super().accept() 89 | 90 | 91 | class AddToFeatureDialog(QDialog): 92 | def __init__(self): 93 | super(QDialog, self).__init__() 94 | self.featureEdit = QLineEdit() 95 | self.featureEdit.setValidator(FeatureValidator()) 96 | layout = QVBoxLayout() 97 | layout.addWidget(QLabel("Add this routine to feature...")) 98 | layout.addWidget(self.featureEdit) 99 | buttons = QDialogButtonBox( 100 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self 101 | ) 102 | buttons.accepted.connect(self.accept) 103 | buttons.rejected.connect(self.reject) 104 | layout.addWidget(buttons) 105 | self.setLayout(layout) 106 | 107 | def accept(self): 108 | if not self.featureEdit.hasAcceptableInput(): 109 | return 110 | self.feature = self.featureEdit.text() 111 | return super().accept() 112 | 113 | 114 | class LookupList(QTreeView): 115 | def __init__(self, project, parent): 116 | super(QTreeView, self).__init__() 117 | self.project = project 118 | self.parent = parent 119 | self.setModel(LookupListModel(project, parent=self)) 120 | self.setItemDelegate(HTMLDelegate()) 121 | self.setContextMenuPolicy(Qt.CustomContextMenu) 122 | self.setDragEnabled(True) 123 | self.setAcceptDrops(True) 124 | self.setDefaultDropAction(Qt.TargetMoveAction) 125 | self.setDropIndicatorShown(True) 126 | self.setDragDropMode(QAbstractItemView.InternalMove) 127 | self.setDragDropOverwriteMode(False) 128 | self.customContextMenuRequested.connect(self.contextMenu) 129 | self.doubleClicked.connect(self.doubleClickHandler) 130 | 131 | def highlight(self, routineName): 132 | routineNames = [x.name for x in self.project.fontfeatures.routines] 133 | if not routineName in routineNames: 134 | return 135 | self.collapseAll() 136 | routineRow = routineNames.index(routineName) 137 | if routineRow: 138 | self.scrollTo(self.model().index(routineRow + 1, 0)) 139 | self.setCurrentIndex(self.model().index(routineRow, 0)) 140 | self.setExpanded(self.model().index(routineRow, 0), True) 141 | pass 142 | 143 | def update(self, index=QModelIndex()): 144 | if index.isValid(): 145 | self.model().dataChanged.emit(index, index) 146 | else: 147 | self.model().beginResetModel() 148 | self.model().dataChanged.emit(index, index) 149 | self.model().endResetModel() 150 | super().update() 151 | 152 | def startDrag(self, dropActions): 153 | item = self.selectedIndexes()[0].internalPointer() 154 | if not isinstance(item, Routine): 155 | return 156 | super(QTreeView, self).startDrag(dropActions) 157 | 158 | def contextMenu(self, position): 159 | indexes = self.selectedIndexes() 160 | menu = QMenu() 161 | menu.addAction("Add routine", self.addRoutine) 162 | if indexes: 163 | thing = indexes[0].internalPointer() 164 | if isinstance(thing, Rule) and hasattr(thing, "computed"): 165 | pass 166 | elif isinstance(thing, Routine): 167 | menu.addAction("Delete routine", self.deleteItem) 168 | menu.addAction("Add to feature...", self.addToFeature) 169 | menu.addAction("Add separator", self.addSeparatorRoutine) 170 | if isinstance(thing, ComputedRoutine): 171 | menu.addAction("Reify", self.reify) 172 | else: 173 | menu.addAction("Add substitution rule", self.addSubRule) 174 | menu.addAction("Add positioning rule", self.addPosRule) 175 | menu.addAction("Add attachment rule", self.addAttRule) 176 | menu.addAction("Add chaining rule", self.addChainRule) 177 | menu.addAction("Set routine flags", self.setFlags) 178 | else: 179 | menu.addAction("Delete rule", self.deleteItem) 180 | menu.exec_(self.viewport().mapToGlobal(position)) 181 | 182 | @pyqtSlot() 183 | def addSubRule(self): 184 | self.model().addRule(self.selectedIndexes()[0], Substitution([[]], [[]])) 185 | self.parent.editor.setWindowModified(True) 186 | 187 | @pyqtSlot() 188 | def addPosRule(self): 189 | self.model().addRule( 190 | self.selectedIndexes()[0], Positioning([[]], [ValueRecord()]) 191 | ) 192 | self.parent.editor.setWindowModified(True) 193 | 194 | @pyqtSlot() 195 | def addChainRule(self): 196 | self.model().addRule(self.selectedIndexes()[0], Chaining([[]], lookups=[[]])) 197 | self.parent.editor.setWindowModified(True) 198 | 199 | @pyqtSlot() 200 | def addAttRule(self): 201 | self.model().addRule(self.selectedIndexes()[0], Attachment("", "")) 202 | self.parent.editor.setWindowModified(True) 203 | 204 | @pyqtSlot() 205 | def addToFeature(self): 206 | index = self.selectedIndexes()[0] 207 | routine = index.internalPointer() 208 | dialog = AddToFeatureDialog() 209 | result = dialog.exec_() 210 | if result: 211 | self.project.fontfeatures.addFeature(dialog.feature, [routine]) 212 | self.model().dataChanged.emit(index, index) 213 | self.parent.editor.setWindowModified(True) 214 | self.parent.editor.update() 215 | 216 | @pyqtSlot() 217 | def setFlags(self): 218 | index = self.selectedIndexes()[0] 219 | routine = index.internalPointer() 220 | dialog = LookupFlagEditor(routine) 221 | result = dialog.exec_() 222 | if result: 223 | self.model().dataChanged.emit(index, index) 224 | self.parent.editor.setWindowModified(True) 225 | 226 | 227 | @pyqtSlot() 228 | def reify(self): 229 | index = self.selectedIndexes()[0] 230 | msg = QMessageBox() 231 | msg.setIcon(QMessageBox.Question) 232 | msg.setWindowTitle("Are you sure?") 233 | msg.setText("A manual routine will no longer automatically respond to changes in your font. Do you still want to convert this to a manual routine?") 234 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) 235 | retval = msg.exec_() 236 | if retval == QMessageBox.Yes: 237 | self._reify(index) 238 | 239 | def _reify(self, index): 240 | oldroutine = index.internalPointer() 241 | rindex = index.row() 242 | newroutine = oldroutine.reify() 243 | self.project.fontfeatures.routines[rindex] = newroutine 244 | for k,v in self.project.fontfeatures.features.items(): 245 | self.project.fontfeatures.features[k] = [ newroutine if r==oldroutine else r for r in v] 246 | 247 | self.parent.editor.setWindowModified(True) 248 | self.parent.editor.update() 249 | 250 | def doubleClickHandler(self, index): 251 | if isinstance(index.internalPointer(), Routine): 252 | return 253 | if hasattr(index.internalPointer(), "computed"): 254 | index = index.parent() 255 | msg = QMessageBox() 256 | msg.setIcon(QMessageBox.Question) 257 | msg.setWindowTitle("Reify?") 258 | msg.setText("This is an automatic rule. To edit it manually, you need to convert its routine to a manual routine. Do you want to do this now?") 259 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) 260 | retval = msg.exec_() 261 | if retval == QMessageBox.Yes: 262 | self._reify(index) 263 | return 264 | if isinstance(index.internalPointer(), Attachment): 265 | self.parent.editor.showAttachmentEditor( 266 | index.internalPointer(), index=index 267 | ) 268 | return 269 | self.parent.editor.showRuleEditor(index.internalPointer(), index=index) 270 | 271 | @pyqtSlot() 272 | def addRoutine(self): 273 | if self.selectedIndexes(): 274 | index = self.selectedIndexes()[0] 275 | self.model().insertRows(index.row()+1, 1) 276 | index = self.model().index(index.row()+1, 0) 277 | else: 278 | index = self.model().appendRow() 279 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect) 280 | self.edit(index) 281 | self.parent.editor.setWindowModified(True) 282 | 283 | @pyqtSlot() 284 | def addSeparatorRoutine(self): 285 | index = self.selectedIndexes()[0] 286 | self.model().insertRows(index.row()+1, 1) 287 | self.project.fontfeatures.routines[index.row()+1] = DividerRoutine() 288 | self.parent.editor.setWindowModified(True) 289 | 290 | @pyqtSlot() 291 | def deleteItem(self): 292 | # XXX Check if routine is in use. Remove from Feature 293 | self.model().removeRows(self.selectedIndexes()[0].row(), 1, QModelIndex()) 294 | self.parent.editor.setWindowModified(True) 295 | 296 | 297 | class LookupListModel(QAbstractItemModel): 298 | def __init__(self, proj, parent=None): 299 | super(LookupListModel, self).__init__(parent) 300 | self._parent = parent 301 | self.project = proj 302 | 303 | def headerData(self, section, orientation, role): 304 | if role != Qt.DisplayRole: 305 | return None 306 | return "Lookups" 307 | 308 | def rowCount(self, index=QModelIndex()): 309 | if index.row() == -1: 310 | return len(self.project.fontfeatures.routines) 311 | if index.isValid(): 312 | item = index.internalPointer() 313 | if isinstance(item, Routine): 314 | item.editor = self._parent.parent.editor 315 | return len(item.rules) 316 | return 0 317 | 318 | def columnCount(self, index=QModelIndex()): 319 | return 1 320 | 321 | def supportedDropActions(self): 322 | return Qt.MoveAction 323 | 324 | def parent(self, index): 325 | if isinstance(index.internalPointer(), Routine): 326 | return QModelIndex() 327 | rule = index.internalPointer() 328 | # Now go find it 329 | for row, routine in enumerate(self.project.fontfeatures.routines): 330 | routine.editor = self._parent.parent.editor 331 | if rule in routine.rules: 332 | return self.createIndex(row, 0, routine) 333 | return QModelIndex() 334 | 335 | def index(self, row, column, index=QModelIndex()): 336 | """ Returns the index of the item in the model specified by the given row, column and parent index """ 337 | 338 | if not self.hasIndex(row, column, index): 339 | return QModelIndex() 340 | # print(row, column, index.internalPointer()) 341 | if not index.isValid(): 342 | ix = self.createIndex(row, column, self.project.fontfeatures.routines[row]) 343 | else: 344 | item = index.internalPointer() 345 | item.editor = self._parent.parent.editor 346 | ix = self.createIndex(row, column, item.rules[row]) 347 | return ix 348 | 349 | def setData(self, index, value, role=Qt.EditRole): 350 | if role != Qt.EditRole: 351 | return False 352 | 353 | if index.isValid() and 0 <= index.row() < len( 354 | self.project.fontfeatures.routines 355 | ): 356 | if isinstance(self.project.fontfeatures.routines[index.row()], DividerRoutine): 357 | self.project.fontfeatures.routines[index.row()].comment = value 358 | else: 359 | self.project.fontfeatures.routines[index.row()].name = value 360 | # self.dataChanged.emit(index, index) 361 | return True 362 | 363 | return False 364 | 365 | def indexIsRoutine(self, index): 366 | item = index.internalPointer() 367 | return isinstance(item, Routine) 368 | 369 | def indexIsRule(self, index): 370 | item = index.internalPointer() 371 | return not isinstance(item, Routine) 372 | 373 | def describeFlags(self, routine): 374 | flags = [] 375 | if not routine.flags: 376 | return "" 377 | if routine.flags & 0x2: 378 | flags.append("IgnoreBase") 379 | if routine.flags & 0x8: 380 | flags.append("IgnoreMark") 381 | if routine.flags & 0x4: 382 | flags.append("IgnoreLig") 383 | if routine.flags & 0x1: 384 | flags.append("RightToLeft") 385 | if len(flags): 386 | return " (" + ",".join(flags) + ")" 387 | return "" 388 | 389 | def data(self, index, role=Qt.DisplayRole): 390 | # print("Getting index ", index.row(), index.column(), index.internalPointer()) 391 | if not index.isValid(): 392 | return None 393 | item = index.internalPointer() 394 | if role == Qt.EditRole and self.indexIsRoutine(index) and isinstance(item,DividerRoutine): 395 | return item.comment 396 | if role == Qt.DisplayRole or role == Qt.EditRole: 397 | if self.indexIsRoutine(index): 398 | if isinstance(item, DividerRoutine): 399 | return f'{item.comment or "————"}' 400 | else: 401 | return (item.name or "")+ self.describeFlags(item) 402 | elif hasattr(item, "computed"): 403 | return f'{item.asFea()}' 404 | elif isinstance(item, Attachment): 405 | return ( 406 | f'Attach {item.mark_name or "Nothing"} to {item.base_name or "Nothing"}' 407 | + self.describeFlags(item) 408 | ) 409 | else: 410 | fea = item.asFea() or "" % item.__class__.__name__ 411 | return fea.split("\n")[0] 412 | return None 413 | 414 | def flags(self, index): 415 | if not index.isValid(): 416 | return Qt.ItemIsDropEnabled 417 | flag = Qt.ItemFlags(QAbstractItemModel.flags(self, index)) 418 | if self.indexIsRoutine(index): 419 | return flag | Qt.ItemIsEditable | Qt.ItemIsDragEnabled 420 | return flag | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled 421 | 422 | def insertRows(self, position, rows=1, parent=QModelIndex()): 423 | """ Insert a row into the model. """ 424 | assert(rows == 1) 425 | self.beginInsertRows(QModelIndex(), position, position + rows - 1) 426 | 427 | self.project.fontfeatures.routines.insert(position,Routine(name="", rules=[])) 428 | self.endInsertRows() 429 | return True 430 | 431 | def appendRow(self): 432 | self.insertRows(len(self.project.fontfeatures.routines)) 433 | return self.index(len(self.project.fontfeatures.routines) - 1, 0) 434 | 435 | def removeRows(self, row, count, parent): 436 | self.beginRemoveRows(parent, row, row+1) 437 | self.project.fontfeatures.routines.pop(row) 438 | self.endRemoveRows() 439 | return True 440 | 441 | def mimeData(self, indexes): 442 | mimeData = super().mimeData(indexes) 443 | mimeData.setText("row id: %i" % indexes[0].row()) 444 | return mimeData 445 | 446 | def dropMimeData(self, data, action, destrow, column, parent): 447 | if parent.isValid(): 448 | return False 449 | if not data.hasText() or not "row id:" in data.text(): 450 | return False 451 | rowid = int(data.text()[7:]) 452 | routines = self.project.fontfeatures.routines 453 | self.beginInsertRows(QModelIndex(), destrow, destrow) 454 | routines.insert(destrow, routines[rowid]) 455 | self.endInsertRows() 456 | return True 457 | 458 | def removeRow(self, index): 459 | """ Remove a row from the model. """ 460 | self.beginRemoveRows(self.parent(index), index.row(), index.row()) 461 | if self.indexIsRoutine(index): 462 | del self.project.fontfeatures.routines[index.row()] 463 | else: 464 | lookup = self.parent(index).internalPointer() 465 | del lookup.rules[index.row()] 466 | self.endRemoveRows() 467 | return True 468 | 469 | def addRule(self, ix, rule): 470 | lookup = ix.internalPointer() 471 | self.beginInsertRows(ix, ix.row(), ix.row()) 472 | lookup.rules.append(rule) 473 | self.endInsertRows() 474 | return True 475 | 476 | 477 | if __name__ == "__main__": 478 | from fluxproject import FluxProject 479 | 480 | app = 0 481 | if QApplication.instance(): 482 | app = QApplication.instance() 483 | else: 484 | app = QApplication(sys.argv) 485 | 486 | w = QWidget() 487 | w.resize(510, 210) 488 | v_box_1 = QVBoxLayout() 489 | 490 | proj = FluxProject("qalam.fluxml") 491 | 492 | tree = LookupList(proj) 493 | v_box_1.addWidget(tree) 494 | 495 | w.setLayout(v_box_1) 496 | 497 | w.show() 498 | sys.exit(app.exec_()) 499 | -------------------------------------------------------------------------------- /Flux/UI/qruleeditor.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QWidget, 3 | QApplication, 4 | QScrollArea, 5 | QHBoxLayout, 6 | QVBoxLayout, 7 | QSplitter, 8 | QLabel, 9 | QLineEdit, 10 | QSpinBox, 11 | QPushButton, 12 | QCheckBox, 13 | QComboBox, 14 | QDialog, 15 | QDialogButtonBox, 16 | QCompleter, 17 | QSizePolicy, 18 | QStyle 19 | ) 20 | from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QStringListModel, QSize 21 | from fontFeatures.shaperLib.Shaper import Shaper 22 | from .qbufferrenderer import QBufferRenderer 23 | from .qglyphname import QGlyphName 24 | from fontFeatures import ( 25 | Positioning, 26 | ValueRecord, 27 | Substitution, 28 | Chaining, 29 | Rule, 30 | Routine, 31 | RoutineReference, 32 | ) 33 | import sys 34 | import darkdetect 35 | from Flux.variations import VariationAwareBuffer, VariationAwareBufferItem 36 | from fontFeatures.shaperLib.Buffer import Buffer, BufferItem 37 | 38 | 39 | if darkdetect.isDark(): 40 | precontext_style = "background-color: #322b2b" 41 | postcontext_style = "background-color: #2b2b32" 42 | else: 43 | precontext_style = "background-color: #ffaaaa" 44 | postcontext_style = "background-color: #aaaaff" 45 | 46 | 47 | class QValueRecordEditor(QWidget): 48 | changed = pyqtSignal() 49 | fieldnames = ["xPlacement", "yPlacement", "xAdvance", "yAdvance"] 50 | labelnames = ["Δx", "Δy", "+x", "+y"] 51 | 52 | def __init__(self, vr, vf=None, master=None): 53 | self.valuerecord = vr 54 | self.boxlayout = QHBoxLayout() 55 | self.boxes = [] 56 | self.master = master 57 | self.vf = vf 58 | super(QWidget, self).__init__() 59 | self.add_boxes() 60 | self.prep_fields() 61 | 62 | def add_boxes(self): 63 | for ix, k in enumerate(self.fieldnames): 64 | t = QSpinBox() 65 | t.setSingleStep(10) 66 | t.setRange(-10000, 10000) 67 | t.valueChanged.connect(self.serialize) 68 | self.boxes.append(t) 69 | self.boxlayout.addWidget(t) 70 | 71 | def prep_fields(self): 72 | vr = self.valuerecord 73 | if self.master: 74 | vr = self.valuerecord.get_value_for_master(self.vf, self.master) 75 | for ix, k in enumerate(self.fieldnames): 76 | t = self.boxes[ix] 77 | t.setValue(getattr(vr, k) or 0) 78 | # label = QLabel(t) 79 | # label.setText(self.labelnames[ix]) 80 | # label.move(label.x()+0,label.y()-50) 81 | self.setLayout(self.boxlayout) 82 | 83 | def change_master(self, master): 84 | self.serialize() 85 | self.master = master 86 | self.prep_fields() 87 | 88 | def serialize(self): 89 | value = {self.fieldnames[ix]: int(self.boxes[ix].value()) for ix in range(len(self.boxes))} 90 | 91 | if self.master: 92 | self.valuerecord.set_value_for_master(self.vf, self.master, ValueRecord(**value)) 93 | else: 94 | for attr, val in value.items(): 95 | setattr(self.valuerecord, attr, val) 96 | self.changed.emit() 97 | 98 | 99 | class QRuleEditor(QDialog): 100 | def __init__(self, project, editor, rule): # Rule is some fontFeatures object 101 | self.project = project 102 | self.editor = editor 103 | self.inputslots = [] 104 | self.precontextslots = [] 105 | self.postcontextslots = [] 106 | self.outputslots = [] 107 | self.buffer_direction = "RTL" 108 | self.buffer_script = "Latin" 109 | self.all_valuerecord_editors = [] 110 | self.index = None 111 | if rule: 112 | self.backup_rule = Rule.fromXML(rule.toXML()) # Deep copy 113 | else: 114 | self.backup_rule = None 115 | 116 | super(QRuleEditor, self).__init__() 117 | 118 | splitter = QSplitter() 119 | self.slotview = QHBoxLayout() 120 | scroll = QScrollArea() 121 | scroll.setLayout(self.slotview) 122 | 123 | self.outputview_before = QBufferRenderer(project, VariationAwareBuffer(self.project.font)) 124 | self.outputview_after = QBufferRenderer(project, VariationAwareBuffer(self.project.font)) 125 | self.before_after = QWidget() 126 | self.before_after_layout_v = QVBoxLayout() 127 | 128 | self.asFea = QLabel() 129 | 130 | featureButtons = QWidget() 131 | self.featureButtonLayout = QHBoxLayout() 132 | featureButtons.setLayout(self.featureButtonLayout) 133 | self.selectedFeatures = [] 134 | 135 | layoutarea = QWidget() 136 | self.before_after_layout_h = QHBoxLayout() 137 | self.before_after_layout_h.addWidget(self.outputview_before) 138 | self.before_after_layout_h.addWidget(self.outputview_after) 139 | layoutarea.setLayout(self.before_after_layout_h) 140 | 141 | if self.project.variations: 142 | self.master_selection = QComboBox() 143 | for mastername in self.project.variations.masters: 144 | self.master_selection.addItem(mastername) 145 | self.master_selection.currentTextChanged.connect(self.masterChanged) 146 | self.before_after_layout_v.addWidget(self.master_selection) 147 | else: 148 | self.master_selection = None 149 | 150 | self.before_after_layout_v.addWidget(featureButtons) 151 | self.before_after_layout_v.addWidget(self.asFea) 152 | self.before_after_layout_v.addWidget(layoutarea) 153 | 154 | self.before_after.setLayout(self.before_after_layout_v) 155 | 156 | splitter.setOrientation(Qt.Vertical) 157 | splitter.addWidget(scroll) 158 | 159 | splitter.addWidget(self.before_after) 160 | buttons = QDialogButtonBox( 161 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self 162 | ) 163 | for button in buttons.buttons(): 164 | button.setDefault(False) 165 | button.setAutoDefault(False) 166 | buttons.accepted.connect(self.accept) 167 | buttons.rejected.connect(self.reject) 168 | v_box_1 = QVBoxLayout() 169 | self.setLayout(v_box_1) 170 | v_box_1.addWidget(splitter) 171 | v_box_1.addWidget(buttons) 172 | self.setRule(rule) 173 | 174 | @property 175 | def currentMaster(self): 176 | if not self.master_selection: 177 | return None 178 | return self.master_selection.currentText() 179 | 180 | def masterChanged(self): 181 | for qvre in self.all_valuerecord_editors: 182 | qvre.change_master(self.currentMaster) 183 | self.resetBuffer() 184 | 185 | def keyPressEvent(self, evt): 186 | return 187 | 188 | def accept(self): 189 | self.editor.fontfeaturespanel.lookuplist.update(self.index) 190 | self.editor.setWindowModified(True) 191 | self.editor.showDebugger() 192 | 193 | def reject(self): 194 | for k in dir(self.backup_rule): 195 | self.rule = getattr(self.backup_rule, k) 196 | self.editor.fontfeaturespanel.lookuplist.update() 197 | self.editor.showDebugger() 198 | 199 | def setRule(self, rule, index=None): 200 | self.rule = rule 201 | self.index = index 202 | self.arrangeSlots() 203 | self.representative_string = self.makeRepresentativeString() 204 | self.resetBuffer() 205 | 206 | @property 207 | def location(self): 208 | sourceIndex = list(self.project.variations.masters.keys()).index(self.currentMaster) 209 | return self.project.variations.designspace.sources[sourceIndex].location 210 | 211 | def resetBuffer(self): 212 | if self.rule: 213 | try: 214 | self.asFea.setText(self.rule.asFea()) 215 | except Exception as e: 216 | print("Can't serialize", e) 217 | self.outputview_before.set_buf(self.makeBuffer("before")) 218 | self.outputview_after.set_buf(self.makeBuffer("after")) 219 | if self.currentMaster: 220 | self.outputview_before.set_location(self.location) 221 | self.outputview_after.set_location(self.location) 222 | 223 | @pyqtSlot() 224 | def changeRepresentativeString(self): 225 | l = self.sender() 226 | if l.text().startswith("@"): 227 | self.representative_string[ 228 | l.slotnumber 229 | ] = self.project.fontfeatures.namedClasses[l.text()[1:]][0] 230 | else: 231 | self.representative_string[l.slotnumber] = l.text() 232 | 233 | self.resetBuffer() 234 | 235 | @pyqtSlot() 236 | def replacementChanged(self): 237 | l = self.sender() 238 | replacements = l.text().split() 239 | self.rule.replacement = [[x] for x in replacements] 240 | self.resetBuffer() 241 | 242 | @pyqtSlot() 243 | def addGlyphToSlot(self): 244 | l = self.sender() 245 | glyphname = l.text() 246 | # Check for class names 247 | if ( 248 | glyphname.startswith("@") 249 | and glyphname[1:] in self.project.fontfeatures.namedClasses.keys() 250 | ): 251 | # It's OK 252 | pass 253 | elif glyphname not in self.project.font.keys(): 254 | print(f"{glyphname} not found") 255 | l.setText("") 256 | return 257 | print("Adding ", glyphname) 258 | l.owner.contents[l.owner.slotindex].append(glyphname) 259 | self.arrangeSlots() 260 | self.representative_string = self.makeRepresentativeString() 261 | self.resetBuffer() 262 | 263 | @pyqtSlot() 264 | def removeGlyphFromSlot(self): 265 | l = self.sender() 266 | del l.contents[l.slotindex][l.indexWithinSlot] 267 | self.arrangeSlots() 268 | self.representative_string = self.makeRepresentativeString() 269 | self.resetBuffer() 270 | 271 | @pyqtSlot() 272 | def addRemoveSlot(self): 273 | sender = self.sender() 274 | action = sender.text() 275 | if action == "<+": 276 | sender.contents.insert(0, []) 277 | # If these are input glyphs, add another replacement etc. 278 | if sender.contents == self.rule.shaper_inputs(): 279 | if isinstance(self.rule, Positioning): 280 | self.rule.valuerecords.insert(0, ValueRecord()) 281 | elif ( 282 | isinstance(self.rule, Substitution) 283 | and len(self.rule.shaper_inputs()) == 1 284 | ): 285 | self.rule.replacement.insert(0, []) 286 | elif isinstance(self.rule, Chaining): 287 | self.rule.lookups.insert(0, []) 288 | elif action == "+>": 289 | sender.contents.append([]) 290 | # If these are input glyphs, add another replacement etc. 291 | if sender.contents == self.rule.shaper_inputs(): 292 | if isinstance(self.rule, Positioning): 293 | self.rule.valuerecords.append(ValueRecord()) 294 | elif ( 295 | isinstance(self.rule, Substitution) 296 | and len(self.rule.shaper_inputs()) == 1 297 | ): 298 | self.rule.replacement.append([]) 299 | elif isinstance(self.rule, Chaining): 300 | self.rule.lookups.append([]) 301 | 302 | elif action == "-": 303 | del sender.contents[self.sender().ix] 304 | self.arrangeSlots() 305 | self.representative_string = self.makeRepresentativeString() 306 | self.resetBuffer() 307 | 308 | def makeASlot(self, slotnumber, contents, style=None, editingWidgets=None): 309 | for ix, glyphslot in enumerate(contents): 310 | slot = QWidget() 311 | slotLayout = QVBoxLayout() 312 | if style: 313 | slot.setStyleSheet(style) 314 | 315 | 316 | scroll = QScrollArea() 317 | scroll.setWidgetResizable(True) 318 | scrollWidget = QWidget() 319 | scrollLayout = QVBoxLayout() 320 | scrollWidget.setLayout(scrollLayout) 321 | scroll.setWidget(scrollWidget) 322 | for ixWithinSlot, glyph in enumerate(glyphslot): 323 | glyphHolder = QWidget() 324 | glyphHolderLayout = QHBoxLayout() 325 | glyphHolder.setLayout(glyphHolderLayout) 326 | l = QPushButton(glyph) 327 | l.setDefault(False) 328 | l.setAutoDefault(False) 329 | l.slotnumber = slotnumber 330 | l.clicked.connect(self.changeRepresentativeString) 331 | glyphHolderLayout.addWidget(l) 332 | 333 | remove = QPushButton("x") 334 | remove.slotindex = ix 335 | remove.indexWithinSlot = ixWithinSlot 336 | remove.contents = contents 337 | remove.clicked.connect(self.removeGlyphFromSlot) 338 | glyphHolderLayout.addWidget(remove) 339 | scrollLayout.addWidget(glyphHolder) 340 | 341 | slotLayout.addWidget(scroll) 342 | 343 | # This is the part that adds a new glyph to a slot 344 | newglyph = QGlyphName(self.project, allow_classes=True) 345 | newglyph.slotindex = ix 346 | newglyph.contents = contents 347 | newglyph.glyphline.returnPressed.connect(self.addGlyphToSlot) 348 | slotLayout.addWidget(newglyph) 349 | 350 | slotLayout.addStretch() 351 | if editingWidgets and ix < len(editingWidgets): 352 | slotLayout.addWidget(editingWidgets[ix]) 353 | 354 | pushbuttonsArea = QWidget() 355 | pushbuttonsLayout = QHBoxLayout() 356 | pushbuttonsArea.setLayout(pushbuttonsLayout) 357 | if ix == 0: 358 | addASlotLeft = QPushButton("<+") 359 | addASlotLeft.contents = contents 360 | addASlotLeft.clicked.connect(self.addRemoveSlot) 361 | pushbuttonsLayout.addWidget(addASlotLeft) 362 | pushbuttonsLayout.addStretch() 363 | if not (editingWidgets and len(contents) == 1): 364 | removeASlot = QPushButton("-") 365 | removeASlot.contents = contents 366 | removeASlot.ix = ix 367 | removeASlot.clicked.connect(self.addRemoveSlot) 368 | pushbuttonsLayout.addWidget(removeASlot) 369 | pushbuttonsLayout.addStretch() 370 | if ix == len(contents) - 1: 371 | addASlotRight = QPushButton("+>") 372 | addASlotRight.contents = contents 373 | addASlotRight.clicked.connect(self.addRemoveSlot) 374 | pushbuttonsLayout.addWidget(addASlotRight) 375 | slotLayout.addWidget(pushbuttonsArea) 376 | 377 | slotnumber = slotnumber + 1 378 | 379 | slot.setLayout(slotLayout) 380 | self.slotview.addWidget(slot) 381 | return slotnumber 382 | 383 | def lookupCombobox(self, current, warning): 384 | c = QComboBox() 385 | c.warning = warning 386 | names = [ 387 | x.name 388 | for x in self.project.fontfeatures.routines 389 | if not hasattr(x, "comment") 390 | ] 391 | names = ["--- No lookup ---"] + names 392 | for name in names: 393 | c.addItem(name) 394 | if current in names: 395 | c.setCurrentIndex(names.index(current)) 396 | self.setComboboxWarningIfNeeded(c) 397 | return c 398 | 399 | @pyqtSlot() 400 | def chainingLookupChanged(self): 401 | l = self.sender() 402 | if l.currentIndex() == 0: 403 | self.rule.lookups[l.ix] = [] 404 | else: 405 | self.rule.lookups[l.ix] = [RoutineReference(name=l.currentText())] 406 | self.setComboboxWarningIfNeeded(l) 407 | self.resetBuffer() 408 | 409 | def changesGlyphstringLength(self, routine, depth=1): 410 | if depth > 10: 411 | return False 412 | for r in routine.rules: 413 | if isinstance(r, Substitution) and len(r.input) != len(r.replacement): 414 | return True 415 | elif isinstance(r, Chaining): 416 | for lus in r.lookups: 417 | for l in (lus or []): 418 | if self.changesGlyphstringLength(l.routine, depth+1): 419 | return True 420 | return False 421 | 422 | def setComboboxWarningIfNeeded(self, combobox): 423 | # Find routine 424 | rname = combobox.currentText() 425 | warningNeeded = False 426 | if rname: 427 | routine = None 428 | for r in self.project.fontfeatures.routines: 429 | if r.name == rname: 430 | routine = r 431 | if routine and self.changesGlyphstringLength(routine): 432 | stdicon = self.style().standardIcon(QStyle.SP_MessageBoxWarning) 433 | combobox.warning.setPixmap(stdicon.pixmap(stdicon.actualSize(QSize(16, 16)))) 434 | combobox.warning.setToolTip("This lookup may change the length of the glyph stream. Subsequent lookups may not fire at the glyph slots you expect.") 435 | else: 436 | combobox.warning.clear() 437 | combobox.warning.setToolTip("") 438 | 439 | def addPrecontext(self): 440 | self.rule.precontext = [[]] 441 | self.arrangeSlots() 442 | 443 | def addPostcontext(self): 444 | self.rule.postcontext = [[]] 445 | self.arrangeSlots() 446 | 447 | def makeEditingWidgets(self): 448 | editingWidgets = [] 449 | if isinstance(self.rule, Substitution): 450 | replacements = [x[0] for x in self.rule.replacement if x] 451 | widget = QGlyphName( 452 | self.project, multiple=len(self.rule.shaper_inputs()) < 2 453 | ) 454 | widget.setText(" ".join(replacements) or "") 455 | widget.position = 0 456 | widget.returnPressed.connect(self.replacementChanged) 457 | editingWidgets.append(widget) 458 | else: 459 | for ix, i in enumerate(self.rule.shaper_inputs()): 460 | if isinstance(self.rule, Positioning): 461 | widget = QValueRecordEditor(self.rule.valuerecords[ix], 462 | vf=self.project.variations, 463 | master=self.currentMaster) 464 | widget.changed.connect(self.resetBuffer) 465 | editingWidgets.append(widget) 466 | self.all_valuerecord_editors.append(widget) 467 | elif isinstance(self.rule, Chaining): 468 | lookup = self.rule.lookups[ix] and self.rule.lookups[ix][0].name 469 | w = QWidget() 470 | wl = QHBoxLayout(w) 471 | w.setLayout(wl) 472 | warning = QLabel() 473 | widget = self.lookupCombobox(lookup, warning) 474 | widget.ix = ix 475 | widget.currentTextChanged.connect(self.chainingLookupChanged) 476 | wl.addWidget(widget) 477 | wl.addWidget(warning) 478 | editingWidgets.append(w) 479 | return editingWidgets 480 | 481 | def clearLayout(self, layout): 482 | if layout is not None: 483 | while layout.count(): 484 | item = layout.takeAt(0) 485 | widget = item.widget() 486 | if widget is not None: 487 | widget.deleteLater() 488 | else: 489 | self.clearLayout(item.layout()) 490 | 491 | def arrangeSlots(self): 492 | self.all_valuerecord_editors = [] 493 | self.clearLayout(self.slotview) 494 | if not self.rule: 495 | return 496 | 497 | slotnumber = 0 498 | 499 | if not hasattr(self.rule, "precontext") or not self.rule.precontext: 500 | widget = QPushButton("<<+") 501 | widget.clicked.connect(self.addPrecontext) 502 | self.slotview.addWidget(widget) 503 | else: 504 | self.slotview.addStretch() 505 | slotnumber = self.makeASlot( 506 | slotnumber, self.rule.precontext, precontext_style 507 | ) 508 | 509 | editingWidgets = self.makeEditingWidgets() 510 | slotnumber = self.makeASlot( 511 | slotnumber, self.rule.shaper_inputs(), editingWidgets=editingWidgets 512 | ) 513 | 514 | if not hasattr(self.rule, "postcontext") or not self.rule.postcontext: 515 | widget = QPushButton("+>>") 516 | widget.clicked.connect(self.addPostcontext) 517 | self.slotview.addWidget(widget) 518 | else: 519 | self.makeASlot(slotnumber, self.rule.postcontext, postcontext_style) 520 | 521 | self.slotview.addStretch() 522 | 523 | def makeRepresentativeString(self): 524 | inputglyphs = [] 525 | if not self.rule: 526 | return inputglyphs 527 | # "x and x[0]" thing because slots may be empty if newly added 528 | if hasattr(self.rule, "precontext"): 529 | inputglyphs.extend([x and x[0] for x in self.rule.precontext]) 530 | 531 | inputglyphs.extend([x and x[0] for x in self.rule.shaper_inputs()]) 532 | 533 | if hasattr(self.rule, "postcontext"): 534 | inputglyphs.extend([x and x[0] for x in self.rule.postcontext]) 535 | 536 | representative_string = [x for x in inputglyphs if x] 537 | for ix, g in enumerate(representative_string): 538 | if ( 539 | g.startswith("@") 540 | and g[1:] in self.project.fontfeatures.namedClasses.keys() 541 | ): 542 | representative_string[ix] = self.project.fontfeatures.namedClasses[ 543 | g[1:] 544 | ][0] 545 | 546 | # We use this representative string to guess information about 547 | # how the *real* shaping process will take place; buffer direction 548 | # and script, and hence choice of complex shaper, and hence from 549 | # that choice of features to be processed. 550 | unicodes = [ 551 | self.project.font.codepointForGlyph(x) for x in representative_string 552 | ] 553 | unicodes = [x for x in unicodes if x] 554 | tounicodes = "".join(map(chr, unicodes)) 555 | bufferForGuessing = Buffer(self.project.font, unicodes=tounicodes) 556 | self.buffer_direction = bufferForGuessing.direction 557 | self.buffer_script = bufferForGuessing.script 558 | # print("Guessed buffer direction ", self.buffer_direction) 559 | # print("Guessed buffer script ", self.buffer_script) 560 | shaper = Shaper(self.project.fontfeatures, self.project.font) 561 | bufferForGuessing = Buffer(self.project.font, glyphs=representative_string) 562 | shaper.execute(bufferForGuessing) 563 | self.availableFeatures = [] 564 | for stage in shaper.stages: 565 | if not isinstance(stage, list): 566 | continue 567 | for f in stage: 568 | if ( 569 | f not in self.availableFeatures 570 | and f in self.project.fontfeatures.features 571 | ): 572 | self.availableFeatures.append(f) 573 | self.makeFeatureButtons() 574 | 575 | return representative_string 576 | 577 | def makeFeatureButtons(self): 578 | self.clearLayout(self.featureButtonLayout) 579 | for f in self.availableFeatures: 580 | self.selectedFeatures.append(f) 581 | featureButton = QCheckBox(f) 582 | featureButton.setChecked(True) 583 | featureButton.stateChanged.connect(self.resetBuffer) 584 | self.featureButtonLayout.addWidget(featureButton) 585 | 586 | def makeShaperFeatureArray(self): 587 | features = [] 588 | for i in range(self.featureButtonLayout.count()): 589 | item = self.featureButtonLayout.itemAt(i).widget() 590 | features.append({"tag": item.text(), "value": item.isChecked()}) 591 | return features 592 | 593 | def makeBuffer(self, before_after="before"): 594 | buf = VariationAwareBuffer( 595 | self.project.font, 596 | direction=self.buffer_direction, 597 | ) 598 | if self.project.variations: 599 | buf.location = self.location 600 | buf.vf = self.project.variations 601 | buf.items = [VariationAwareBufferItem.new_glyph(g, self.project.font, buf) for g in self.representative_string] 602 | shaper = Shaper(self.project.fontfeatures, self.project.font) 603 | 604 | shaper.execute(buf, features=self.makeShaperFeatureArray()) 605 | routine = Routine(rules=[self.rule]) 606 | # print("Before shaping: ", buf.serialize()) 607 | if before_after == "after" and self.rule: 608 | print("Before application: ", buf.serialize()) 609 | print(self.rule.asFea()) 610 | buf.clear_mask() # XXX 611 | try: 612 | routine.apply_to_buffer(buf) 613 | except Exception as e: 614 | print("Couldn't shape: " + str(e)) 615 | print("After application: ", buf.serialize()) 616 | return buf 617 | 618 | 619 | if __name__ == "__main__": 620 | from fluxproject import FluxProject 621 | 622 | app = 0 623 | if QApplication.instance(): 624 | app = QApplication.instance() 625 | else: 626 | app = QApplication(sys.argv) 627 | 628 | w = QWidget() 629 | w.resize(510, 210) 630 | v_box_1 = QVBoxLayout() 631 | 632 | proj = FluxProject("qalam.fluxml") 633 | proj.fontfeatures.features["mark"] = [proj.fontfeatures.routines[2]] 634 | proj.fontfeatures.features["curs"] = [proj.fontfeatures.routines[1]] 635 | 636 | # v = ValueRecord(yPlacement=0) 637 | # rule = Positioning( 638 | # [["dda", "tda"]], 639 | # [v], 640 | # precontext=[["BEm2", "BEi3"]], 641 | # postcontext=[["GAFm1", "GAFf1"]], 642 | # ) 643 | rule = Substitution(input_=[["space"]], replacement=[["space"]]) 644 | 645 | v_box_1.addWidget(QRuleEditor(proj, None)) 646 | 647 | w.setLayout(v_box_1) 648 | 649 | w.show() 650 | sys.exit(app.exec_()) 651 | --------------------------------------------------------------------------------