Skip to content

_base

Full documentation pages are generated for docstring reference only and may contain symbols imported from other modules. Imported symbols are not distinguished from locally defined symbols and will appear in any module that they are imported into. For better information on where symbols should be imported from, review the sourcecode on the github.

FoSpy.ui.app.block_widgets._base

SCROLL_WIDTH module-attribute

SCROLL_WIDTH = (
    SIDEBAR_WIDTH - SIDEBAR_MARGINS[0] - SIDEBAR_MARGINS[2]
)

SIDEBAR_MARGINS module-attribute

SIDEBAR_MARGINS = (10, 10, 10, 10)

SIDEBAR_WIDTH module-attribute

SIDEBAR_WIDTH = 400

CommentEditorWidget

Bases: BaseEditorWidget

Source code in FoSpy/ui/app/editors/comments.py
class CommentEditorWidget(BaseEditorWidget):
    def __init__(self, block_widget, blk, prop_name, btn):
        super().__init__(block_widget,
            CommentsEditorPanel(blk, prop_name)
        )
        self.btn = btn
        self.editor.btn = btn
        self.on_apply = lambda: None
        self.base_layout.setStretchFactor(self.hintPanel, 0)

    def refresh_editor(self):
        self.editor.refresh_view()

    def apply(self):
        self.editor.send_comments()
        super().apply()

    def is_changed(self):
        blk_comments = self.editor.get_comments()

        editor_comments = list(self.editor.comment_rows.values())

        return blk_comments != editor_comments

btn instance-attribute

btn = btn

on_apply instance-attribute

on_apply = lambda: None

__init__

__init__(block_widget, blk, prop_name, btn)
Source code in FoSpy/ui/app/editors/comments.py
def __init__(self, block_widget, blk, prop_name, btn):
    super().__init__(block_widget,
        CommentsEditorPanel(blk, prop_name)
    )
    self.btn = btn
    self.editor.btn = btn
    self.on_apply = lambda: None
    self.base_layout.setStretchFactor(self.hintPanel, 0)

apply

apply()
Source code in FoSpy/ui/app/editors/comments.py
def apply(self):
    self.editor.send_comments()
    super().apply()

is_changed

is_changed()
Source code in FoSpy/ui/app/editors/comments.py
def is_changed(self):
    blk_comments = self.editor.get_comments()

    editor_comments = list(self.editor.comment_rows.values())

    return blk_comments != editor_comments

refresh_editor

refresh_editor()
Source code in FoSpy/ui/app/editors/comments.py
def refresh_editor(self):
    self.editor.refresh_view()

ListBlockWidget

Bases: QWidget

Source code in FoSpy/ui/app/block_widgets/_base.py
class ListBlockWidget(QWidget):
    def __init__(self, label:str, blk:ListBlock, window:MainWindow):
        self.win = window
        self.blk = blk
        parent = window.splitter
        self.blk_widgets = {}
        self.current_tab = 0

        super().__init__(parent)

        self.header_row, base_layout = _add_header(self, label, "Tab View")

        lr_row = QHBoxLayout()
        lr_row.addWidget(QLabel("<h4>Move Active Tab:</h4>"))

        l_btn = QPushButton("<<")
        l_btn.clicked.connect(self.move_tab_left)
        lr_row.addWidget(l_btn)

        r_btn = QPushButton(">>")
        r_btn.clicked.connect(self.move_tab_right)
        lr_row.addWidget(r_btn)

        lr_row.addStretch()
        base_layout.addLayout(lr_row)


        main_layout = QVBoxLayout()
        main_layout.setContentsMargins(10, 10, 10, 10)
        base_layout.addLayout(main_layout)

        tabs = QTabWidget(self)
        tabs.setMovable(False)
        self.tabs = tabs

        for i, child_blk in enumerate(self.blk._objs):
            label = _get_label(child_blk, i)
            widget = _get_widget(child_blk)


            tab_content = widget(label, child_blk, window)

            delete_btn = QPushButton("Delete this Block")
            delete_btn.clicked.connect(lambda *_, blk=child_blk: self.remove_block(blk))
            tab_content.header_row.addWidget(delete_btn)
            tab_content.header_row.addStretch()

            self.blk_widgets[child_blk] = tab_content
            tabs.addTab(tab_content, label)

        for temp_id, template in self.blk._staged_templates.items():
            label = _get_template_label(template)
            widget = _get_widget(template)

            tab_content = widget(label, template, window)
            delete_btn = QPushButton("Delete this Template")
            delete_btn.clicked.connect(lambda *_, blk=template: self.remove_block(blk))
            tab_content.header_row.addWidget(delete_btn)
            tab_content.header_row.addStretch()

            self.blk_widgets[template] = tab_content
            tabs.addTab(tab_content, label)

        plus_tab = QWidget()
        add_btn = QPushButton("Add New Block")
        add_btn.clicked.connect(self.add_block)
        add_btn.setMaximumWidth(200)

        plus_layout = QVBoxLayout()
        plus_layout.addWidget(add_btn)
        plus_layout.addStretch()
        plus_tab.setLayout(plus_layout)

        self.tabs.addTab(plus_tab, "+")

        tabs.currentChanged.connect(self.on_tab_changed)

        main_layout.addWidget(tabs)

    @staticmethod
    def hard_refresh(func):
        def decorated(self, *args, **kwargs):
            try:
                current_blk = self._find_block(self.tabs.currentIndex())
            except IndexError:
                current_blk = self.blk

            def pending(f=func, a=args, k=kwargs):
                f(self, *a, **k)

            return self.win.hard_refresh(current_blk, func=pending, to_blk=True)
        return decorated

    def _find_block(self, idx):
        if idx < len(self.blk._objs):
            return self.blk._objs[idx]
        else:
            return list(self.blk._staged_templates.values())[idx - len(self.blk._objs)]

    def _find_idx(self, blk):
        if blk in self.blk._objs:
            return self.blk._objs.index(blk)
        else:
            return len(self.blk._objs) + list(self.blk._staged_templates.values()).index(blk)

    @hard_refresh
    def add_block(self):
        new_name = self.win._get_text_inputs(
            "Add New Block",
            "Enter a nickname for the new block:",
            "Nickname"
        )

        self.blk.stage_template(new_name)

    def move_tab_left(self):
        blk = self._find_block(self.current_tab)
        if blk not in self.blk._objs or self.blk._objs.index(blk) == 0:
            return

        self.hard_refresh(lambda s, b=blk: s.blk.order_up(b))(self)

    def move_tab_right(self):
        blk = self._find_block(self.current_tab)
        if blk not in self.blk._objs or self.blk._objs.index(blk) == len(self.blk._objs) - 1:
            return

        self.hard_refresh(lambda s, b=blk: s.blk.order_down(b))(self)

    def on_tab_changed(self, index:int):
        if index == self.tabs.count() - 1:
            self.on_tab_changed(self.current_tab)
            return self.add_block()

        blk = self._find_block(index)
        self.win.go_to_block(blk)
        self.current_tab = index

    def go_to_tab(self, blk):
        idx = self._find_idx(blk)
        self.tabs.setCurrentIndex(idx)

    def find_widget(self, blk):
        idx = self._find_idx(blk)
        return self.tabs.widget(idx)

    def remove_block(self, blk):
        if not self.win._custom_popup(
            "Delete this block?",
            "Are you sure you want to delete this block? "
            "Deleted blocks cannot be recovered once any changes are saved.",
            ("Delete", True),
            cancel=True
        ):
            return


        self.blk.remove_block(blk)
        self.win._flag_edited(self.blk)
        if hasattr(self.blk, "_parent_block"):
            self.win.go_to_block(self.blk._parent_block)
        self.win.go_to_block(self.blk)

blk instance-attribute

blk = blk

blk_widgets instance-attribute

blk_widgets = {}

current_tab instance-attribute

current_tab = 0

tabs instance-attribute

tabs = tabs

win instance-attribute

win = window

__init__

__init__(label, blk, window)
Source code in FoSpy/ui/app/block_widgets/_base.py
def __init__(self, label:str, blk:ListBlock, window:MainWindow):
    self.win = window
    self.blk = blk
    parent = window.splitter
    self.blk_widgets = {}
    self.current_tab = 0

    super().__init__(parent)

    self.header_row, base_layout = _add_header(self, label, "Tab View")

    lr_row = QHBoxLayout()
    lr_row.addWidget(QLabel("<h4>Move Active Tab:</h4>"))

    l_btn = QPushButton("<<")
    l_btn.clicked.connect(self.move_tab_left)
    lr_row.addWidget(l_btn)

    r_btn = QPushButton(">>")
    r_btn.clicked.connect(self.move_tab_right)
    lr_row.addWidget(r_btn)

    lr_row.addStretch()
    base_layout.addLayout(lr_row)


    main_layout = QVBoxLayout()
    main_layout.setContentsMargins(10, 10, 10, 10)
    base_layout.addLayout(main_layout)

    tabs = QTabWidget(self)
    tabs.setMovable(False)
    self.tabs = tabs

    for i, child_blk in enumerate(self.blk._objs):
        label = _get_label(child_blk, i)
        widget = _get_widget(child_blk)


        tab_content = widget(label, child_blk, window)

        delete_btn = QPushButton("Delete this Block")
        delete_btn.clicked.connect(lambda *_, blk=child_blk: self.remove_block(blk))
        tab_content.header_row.addWidget(delete_btn)
        tab_content.header_row.addStretch()

        self.blk_widgets[child_blk] = tab_content
        tabs.addTab(tab_content, label)

    for temp_id, template in self.blk._staged_templates.items():
        label = _get_template_label(template)
        widget = _get_widget(template)

        tab_content = widget(label, template, window)
        delete_btn = QPushButton("Delete this Template")
        delete_btn.clicked.connect(lambda *_, blk=template: self.remove_block(blk))
        tab_content.header_row.addWidget(delete_btn)
        tab_content.header_row.addStretch()

        self.blk_widgets[template] = tab_content
        tabs.addTab(tab_content, label)

    plus_tab = QWidget()
    add_btn = QPushButton("Add New Block")
    add_btn.clicked.connect(self.add_block)
    add_btn.setMaximumWidth(200)

    plus_layout = QVBoxLayout()
    plus_layout.addWidget(add_btn)
    plus_layout.addStretch()
    plus_tab.setLayout(plus_layout)

    self.tabs.addTab(plus_tab, "+")

    tabs.currentChanged.connect(self.on_tab_changed)

    main_layout.addWidget(tabs)

_find_block

_find_block(idx)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _find_block(self, idx):
    if idx < len(self.blk._objs):
        return self.blk._objs[idx]
    else:
        return list(self.blk._staged_templates.values())[idx - len(self.blk._objs)]

_find_idx

_find_idx(blk)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _find_idx(self, blk):
    if blk in self.blk._objs:
        return self.blk._objs.index(blk)
    else:
        return len(self.blk._objs) + list(self.blk._staged_templates.values()).index(blk)

add_block

add_block()
Source code in FoSpy/ui/app/block_widgets/_base.py
@hard_refresh
def add_block(self):
    new_name = self.win._get_text_inputs(
        "Add New Block",
        "Enter a nickname for the new block:",
        "Nickname"
    )

    self.blk.stage_template(new_name)

find_widget

find_widget(blk)
Source code in FoSpy/ui/app/block_widgets/_base.py
def find_widget(self, blk):
    idx = self._find_idx(blk)
    return self.tabs.widget(idx)

go_to_tab

go_to_tab(blk)
Source code in FoSpy/ui/app/block_widgets/_base.py
def go_to_tab(self, blk):
    idx = self._find_idx(blk)
    self.tabs.setCurrentIndex(idx)

hard_refresh staticmethod

hard_refresh(func)
Source code in FoSpy/ui/app/block_widgets/_base.py
@staticmethod
def hard_refresh(func):
    def decorated(self, *args, **kwargs):
        try:
            current_blk = self._find_block(self.tabs.currentIndex())
        except IndexError:
            current_blk = self.blk

        def pending(f=func, a=args, k=kwargs):
            f(self, *a, **k)

        return self.win.hard_refresh(current_blk, func=pending, to_blk=True)
    return decorated

move_tab_left

move_tab_left()
Source code in FoSpy/ui/app/block_widgets/_base.py
def move_tab_left(self):
    blk = self._find_block(self.current_tab)
    if blk not in self.blk._objs or self.blk._objs.index(blk) == 0:
        return

    self.hard_refresh(lambda s, b=blk: s.blk.order_up(b))(self)

move_tab_right

move_tab_right()
Source code in FoSpy/ui/app/block_widgets/_base.py
def move_tab_right(self):
    blk = self._find_block(self.current_tab)
    if blk not in self.blk._objs or self.blk._objs.index(blk) == len(self.blk._objs) - 1:
        return

    self.hard_refresh(lambda s, b=blk: s.blk.order_down(b))(self)

on_tab_changed

on_tab_changed(index)
Source code in FoSpy/ui/app/block_widgets/_base.py
def on_tab_changed(self, index:int):
    if index == self.tabs.count() - 1:
        self.on_tab_changed(self.current_tab)
        return self.add_block()

    blk = self._find_block(index)
    self.win.go_to_block(blk)
    self.current_tab = index

remove_block

remove_block(blk)
Source code in FoSpy/ui/app/block_widgets/_base.py
def remove_block(self, blk):
    if not self.win._custom_popup(
        "Delete this block?",
        "Are you sure you want to delete this block? "
        "Deleted blocks cannot be recovered once any changes are saved.",
        ("Delete", True),
        cancel=True
    ):
        return


    self.blk.remove_block(blk)
    self.win._flag_edited(self.blk)
    if hasattr(self.blk, "_parent_block"):
        self.win.go_to_block(self.blk._parent_block)
    self.win.go_to_block(self.blk)

MainWindow

Bases: QMainWindow

Source code in FoSpy/ui/app/window.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
class MainWindow(QMainWindow):
    def __init__(self, open_path:pathlib.Path | str | None=None, copy:bool | None=None):
        """Initialize the FoSpy viewer app window.

        Args:
            open_path (pathlib.Path|str, optional):
                Open the file at this path on startup. Defaults to None.
            copy (bool, optional):
                - True: Open the editor with an unsaved copy of the file.
                - False: Open the editor with the original file.
                - None: GUI prompt.
        """
        super().__init__()

        self.tree_visible = True

        self.setWindowTitle(WINDOW_TITLE)
        self.resize(*WINDOW_DIMENSIONS)

        # map of block -> tree item
        self.tree_items = {}

        # build dropdown ribbon
        self._create_menu_bar()

        main_splitter = QSplitter(Qt.Orientation.Horizontal)
        self.setCentralWidget(main_splitter)
        self.splitter = main_splitter


        self._build_tree()

        # content area
        self.content_stack = QStackedWidget()
        main_splitter.addWidget(self.content_stack)

        if copy is None:
            copy = self._startup_copy_dlg(open_path)
            if copy is DLG_ESCAPE:
                open_path = None
                copy = False

        self._open_file(open_path=open_path, copy=copy)

        sys.excepthook = self.handle_exception

    def closeEvent(self, event):
        if not self._unsaved_dlg("exiting"):
            return event.ignore()

        return super().closeEvent(event)

    def handle_exception(self, exctype, value, tb):
        options = [
            ("Continue", False),
            ("View Full Error Details", True)
        ]

        if cfg.get("APP.debug"):
            exc = exctype(value)
            exc.__traceback__ = tb
            options.append(("Raise Exception", exc))


        resp = self._custom_popup(
            "Error!",
            "An error has occurred:\n\n"
            f"{exctype.__name__}: {value}",
            *options,
            cancel=False
        )

        if isinstance(resp, Exception):
            raise resp

        if resp:
            import subprocess
            import sys
            import tempfile

            with tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w", encoding="utf-8") as tmp:
                tmp.write("".join(traceback.format_exception(exctype, value, tb)))
                tmp.close()

                if sys.platform.startswith("win"):
                    os.startfile(tmp.name)
                elif sys.platform.startswith("darwin"):
                    subprocess.call(["open", tmp.name])
                else:
                    QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))

    def _startup_copy_dlg(self, open_path):
        if open_path is None:
            return False

        path_str = os.path.abspath(open_path)

        return self._custom_popup(
            "FoSpy GUI -File Opened on Startup",
            "You are opening the file below on startup. "
            "Do you want to edit the file directly or make a copy?\n\n"
            + path_str,
            ("Edit A Copy", True),
            ("Edit Original File", False),
            cancel=True
        )

    def _open_file(self, open_path=None, copy=False):
        if open_path is not None:
            fb = FileBlock.fromFile(open_path)
            if copy:
                fb = fb.copy()
        else:
            fb = None

        self.root_block = fb
        self.refresh_tree()
        self._initialize_views()

        if fb is None:
            return

        if fb._sourceFile is None:
            self._flag_edited(fb)


    def _build_tree(self):
        """Builds the tree view with the given file block."""

        legend = QLabel("""
            <h4>Legend:</h4>
                <table border="0" cellpadding="2">
                    <tr>
                        <td align="center">🏷️</td>
                        <td>Unfilled Template</td>
                    </tr>
                    <tr>
                        <td align="center">+</td>
                        <td>Contains Unfilled Templates</td>
                    </tr>
                    <tr>
                        <td align="center">*</td>
                        <td>Unsaved Changes</td>
                    </tr>
                </table>
        """)

        tree_widget = QWidget()
        tree_layout = QVBoxLayout(tree_widget)
        tree_layout.addWidget(legend)

        self.splitter.addWidget(tree_widget)

        # tree sidebar
        self.tree_view = QTreeView()
        self.tree_view.setHeaderHidden(True)

        self.tree_model = QStandardItemModel()
        self.tree_view.setModel(self.tree_model)

        # wiring
        self.tree_view.clicked.connect(self._on_tree_selection)
        tree_layout.addWidget(self.tree_view)

        self.tree_view.resizeColumnToContents(0)


    def refresh_tree(self, blk:Block=None):
        """Refreshes or builds tree nodes.
        If target_item is given, only that sub-tree branch is cleared and rebuilt"""
        print("Debug: refreshing tree...")
        if blk is None:
            blk = self.root_block
            if blk is None:
                return
            target_item = None
        else:
            target_item = self.tree_items.get(blk, None)

        if target_item is None:
            self._clear_views(self.tree_model.invisibleRootItem())
            self.tree_model.clear()

            label = "*" if self._get_flag(blk, "edited") else ""
            label += _get_label(blk)
            target_item = QStandardItem(label)

            self.tree_model.appendRow(target_item)

        else:
            self._clear_views(target_item)
            target_item.removeRows(0, target_item.rowCount())

        self.tree_items[blk] = target_item
        self._register_view(target_item, blk)
        self._populate_tree_nodes(target_item, blk)

        if blk is self.root_block and blk is not None:
            self.go_to_block(blk)


    def _populate_tree_nodes(self, parent_item:QStandardItem, blk:Block):
        """Recursively adds child nodes to a QStandardItem."""

        if not isinstance(blk, Block):
            return

        if not hasattr(blk, "__GUI_FLAGS__"):
            blk.__GUI_FLAGS__ = {}


        if isinstance(blk, ListBlock):
            for i, blk_i in enumerate(blk._objs):
                label = _get_label(blk_i, i)

                child_item = QStandardItem(label)
                self._add_tree_item(child_item, parent_item, blk_i)

            for _blk in blk._staged_templates.values():
                label = _get_template_label(_blk)

                child_item = QStandardItem(label)
                self._add_tree_item(child_item, parent_item, _blk)

        elif isinstance(blk, SingleBlock):

            # get dict of property name -> live object
            prop_dict = blk.get_prop_dict()
            for prop, obj in prop_dict.items():
                # only Block instances get added to tree. Primitives are edited
                # in the SingleBlock's own widget
                if isinstance(obj, Block):
                    label = prop
                    if obj.has_staged():
                        label += "+"
                    child_item = QStandardItem(label)
                    self._add_tree_item(child_item, parent_item, obj)

            for prop, obj in blk._staged_templates.items():
                label = "🏷️" + prop

                if obj.has_staged():
                    label += "+"

                child_item = QStandardItem(label)
                self._add_tree_item(child_item, parent_item, obj)

        self._set_flag(blk, "refresh", False)


    def _set_flag(self, blk:Block, flag:str, value:bool):
        if not hasattr(blk, "__GUI_FLAGS__"):
            blk.__GUI_FLAGS__ = {}
        blk.__GUI_FLAGS__[flag] = value
        item = self.tree_items.get(blk, None)
        if item is None:
            return

        if flag == "edited":
            txt = item.text()
            if value and "*" not in txt:
                item.setText(f"*{txt}")
            elif not value:
                txt = txt.replace("*", "")
                item.setText(txt)

    @classmethod
    def _get_flag(cls, blk:Block, flag:str):
        if blk is None or not hasattr(blk, "__GUI_FLAGS__"):
            return False
        return blk.__GUI_FLAGS__.get(flag, False)    

    def _flag_edited(self, blk):
        self._set_flag(blk, "edited", True)
        self._set_flag(blk, "refresh", True)

        if hasattr(blk, "_parent_block") and blk._parent_block is not None:
            self._flag_edited(blk._parent_block)


    def _add_tree_item(self, child_item:QStandardItem, parent_item:QStandardItem, blk:Block):
        """Adds a single child item with corresponding block to a parent."""
        parent_item.appendRow(child_item)

        if isinstance(blk, Rename):
            idx = child_item.index()
            self.tree_view.setRowHidden(idx.row(), idx.parent(), True)
            # child_item.setVisible(False) equivalent

        self._register_view(child_item, blk)
        self._populate_tree_nodes(child_item, blk)

        self.tree_items[blk] = child_item
        for flag, value in blk.__GUI_FLAGS__.items():
            self._set_flag(blk, flag, value)

    def _register_view(self, item:QStandardItem, blk:Block):

        label = item.text()

        view_data = {
            "builder": lambda lbl=label,b=blk, i=item: self._build_widget(lbl,b,i),
            "widget": None,
            "block": blk
        }

        item.setData(view_data, WIDGET_DATA_ROLE)

    def _clear_views(self, parent_item:QStandardItem):
        for row in range(parent_item.rowCount()):
            child = parent_item.child(row)

            if child:
                self._clear_views(child)

                widget_data = child.data(WIDGET_DATA_ROLE)
                if widget_data and widget_data.get("widget", None) is not None:
                    widget = widget_data.get("widget", None)
                    if widget:
                        self.content_stack.removeWidget(widget)
                        widget.deleteLater()



    def _create_menu_bar(self):
        """Dropdown menu ribbon."""
        from .menus import MENU_BUILDERS

        self.menus = {}
        menu_bar = self.menuBar()

        for name, builder in MENU_BUILDERS.items():
            self.menus[name] = builder(self, menu_bar)


    def _open_console(self):
        if hasattr(self, "__python_console__") and self.__python_console__ is not None:
            return self.__python_console__.show()

        from .console import PythonConsole
        local_vars = {
            "win": (self, "Application Window"),
            "file": (self.root_block, "Current Open File")
        }
        console = PythonConsole(parent=self, persistent=True, **local_vars)
        console.exec()

    def _open_docs_site(self):
        from ._utils import _find_docs_url, _get_version

        version = _get_version()
        url = _find_docs_url(version)

        if url.endswith("latest/") and not self._custom_popup(
                "Documentation for version not found",
                f"A documentation URL for version {version} could not be found.\n\n"
                "Redirecting to the latest version instead:\n"
                + url,
                cancel=True):
            return

        QDesktopServices.openUrl(QUrl(url))

    def _choose_theme(self, theme_id):
        app = QApplication.instance()
        app.setQuitOnLastWindowClosed(True)
        if not app:
            return

        cfg.APP.theme = theme_id
        cfg.APP.save()

        try:
            qdarktheme.setup_theme(theme_id)
        except AttributeError:
            app.setStyleSheet(qdarktheme.load_stylesheet(theme_id))


    def _open_dlg(self, copy=False):
        from ...blocks.files import EXT_DESC_MAP

        if not self._unsaved_dlg("opening a new file"):
            return

        all_ext = [f"*.{ext}" for ext in EXT_DESC_MAP]
        ext_list = [f'All FoS-style Files ({" ".join(all_ext)})']
        ext_list.extend([f"{desc} (*.{ext})" for ext, desc in EXT_DESC_MAP.items()])
        ext_list.append("All Files (*)")

        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Open FoS-style file",
            "",
            ";;".join(ext_list)
        )

        if file_path and file_path.endswith("fosx"):
            from ...blocks.files import open_fosx

            ext_dir = self._open_fosx_dlg()
            file_path = open_fosx(file_path, ext_dir)
            copy = True

        if file_path:
            self._open_file(open_path=file_path, copy=copy)

    def _open_fosx_dlg(self):
        response = self._custom_popup(
            "Opening a FoSX file",
            "You are about to open a FoSX file. FoSX is a packaged format and must be extracted before opening.\n\n"
            "Would you like to choose the extraction location, or open a copy from a temporary location?",
            ("Choose Location", True),
            ("Temporary Location", None),
            cancel=True
        )

        if response:
            return QFileDialog.getExistingDirectory(
                self, "Select Extraction Location for FoSX file..."
            )

        return None

    @classmethod
    def _custom_popup(cls, title, text, *btns:str|tuple[str, Any], default=0, cancel=True):
        msg_box = QMessageBox()
        msg_box.setWindowTitle(title)
        msg_box.setText(text)

        if len(btns) == 0:
            btns = [("OK", True)]

        results = {}

        if not (
            (default>=0 and default<len(btns)) or 
            (default is DLG_ESCAPE and cancel)
        ):
            default = 0

        for i, btn in enumerate(btns):
            if isinstance(btn, tuple):
                btn_txt = btn[0]
                result = btn[1]
            else:
                btn_txt = btn
                result = i

            btn = msg_box.addButton(btn_txt, QMessageBox.ActionRole)
            if i == default:
                msg_box.setDefaultButton(btn)
                default = btn
            results[btn] = result

        if cancel:
            btn = msg_box.addButton("Cancel", QMessageBox.ActionRole)
            results[btn] = DLG_ESCAPE
            if default is DLG_ESCAPE:
                msg_box.setDefaultButton(btn)
                default = btn

        msg_box.setWindowModality(Qt.ApplicationModal)
        msg_box.raise_()
        msg_box.activateWindow()
        msg_box.setWindowFlag(Qt.WindowStaysOnTopHint)

        msg_box.exec()

        clicked = msg_box.clickedButton() or default

        return results[clicked]

    def _get_text_inputs(self, title, prompt, *labels, **dropdowns):
        from ._utils import TextInputDialog

        dlg = TextInputDialog(title, prompt, *labels, **dropdowns, parent=self)
        if dlg.exec():
            return dlg.get_results()

        return None

    def _unsaved_dlg(self, pending_action="exiting"):
        if not self._get_flag(self.root_block, "edited"):
            return True

        options = [
            ("Save", self.save),
            ("Save As...", self.save_dlg),
            ("Discard", lambda: True)
        ]

        result = self._custom_popup(
            "Unsaved Changes",
            f"You have unsaved changes. What would you like to do before {pending_action}?",
            *options,
            default=1,
            cancel=True
        )

        if result is DLG_ESCAPE:
            return result

        return result()


    def _edit_copy(self):
        if (self.root_block is None or
            not self._unsaved_dlg("switching to a copy")):
            return

        self._open_file(open_path=self.root_block._sourceFile, copy=True)


    def _initialize_views(self):
        if self.root_block is not None:
            return self.go_to_block(self.root_block)

        self.no_file = TextContentWidget(
            "No File Selected",
            "Open a FoS file to view its contents.\n"
            "File > Open..."
        )

        empty_idx = self.content_stack.addWidget(self.no_file)
        self.content_stack.setCurrentIndex(empty_idx)

    def _on_tree_selection(self, idx):
        """Triggers when clicking within the tree layout."""

        item = self.tree_model.itemFromIndex(idx)
        if not item:
            return

        self.find_widget(item=item, go_to=True)

        self.tree_view.resizeColumnToContents(0)

        if self.tree_visible:
            tree_width = self.tree_view.sizeHint().width()
            splitter_width = self.splitter.sizeHint().width()

            self.splitter.setSizes([tree_width, splitter_width - tree_width])

    def find_widget(self, item=None, blk:Block=None, go_to=False):
        """Find and return the widget associated with a block or tree item.

        If both item and block are provided, item takes precedence.
        """
        if isinstance(item, Block):
            from warnings import warn
            warn("A Block was passed in the first position instead of as 'blk='.", stacklevel=2, category=DeprecationWarning)
            blk = item
            item = None

        if item is None:
            if blk is None:
                return None

            item = self.tree_items.get(blk, None)
            return self.find_widget(item=item, go_to=go_to)



        widget_data = item.data(WIDGET_DATA_ROLE)
        if widget_data is None:
            return None

        blk = widget_data["block"]
        if self._get_flag(blk, "refresh"):
            self._set_flag(blk, "refresh", False)
            self.refresh_tree(blk)

        widget = widget_data.get("widget", None)
        if widget is not None:
            if go_to:
                self.content_stack.setCurrentWidget(widget)
            return widget

        builder = widget_data["builder"]
        widget_finder = builder()
        widget = widget_finder(go_to=go_to)

        return widget

    def go_to_block(self, blk:Block):
        """Programmatically select a block in the tree."""

        item = self.tree_items.get(blk, None)

        if item is None:
            raise ValueError(f"Block {blk} not found in tree.")

        idx = self.tree_model.indexFromItem(item)
        if not idx.isValid():
            raise ValueError(f"Item {item} not found in tree model.")

        self.tree_view.setCurrentIndex(idx)
        self.tree_view.scrollTo(idx)
        self._on_tree_selection(idx)


    def _build_widget(self, label, blk:Block, item:QStandardItem):
        """Build a widget for the given block or navigate to tab in parent's widget.

        Default behavior:
            Build widget for block and return it.

        If block's parent is a ListBlock:
            Return a function that switches to parent's tab corresponding to block."""
        from .block_widgets._utils import _get_widget

        if hasattr(blk, "_parent_block") and blk._parent_block is not None:
            parent_blk = blk._parent_block
        elif hasattr(blk, "_staged_parent") and blk._staged_parent is not None:
            parent_blk = blk._staged_parent
        else:
            parent_blk = None

        if isinstance(parent_blk, ListBlock):
            parent_widget = self.find_widget(blk=parent_blk, go_to=False)

            def find_widget_tab(b=blk, pb=parent_blk, pw=parent_widget, s=self, go_to=False):

                blk_widget = pw.find_widget(b)
                if go_to:
                    s.go_to_block(pb)
                    parent_widget.go_to_tab(b)

                    blk_item = s.tree_items[b]
                    idx = s.tree_model.indexFromItem(blk_item)
                    s.tree_view.setCurrentIndex(idx)
                    s.tree_view.scrollTo(idx)
                return blk_widget

            return find_widget_tab

        widget = _get_widget(blk)(label, blk, self)
        self.content_stack.addWidget(widget)

        if parent_blk is not None:
            parent_btn = QPushButton("↑")
            parent_btn.clicked.connect(lambda *_: self.go_to_block(parent_blk))
            widget.header_row.addWidget(parent_btn)

        widget.header_row.addStretch()

        # widget only gets cached to item data if it's not in a ListBlock
        widget_data = item.data(WIDGET_DATA_ROLE)
        widget_data["widget"] = widget
        item.setData(widget_data, WIDGET_DATA_ROLE)

        def find_widget(s=self, w=widget, go_to=False):
            if go_to:
                s.content_stack.setCurrentWidget(w)
            return w

        return find_widget

    def hard_refresh(self, blk=None, func=lambda: None, to_editor=None, to_blk=True):

        def _editor_active(widget):
            return (hasattr(widget, "active") and
                    widget.active is not None and
                    widget.active.count() > 0)

        # allow only one open tab if refresh is returning to an editor
        # (usually on editors "apply" action)
        max_tabs = 0 if to_editor is None else 1

        if blk is None and to_blk:
            # try to find current block instead
            current_blk_widget = self.content_stack.currentWidget()
            blk = current_blk_widget.blk

            if (_editor_active(current_blk_widget) and
                to_editor is None):
                # return to current editor, but changes will still be lost,
                # so don't change max_tabs
                to_editor = current_blk_widget.active.currentWidget()
        else:
            current_blk_widget = self.find_widget(blk=blk, go_to=False)

        # Only prompt if there are editor tabs open
        if (_editor_active(current_blk_widget) and
            current_blk_widget.active.count() > max_tabs and
            not self._custom_popup(
                "Warning!",
                "This change requires a refresh of the entire window for this block. "
                "You will lose changes made in other editor tabs.\n\n"
                "Continue?",
                ("Apply Changes and Refresh", True),
                cancel=True
        )):
            return None

        out = func()

        # root = self.root_block
        # self._set_flag(root, "refresh", True)
        # self.go_to_block(root)

        self.refresh_tree()

        if to_blk:
            new_blk_widget = self.find_widget(blk=blk, go_to=True)

            if to_editor is not None:
                from .editors._base import BasePropEditor
                from .editors.comments import CommentEditorWidget
                if isinstance(to_editor, BasePropEditor):
                    new_blk_widget.activate_prop_editor(to_editor.prop_name)
                elif isinstance(to_editor, CommentEditorWidget):
                    new_blk_widget.activate_comment_editor(to_editor.editor.prop_name)
                elif isinstance(to_editor, str):
                    pass
                else:
                    new_blk_widget.activate_misc_editor(to_editor.editor_id)

        return out


    def _get_item_path(self, item:QStandardItem):
        path = []

        while item is not None:
            path.append(item.row())
            item = item.parent()

        return list(reversed(path))

    def _get_item_from_path(self, path:list[int]):
        if not path:
            return None

        item = self.tree_model.item(path[0])

        for row in path[1:]:
            if item is None:
                return None

            item = item.child(row)

        return item

    def save(self, *args,path:str | None=None):
        if not self.root_block:
            return

        if path is None and self.root_block._sourceFile is None:
            return self.save_dlg()

        if path is not None and path.endswith(".fosx"):
            copy = self.root_block.copy()
            copy.save(filepath=path)

        else:
            self.root_block.save(filepath=path)
            src = self.root_block._sourceFile
            # cache current tree selection
            current_idx = self.tree_view.currentIndex()
            cached_path = None
            if current_idx.isValid():
                current_item = self.tree_model.itemFromIndex(current_idx)
                if current_item is not None:
                    cached_path = self._get_item_path(current_item)

            self._open_file(src, copy=False)

            # restore tree selection
            if cached_path:
                new_item = self._get_item_from_path(cached_path)
                if new_item:
                    new_idx = self.tree_model.indexFromItem(new_item)
                    if new_idx.isValid():
                        self.tree_view.setCurrentIndex(new_idx)
                        self.tree_view.scrollTo(new_idx)
                        self._on_tree_selection(new_idx)

        return True

    def save_dlg(self, *args):
        from ...blocks.files import EXT_DESC_MAP
        all_ext = [f"*.{ext}" for ext in EXT_DESC_MAP]
        ext_list = [f"{desc} (*.{ext})" for ext, desc in EXT_DESC_MAP.items()]
        ext_list.append(f'All FoS-style Files ({" ".join(all_ext)})')
        ext_list.append("All Files (*)")

        path, _ = QFileDialog.getSaveFileName(
            self,
            "Save FoS-style file",
            "",
            ";;".join(ext_list)
        )

        if path:
            if path.endswith(".fosx")and not self._custom_popup(
                    "Packaging File",
                    "You are about to package this file as a FoSX archive. "
                    "FoSX-packaged files cannot be edited directly. "
                    "You will be returned to the non-packaged file after saving.",
                    cancel=True):
                return

            self.save(path=path)
            return True

        return False

content_stack instance-attribute

content_stack = QStackedWidget()

splitter instance-attribute

splitter = main_splitter

tree_items instance-attribute

tree_items = {}

tree_visible instance-attribute

tree_visible = True

__init__

__init__(open_path=None, copy=None)

Initialize the FoSpy viewer app window.

Parameters:

Name Type Description Default
open_path pathlib.Path | str

Open the file at this path on startup. Defaults to None.

None
copy bool
  • True: Open the editor with an unsaved copy of the file.
  • False: Open the editor with the original file.
  • None: GUI prompt.
None
Source code in FoSpy/ui/app/window.py
def __init__(self, open_path:pathlib.Path | str | None=None, copy:bool | None=None):
    """Initialize the FoSpy viewer app window.

    Args:
        open_path (pathlib.Path|str, optional):
            Open the file at this path on startup. Defaults to None.
        copy (bool, optional):
            - True: Open the editor with an unsaved copy of the file.
            - False: Open the editor with the original file.
            - None: GUI prompt.
    """
    super().__init__()

    self.tree_visible = True

    self.setWindowTitle(WINDOW_TITLE)
    self.resize(*WINDOW_DIMENSIONS)

    # map of block -> tree item
    self.tree_items = {}

    # build dropdown ribbon
    self._create_menu_bar()

    main_splitter = QSplitter(Qt.Orientation.Horizontal)
    self.setCentralWidget(main_splitter)
    self.splitter = main_splitter


    self._build_tree()

    # content area
    self.content_stack = QStackedWidget()
    main_splitter.addWidget(self.content_stack)

    if copy is None:
        copy = self._startup_copy_dlg(open_path)
        if copy is DLG_ESCAPE:
            open_path = None
            copy = False

    self._open_file(open_path=open_path, copy=copy)

    sys.excepthook = self.handle_exception

_add_tree_item

_add_tree_item(child_item, parent_item, blk)

Adds a single child item with corresponding block to a parent.

Source code in FoSpy/ui/app/window.py
def _add_tree_item(self, child_item:QStandardItem, parent_item:QStandardItem, blk:Block):
    """Adds a single child item with corresponding block to a parent."""
    parent_item.appendRow(child_item)

    if isinstance(blk, Rename):
        idx = child_item.index()
        self.tree_view.setRowHidden(idx.row(), idx.parent(), True)
        # child_item.setVisible(False) equivalent

    self._register_view(child_item, blk)
    self._populate_tree_nodes(child_item, blk)

    self.tree_items[blk] = child_item
    for flag, value in blk.__GUI_FLAGS__.items():
        self._set_flag(blk, flag, value)

_build_tree

_build_tree()

Builds the tree view with the given file block.

Source code in FoSpy/ui/app/window.py
def _build_tree(self):
    """Builds the tree view with the given file block."""

    legend = QLabel("""
        <h4>Legend:</h4>
            <table border="0" cellpadding="2">
                <tr>
                    <td align="center">🏷️</td>
                    <td>Unfilled Template</td>
                </tr>
                <tr>
                    <td align="center">+</td>
                    <td>Contains Unfilled Templates</td>
                </tr>
                <tr>
                    <td align="center">*</td>
                    <td>Unsaved Changes</td>
                </tr>
            </table>
    """)

    tree_widget = QWidget()
    tree_layout = QVBoxLayout(tree_widget)
    tree_layout.addWidget(legend)

    self.splitter.addWidget(tree_widget)

    # tree sidebar
    self.tree_view = QTreeView()
    self.tree_view.setHeaderHidden(True)

    self.tree_model = QStandardItemModel()
    self.tree_view.setModel(self.tree_model)

    # wiring
    self.tree_view.clicked.connect(self._on_tree_selection)
    tree_layout.addWidget(self.tree_view)

    self.tree_view.resizeColumnToContents(0)

_build_widget

_build_widget(label, blk, item)

Build a widget for the given block or navigate to tab in parent's widget.

Default behavior

Build widget for block and return it.

If block's parent is a ListBlock: Return a function that switches to parent's tab corresponding to block.

Source code in FoSpy/ui/app/window.py
def _build_widget(self, label, blk:Block, item:QStandardItem):
    """Build a widget for the given block or navigate to tab in parent's widget.

    Default behavior:
        Build widget for block and return it.

    If block's parent is a ListBlock:
        Return a function that switches to parent's tab corresponding to block."""
    from .block_widgets._utils import _get_widget

    if hasattr(blk, "_parent_block") and blk._parent_block is not None:
        parent_blk = blk._parent_block
    elif hasattr(blk, "_staged_parent") and blk._staged_parent is not None:
        parent_blk = blk._staged_parent
    else:
        parent_blk = None

    if isinstance(parent_blk, ListBlock):
        parent_widget = self.find_widget(blk=parent_blk, go_to=False)

        def find_widget_tab(b=blk, pb=parent_blk, pw=parent_widget, s=self, go_to=False):

            blk_widget = pw.find_widget(b)
            if go_to:
                s.go_to_block(pb)
                parent_widget.go_to_tab(b)

                blk_item = s.tree_items[b]
                idx = s.tree_model.indexFromItem(blk_item)
                s.tree_view.setCurrentIndex(idx)
                s.tree_view.scrollTo(idx)
            return blk_widget

        return find_widget_tab

    widget = _get_widget(blk)(label, blk, self)
    self.content_stack.addWidget(widget)

    if parent_blk is not None:
        parent_btn = QPushButton("↑")
        parent_btn.clicked.connect(lambda *_: self.go_to_block(parent_blk))
        widget.header_row.addWidget(parent_btn)

    widget.header_row.addStretch()

    # widget only gets cached to item data if it's not in a ListBlock
    widget_data = item.data(WIDGET_DATA_ROLE)
    widget_data["widget"] = widget
    item.setData(widget_data, WIDGET_DATA_ROLE)

    def find_widget(s=self, w=widget, go_to=False):
        if go_to:
            s.content_stack.setCurrentWidget(w)
        return w

    return find_widget

_choose_theme

_choose_theme(theme_id)
Source code in FoSpy/ui/app/window.py
def _choose_theme(self, theme_id):
    app = QApplication.instance()
    app.setQuitOnLastWindowClosed(True)
    if not app:
        return

    cfg.APP.theme = theme_id
    cfg.APP.save()

    try:
        qdarktheme.setup_theme(theme_id)
    except AttributeError:
        app.setStyleSheet(qdarktheme.load_stylesheet(theme_id))

_clear_views

_clear_views(parent_item)
Source code in FoSpy/ui/app/window.py
def _clear_views(self, parent_item:QStandardItem):
    for row in range(parent_item.rowCount()):
        child = parent_item.child(row)

        if child:
            self._clear_views(child)

            widget_data = child.data(WIDGET_DATA_ROLE)
            if widget_data and widget_data.get("widget", None) is not None:
                widget = widget_data.get("widget", None)
                if widget:
                    self.content_stack.removeWidget(widget)
                    widget.deleteLater()

_create_menu_bar

_create_menu_bar()

Dropdown menu ribbon.

Source code in FoSpy/ui/app/window.py
def _create_menu_bar(self):
    """Dropdown menu ribbon."""
    from .menus import MENU_BUILDERS

    self.menus = {}
    menu_bar = self.menuBar()

    for name, builder in MENU_BUILDERS.items():
        self.menus[name] = builder(self, menu_bar)

_custom_popup classmethod

_custom_popup(title, text, *btns, default=0, cancel=True)
Source code in FoSpy/ui/app/window.py
@classmethod
def _custom_popup(cls, title, text, *btns:str|tuple[str, Any], default=0, cancel=True):
    msg_box = QMessageBox()
    msg_box.setWindowTitle(title)
    msg_box.setText(text)

    if len(btns) == 0:
        btns = [("OK", True)]

    results = {}

    if not (
        (default>=0 and default<len(btns)) or 
        (default is DLG_ESCAPE and cancel)
    ):
        default = 0

    for i, btn in enumerate(btns):
        if isinstance(btn, tuple):
            btn_txt = btn[0]
            result = btn[1]
        else:
            btn_txt = btn
            result = i

        btn = msg_box.addButton(btn_txt, QMessageBox.ActionRole)
        if i == default:
            msg_box.setDefaultButton(btn)
            default = btn
        results[btn] = result

    if cancel:
        btn = msg_box.addButton("Cancel", QMessageBox.ActionRole)
        results[btn] = DLG_ESCAPE
        if default is DLG_ESCAPE:
            msg_box.setDefaultButton(btn)
            default = btn

    msg_box.setWindowModality(Qt.ApplicationModal)
    msg_box.raise_()
    msg_box.activateWindow()
    msg_box.setWindowFlag(Qt.WindowStaysOnTopHint)

    msg_box.exec()

    clicked = msg_box.clickedButton() or default

    return results[clicked]

_edit_copy

_edit_copy()
Source code in FoSpy/ui/app/window.py
def _edit_copy(self):
    if (self.root_block is None or
        not self._unsaved_dlg("switching to a copy")):
        return

    self._open_file(open_path=self.root_block._sourceFile, copy=True)

_flag_edited

_flag_edited(blk)
Source code in FoSpy/ui/app/window.py
def _flag_edited(self, blk):
    self._set_flag(blk, "edited", True)
    self._set_flag(blk, "refresh", True)

    if hasattr(blk, "_parent_block") and blk._parent_block is not None:
        self._flag_edited(blk._parent_block)

_get_flag classmethod

_get_flag(blk, flag)
Source code in FoSpy/ui/app/window.py
@classmethod
def _get_flag(cls, blk:Block, flag:str):
    if blk is None or not hasattr(blk, "__GUI_FLAGS__"):
        return False
    return blk.__GUI_FLAGS__.get(flag, False)    

_get_item_from_path

_get_item_from_path(path)
Source code in FoSpy/ui/app/window.py
def _get_item_from_path(self, path:list[int]):
    if not path:
        return None

    item = self.tree_model.item(path[0])

    for row in path[1:]:
        if item is None:
            return None

        item = item.child(row)

    return item

_get_item_path

_get_item_path(item)
Source code in FoSpy/ui/app/window.py
def _get_item_path(self, item:QStandardItem):
    path = []

    while item is not None:
        path.append(item.row())
        item = item.parent()

    return list(reversed(path))

_get_text_inputs

_get_text_inputs(title, prompt, *labels, **dropdowns)
Source code in FoSpy/ui/app/window.py
def _get_text_inputs(self, title, prompt, *labels, **dropdowns):
    from ._utils import TextInputDialog

    dlg = TextInputDialog(title, prompt, *labels, **dropdowns, parent=self)
    if dlg.exec():
        return dlg.get_results()

    return None

_initialize_views

_initialize_views()
Source code in FoSpy/ui/app/window.py
def _initialize_views(self):
    if self.root_block is not None:
        return self.go_to_block(self.root_block)

    self.no_file = TextContentWidget(
        "No File Selected",
        "Open a FoS file to view its contents.\n"
        "File > Open..."
    )

    empty_idx = self.content_stack.addWidget(self.no_file)
    self.content_stack.setCurrentIndex(empty_idx)

_on_tree_selection

_on_tree_selection(idx)

Triggers when clicking within the tree layout.

Source code in FoSpy/ui/app/window.py
def _on_tree_selection(self, idx):
    """Triggers when clicking within the tree layout."""

    item = self.tree_model.itemFromIndex(idx)
    if not item:
        return

    self.find_widget(item=item, go_to=True)

    self.tree_view.resizeColumnToContents(0)

    if self.tree_visible:
        tree_width = self.tree_view.sizeHint().width()
        splitter_width = self.splitter.sizeHint().width()

        self.splitter.setSizes([tree_width, splitter_width - tree_width])

_open_console

_open_console()
Source code in FoSpy/ui/app/window.py
def _open_console(self):
    if hasattr(self, "__python_console__") and self.__python_console__ is not None:
        return self.__python_console__.show()

    from .console import PythonConsole
    local_vars = {
        "win": (self, "Application Window"),
        "file": (self.root_block, "Current Open File")
    }
    console = PythonConsole(parent=self, persistent=True, **local_vars)
    console.exec()

_open_dlg

_open_dlg(copy=False)
Source code in FoSpy/ui/app/window.py
def _open_dlg(self, copy=False):
    from ...blocks.files import EXT_DESC_MAP

    if not self._unsaved_dlg("opening a new file"):
        return

    all_ext = [f"*.{ext}" for ext in EXT_DESC_MAP]
    ext_list = [f'All FoS-style Files ({" ".join(all_ext)})']
    ext_list.extend([f"{desc} (*.{ext})" for ext, desc in EXT_DESC_MAP.items()])
    ext_list.append("All Files (*)")

    file_path, _ = QFileDialog.getOpenFileName(
        self,
        "Open FoS-style file",
        "",
        ";;".join(ext_list)
    )

    if file_path and file_path.endswith("fosx"):
        from ...blocks.files import open_fosx

        ext_dir = self._open_fosx_dlg()
        file_path = open_fosx(file_path, ext_dir)
        copy = True

    if file_path:
        self._open_file(open_path=file_path, copy=copy)

_open_docs_site

_open_docs_site()
Source code in FoSpy/ui/app/window.py
def _open_docs_site(self):
    from ._utils import _find_docs_url, _get_version

    version = _get_version()
    url = _find_docs_url(version)

    if url.endswith("latest/") and not self._custom_popup(
            "Documentation for version not found",
            f"A documentation URL for version {version} could not be found.\n\n"
            "Redirecting to the latest version instead:\n"
            + url,
            cancel=True):
        return

    QDesktopServices.openUrl(QUrl(url))

_open_file

_open_file(open_path=None, copy=False)
Source code in FoSpy/ui/app/window.py
def _open_file(self, open_path=None, copy=False):
    if open_path is not None:
        fb = FileBlock.fromFile(open_path)
        if copy:
            fb = fb.copy()
    else:
        fb = None

    self.root_block = fb
    self.refresh_tree()
    self._initialize_views()

    if fb is None:
        return

    if fb._sourceFile is None:
        self._flag_edited(fb)

_open_fosx_dlg

_open_fosx_dlg()
Source code in FoSpy/ui/app/window.py
def _open_fosx_dlg(self):
    response = self._custom_popup(
        "Opening a FoSX file",
        "You are about to open a FoSX file. FoSX is a packaged format and must be extracted before opening.\n\n"
        "Would you like to choose the extraction location, or open a copy from a temporary location?",
        ("Choose Location", True),
        ("Temporary Location", None),
        cancel=True
    )

    if response:
        return QFileDialog.getExistingDirectory(
            self, "Select Extraction Location for FoSX file..."
        )

    return None

_populate_tree_nodes

_populate_tree_nodes(parent_item, blk)

Recursively adds child nodes to a QStandardItem.

Source code in FoSpy/ui/app/window.py
def _populate_tree_nodes(self, parent_item:QStandardItem, blk:Block):
    """Recursively adds child nodes to a QStandardItem."""

    if not isinstance(blk, Block):
        return

    if not hasattr(blk, "__GUI_FLAGS__"):
        blk.__GUI_FLAGS__ = {}


    if isinstance(blk, ListBlock):
        for i, blk_i in enumerate(blk._objs):
            label = _get_label(blk_i, i)

            child_item = QStandardItem(label)
            self._add_tree_item(child_item, parent_item, blk_i)

        for _blk in blk._staged_templates.values():
            label = _get_template_label(_blk)

            child_item = QStandardItem(label)
            self._add_tree_item(child_item, parent_item, _blk)

    elif isinstance(blk, SingleBlock):

        # get dict of property name -> live object
        prop_dict = blk.get_prop_dict()
        for prop, obj in prop_dict.items():
            # only Block instances get added to tree. Primitives are edited
            # in the SingleBlock's own widget
            if isinstance(obj, Block):
                label = prop
                if obj.has_staged():
                    label += "+"
                child_item = QStandardItem(label)
                self._add_tree_item(child_item, parent_item, obj)

        for prop, obj in blk._staged_templates.items():
            label = "🏷️" + prop

            if obj.has_staged():
                label += "+"

            child_item = QStandardItem(label)
            self._add_tree_item(child_item, parent_item, obj)

    self._set_flag(blk, "refresh", False)

_register_view

_register_view(item, blk)
Source code in FoSpy/ui/app/window.py
def _register_view(self, item:QStandardItem, blk:Block):

    label = item.text()

    view_data = {
        "builder": lambda lbl=label,b=blk, i=item: self._build_widget(lbl,b,i),
        "widget": None,
        "block": blk
    }

    item.setData(view_data, WIDGET_DATA_ROLE)

_set_flag

_set_flag(blk, flag, value)
Source code in FoSpy/ui/app/window.py
def _set_flag(self, blk:Block, flag:str, value:bool):
    if not hasattr(blk, "__GUI_FLAGS__"):
        blk.__GUI_FLAGS__ = {}
    blk.__GUI_FLAGS__[flag] = value
    item = self.tree_items.get(blk, None)
    if item is None:
        return

    if flag == "edited":
        txt = item.text()
        if value and "*" not in txt:
            item.setText(f"*{txt}")
        elif not value:
            txt = txt.replace("*", "")
            item.setText(txt)

_startup_copy_dlg

_startup_copy_dlg(open_path)
Source code in FoSpy/ui/app/window.py
def _startup_copy_dlg(self, open_path):
    if open_path is None:
        return False

    path_str = os.path.abspath(open_path)

    return self._custom_popup(
        "FoSpy GUI -File Opened on Startup",
        "You are opening the file below on startup. "
        "Do you want to edit the file directly or make a copy?\n\n"
        + path_str,
        ("Edit A Copy", True),
        ("Edit Original File", False),
        cancel=True
    )

_unsaved_dlg

_unsaved_dlg(pending_action='exiting')
Source code in FoSpy/ui/app/window.py
def _unsaved_dlg(self, pending_action="exiting"):
    if not self._get_flag(self.root_block, "edited"):
        return True

    options = [
        ("Save", self.save),
        ("Save As...", self.save_dlg),
        ("Discard", lambda: True)
    ]

    result = self._custom_popup(
        "Unsaved Changes",
        f"You have unsaved changes. What would you like to do before {pending_action}?",
        *options,
        default=1,
        cancel=True
    )

    if result is DLG_ESCAPE:
        return result

    return result()

closeEvent

closeEvent(event)
Source code in FoSpy/ui/app/window.py
def closeEvent(self, event):
    if not self._unsaved_dlg("exiting"):
        return event.ignore()

    return super().closeEvent(event)

find_widget

find_widget(item=None, blk=None, go_to=False)

Find and return the widget associated with a block or tree item.

If both item and block are provided, item takes precedence.

Source code in FoSpy/ui/app/window.py
def find_widget(self, item=None, blk:Block=None, go_to=False):
    """Find and return the widget associated with a block or tree item.

    If both item and block are provided, item takes precedence.
    """
    if isinstance(item, Block):
        from warnings import warn
        warn("A Block was passed in the first position instead of as 'blk='.", stacklevel=2, category=DeprecationWarning)
        blk = item
        item = None

    if item is None:
        if blk is None:
            return None

        item = self.tree_items.get(blk, None)
        return self.find_widget(item=item, go_to=go_to)



    widget_data = item.data(WIDGET_DATA_ROLE)
    if widget_data is None:
        return None

    blk = widget_data["block"]
    if self._get_flag(blk, "refresh"):
        self._set_flag(blk, "refresh", False)
        self.refresh_tree(blk)

    widget = widget_data.get("widget", None)
    if widget is not None:
        if go_to:
            self.content_stack.setCurrentWidget(widget)
        return widget

    builder = widget_data["builder"]
    widget_finder = builder()
    widget = widget_finder(go_to=go_to)

    return widget

go_to_block

go_to_block(blk)

Programmatically select a block in the tree.

Source code in FoSpy/ui/app/window.py
def go_to_block(self, blk:Block):
    """Programmatically select a block in the tree."""

    item = self.tree_items.get(blk, None)

    if item is None:
        raise ValueError(f"Block {blk} not found in tree.")

    idx = self.tree_model.indexFromItem(item)
    if not idx.isValid():
        raise ValueError(f"Item {item} not found in tree model.")

    self.tree_view.setCurrentIndex(idx)
    self.tree_view.scrollTo(idx)
    self._on_tree_selection(idx)

handle_exception

handle_exception(exctype, value, tb)
Source code in FoSpy/ui/app/window.py
def handle_exception(self, exctype, value, tb):
    options = [
        ("Continue", False),
        ("View Full Error Details", True)
    ]

    if cfg.get("APP.debug"):
        exc = exctype(value)
        exc.__traceback__ = tb
        options.append(("Raise Exception", exc))


    resp = self._custom_popup(
        "Error!",
        "An error has occurred:\n\n"
        f"{exctype.__name__}: {value}",
        *options,
        cancel=False
    )

    if isinstance(resp, Exception):
        raise resp

    if resp:
        import subprocess
        import sys
        import tempfile

        with tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w", encoding="utf-8") as tmp:
            tmp.write("".join(traceback.format_exception(exctype, value, tb)))
            tmp.close()

            if sys.platform.startswith("win"):
                os.startfile(tmp.name)
            elif sys.platform.startswith("darwin"):
                subprocess.call(["open", tmp.name])
            else:
                QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))

hard_refresh

hard_refresh(
    blk=None, func=lambda: None, to_editor=None, to_blk=True
)
Source code in FoSpy/ui/app/window.py
def hard_refresh(self, blk=None, func=lambda: None, to_editor=None, to_blk=True):

    def _editor_active(widget):
        return (hasattr(widget, "active") and
                widget.active is not None and
                widget.active.count() > 0)

    # allow only one open tab if refresh is returning to an editor
    # (usually on editors "apply" action)
    max_tabs = 0 if to_editor is None else 1

    if blk is None and to_blk:
        # try to find current block instead
        current_blk_widget = self.content_stack.currentWidget()
        blk = current_blk_widget.blk

        if (_editor_active(current_blk_widget) and
            to_editor is None):
            # return to current editor, but changes will still be lost,
            # so don't change max_tabs
            to_editor = current_blk_widget.active.currentWidget()
    else:
        current_blk_widget = self.find_widget(blk=blk, go_to=False)

    # Only prompt if there are editor tabs open
    if (_editor_active(current_blk_widget) and
        current_blk_widget.active.count() > max_tabs and
        not self._custom_popup(
            "Warning!",
            "This change requires a refresh of the entire window for this block. "
            "You will lose changes made in other editor tabs.\n\n"
            "Continue?",
            ("Apply Changes and Refresh", True),
            cancel=True
    )):
        return None

    out = func()

    # root = self.root_block
    # self._set_flag(root, "refresh", True)
    # self.go_to_block(root)

    self.refresh_tree()

    if to_blk:
        new_blk_widget = self.find_widget(blk=blk, go_to=True)

        if to_editor is not None:
            from .editors._base import BasePropEditor
            from .editors.comments import CommentEditorWidget
            if isinstance(to_editor, BasePropEditor):
                new_blk_widget.activate_prop_editor(to_editor.prop_name)
            elif isinstance(to_editor, CommentEditorWidget):
                new_blk_widget.activate_comment_editor(to_editor.editor.prop_name)
            elif isinstance(to_editor, str):
                pass
            else:
                new_blk_widget.activate_misc_editor(to_editor.editor_id)

    return out

refresh_tree

refresh_tree(blk=None)

Refreshes or builds tree nodes. If target_item is given, only that sub-tree branch is cleared and rebuilt

Source code in FoSpy/ui/app/window.py
def refresh_tree(self, blk:Block=None):
    """Refreshes or builds tree nodes.
    If target_item is given, only that sub-tree branch is cleared and rebuilt"""
    print("Debug: refreshing tree...")
    if blk is None:
        blk = self.root_block
        if blk is None:
            return
        target_item = None
    else:
        target_item = self.tree_items.get(blk, None)

    if target_item is None:
        self._clear_views(self.tree_model.invisibleRootItem())
        self.tree_model.clear()

        label = "*" if self._get_flag(blk, "edited") else ""
        label += _get_label(blk)
        target_item = QStandardItem(label)

        self.tree_model.appendRow(target_item)

    else:
        self._clear_views(target_item)
        target_item.removeRows(0, target_item.rowCount())

    self.tree_items[blk] = target_item
    self._register_view(target_item, blk)
    self._populate_tree_nodes(target_item, blk)

    if blk is self.root_block and blk is not None:
        self.go_to_block(blk)

save

save(*args, path=None)
Source code in FoSpy/ui/app/window.py
def save(self, *args,path:str | None=None):
    if not self.root_block:
        return

    if path is None and self.root_block._sourceFile is None:
        return self.save_dlg()

    if path is not None and path.endswith(".fosx"):
        copy = self.root_block.copy()
        copy.save(filepath=path)

    else:
        self.root_block.save(filepath=path)
        src = self.root_block._sourceFile
        # cache current tree selection
        current_idx = self.tree_view.currentIndex()
        cached_path = None
        if current_idx.isValid():
            current_item = self.tree_model.itemFromIndex(current_idx)
            if current_item is not None:
                cached_path = self._get_item_path(current_item)

        self._open_file(src, copy=False)

        # restore tree selection
        if cached_path:
            new_item = self._get_item_from_path(cached_path)
            if new_item:
                new_idx = self.tree_model.indexFromItem(new_item)
                if new_idx.isValid():
                    self.tree_view.setCurrentIndex(new_idx)
                    self.tree_view.scrollTo(new_idx)
                    self._on_tree_selection(new_idx)

    return True

save_dlg

save_dlg(*args)
Source code in FoSpy/ui/app/window.py
def save_dlg(self, *args):
    from ...blocks.files import EXT_DESC_MAP
    all_ext = [f"*.{ext}" for ext in EXT_DESC_MAP]
    ext_list = [f"{desc} (*.{ext})" for ext, desc in EXT_DESC_MAP.items()]
    ext_list.append(f'All FoS-style Files ({" ".join(all_ext)})')
    ext_list.append("All Files (*)")

    path, _ = QFileDialog.getSaveFileName(
        self,
        "Save FoS-style file",
        "",
        ";;".join(ext_list)
    )

    if path:
        if path.endswith(".fosx")and not self._custom_popup(
                "Packaging File",
                "You are about to package this file as a FoSX archive. "
                "FoSX-packaged files cannot be edited directly. "
                "You will be returned to the non-packaged file after saving.",
                cancel=True):
            return

        self.save(path=path)
        return True

    return False

Sentinel

Source code in FoSpy/ui/app/window.py
class Sentinel:
    def __init__(self, hint, bool_val=True):
        self.hint = hint
        self.bool_val = bool_val
    def __bool__(self):
        return self.bool_val
    def __repr__(self):
        return f"<{self.hint}>"

bool_val instance-attribute

bool_val = bool_val

hint instance-attribute

hint = hint

__bool__

__bool__()
Source code in FoSpy/ui/app/window.py
def __bool__(self):
    return self.bool_val

__init__

__init__(hint, bool_val=True)
Source code in FoSpy/ui/app/window.py
def __init__(self, hint, bool_val=True):
    self.hint = hint
    self.bool_val = bool_val

__repr__

__repr__()
Source code in FoSpy/ui/app/window.py
def __repr__(self):
    return f"<{self.hint}>"

SingleBlockWidget

Bases: QWidget

Source code in FoSpy/ui/app/block_widgets/_base.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
class SingleBlockWidget(QWidget):
    prop_map = None
    def __init__(self, label:str,blk:SingleBlock, window:MainWindow):
        self.win = window
        self.blk = blk
        parent = window.splitter
        self.editor_map = {"props": {}, "comments": {}, "misc": {}}
        self.footnote_iter = _footnote_iter()
        self.missing_prop_rows = {}
        self.line_edits = {}

        super().__init__(parent)


        self.header_row, base_layout = _add_header(self, label, "Properties")
        self.base_layout = base_layout

        custom_btn_layout = QHBoxLayout()
        base_layout.addLayout(custom_btn_layout)

        custom_txt_btn = QPushButton("Add Custom Property")
        custom_txt_btn.clicked.connect(lambda *_: self.add_custom_prop())
        custom_btn_layout.addWidget(custom_txt_btn)

        custom_blk_btn = QPushButton("Add Custom Block")
        custom_blk_btn.clicked.connect(lambda *_: self.add_custom_block())
        custom_btn_layout.addWidget(custom_blk_btn)
        custom_btn_layout.addStretch()

        if hasattr(blk, "rename"):
            rename_btn = QPushButton("Rename Properties")
            rename_btn.clicked.connect(
                lambda *_, b=blk.rename: self.win.go_to_block(b)
            )
            self.header_row.addWidget(rename_btn)

        main_layout = QHBoxLayout()
        main_layout.setContentsMargins(0, 0, 0, 0)

        base_layout.addLayout(main_layout)

        self.footnotes = QVBoxLayout()
        self.footnotes.setContentsMargins(0, 0, 0, 0)
        base_layout.addLayout(self.footnotes)

        sidebar = QVBoxLayout()
        self.sidebar = sidebar
        sidebar.setContentsMargins(*SIDEBAR_MARGINS)
        main_layout.addLayout(sidebar, stretch=0)

        editor = QStackedWidget()
        self.editor = editor
        main_layout.addWidget(editor, stretch=1)

        self.inactive = QLabel(
            "Select a property or comment editor to inspect it in more detail.\n\n"
            "Greyed-out properties can only be edited in the inspector."
            )
        self.inactive.setWordWrap(True)
        editor.addWidget(self.inactive)

        self.active = QTabWidget()
        editor.addWidget(self.active)

        self.deactivate_editor()

        sidebar_w = SIDEBAR_WIDTH

        scroll_w = sidebar_w - SIDEBAR_MARGINS[0] - SIDEBAR_MARGINS[2]

        scroll = QScrollArea(self)
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QScrollArea.Shape.NoFrame)
        scroll.setMinimumWidth(scroll_w)
        scroll.setContentsMargins(0,0,0,0)

        scroll_content = QWidget()
        scroll_content.setMinimumWidth(scroll_w)
        self.scroll_content = scroll_content

        scroll.setWidget(scroll_content)
        sidebar.addWidget(scroll)

        self.prop_labels = {}

        self._refresh_properties()

    @staticmethod
    def hard_refresh(func):
        def decorated(self, *args, **kwargs):
            def pending(f=func, a=args, k=kwargs):
                f(self, *a, **k)

            self.win.hard_refresh(self.blk, func=pending)
        return decorated


    def add_custom_prop(self):
        validators = self.blk.get_validators()

        prop_name = None
        while prop_name is None:
            prop_name = self.win._get_text_inputs("New Custom Property",
                "Enter the name of the property to add:\n"
                "(Letters and underscores only)",
                "Property Name"
            ).replace(" ", "_").replace("-","_")

            if prop_name is None:
                return

            if prop_name in validators or hasattr(self.blk, prop_name):
                self.win._custom_popup(
                    "Can't add custom property",
                    "That property is already expected in this block.",
                    cancel=False
                )
                prop_name = None

        def pending_refresh(s, p=prop_name):
            setattr(s.blk, p, "")

        self.hard_refresh(pending_refresh)(self)

    def add_custom_block(self):
        from ....blocks import SingleBlock, ListBlock

        validators = self.blk.get_validators()

        aliases = self.blk._aliases

        alias_opts = {
            aliases[k].__name__: k for k in sorted(aliases.keys())
        }

        prop_name = None
        while prop_name is None:
            results = self.win._get_text_inputs("New Custom Property",
                "Enter the name of the property to add:\n"
                "(Letters and underscores only)",
                "Property Name",
                **{"Block Type": alias_opts}
            )

            if results is None:
                return

            prop_name = results["Property Name"].replace(" ", "_").replace("-","_")
            alias = results["Block Type"]

            if prop_name in validators or hasattr(self.blk, prop_name):
                self.win._custom_popup(
                    "Can't add custom property",
                    "That property is already expected in this block.",
                    cancel=False
                )
                prop_name = None

        prop_alias = prop_name + "$" + alias
        def pending_refresh(s, p=prop_alias):
            s.blk.stage_template(p)

        self.hard_refresh(pending_refresh)(self)

    def _add_footnote(self, txt):
        i = next(self.footnote_iter)

        footnote = QLabel(f"<sup>{i}</sup> {txt}")
        self.footnotes.addWidget(footnote)

        return i

    def _get_tabs(self):
        return [
            self.active.widget(i) for
            i in range(self.active.count())
        ]

    def deactivate_editor(self, editor=None):
        tabs = self._get_tabs()

        if editor in tabs:
            self.active.removeTab(self.active.indexOf(editor))

        if self.active.count() == 0 or editor is None:
            self.editor.setCurrentWidget(self.inactive)

        if hasattr(editor, "btn"):
            editor.btn.setText(editor.btn.text().replace("*",""))

    def activate_editor(self, editor, label="MISSING"):
        tabs = [
            self.active.widget(i) for
            i in range(self.active.count())
        ]

        if hasattr(editor, "btn"):
            txt = editor.btn.text()
            if "*" not in txt:
                editor.btn.setText(txt+"*")

        if editor not in tabs:
            self.active.addTab(editor, label)

        self.active.setCurrentIndex(self.active.indexOf(editor))
        self.editor.setCurrentWidget(self.active)

    def _register_misc_editor(self, editor, label="Misc Editor", editor_id:Sentinel=None):
        if editor_id is None:
            # instantiate unique ID object
            from ..window import Sentinel
            editor_id = Sentinel("misc editor id")

        self.editor_map["misc"][editor_id] = (editor, label)

        return editor_id

    def activate_misc_editor(self, editor_id:Sentinel):
        editor, label = self.editor_map["misc"][editor_id]
        self.activate_editor(editor, label)

    def activate_prop_editor(self, prop_name):
        editor = self.editor_map["props"][prop_name]
        self.activate_editor(editor, label=f"✏️ {prop_name}")

    def activate_comment_editor(self, prop_name):
        editor = self.editor_map["comments"][prop_name]
        self.activate_editor(editor, label=f"🗩 {prop_name}")

    def _refresh_properties(self, pending:callable=lambda:None):
        if self.active.count() > 0 and not self.win._custom_popup(
            "Refresh Required",
            "This action requires a refresh of the current block. This will close all open editor tabs. Continue?",
            ("Continue", True),
            cancel=True
        ):
            return


        pending()

        if hasattr(self, "prop_layout"):
            dummy = QWidget()
            dummy.setLayout(self.prop_layout)
            dummy.deleteLater()

        prop_layout = QVBoxLayout(self.scroll_content)
        prop_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.prop_layout = prop_layout

        prop_dict = self.blk.get_prop_dict()
        prop_dict.pop("rename", None)
        rename_dict = self.blk.rename_dict()
        renamed_from = {v:k for k,v in rename_dict.items()}

        req_props = self.blk.get_req_validators().keys()
        opt_props = self.blk.get_validators()
        staged_templates = self.blk._staged_templates

        self.failed_found = False
        for prop, val in prop_dict.items():
            opt_props.pop(prop, None)
            self._add_prop_row(prop, val, renamed_from, req_props)

        for prop, val in staged_templates.items():
            opt_props.pop(prop, None)
            self._add_prop_row(prop, val, renamed_from, req_props, staged=True)

        opt_props.pop('ext', None)
        opt_props.pop('rename', None)

        if len(opt_props) > 0:
            self.opt_header = QLabel("<h4>Optional Properties Available:</h4>")
            self.opt_header.setAlignment(Qt.AlignmentFlag.AlignRight)
            prop_layout.addWidget(self.opt_header)

        for opt in opt_props:
            self._add_missing_prop(opt)
            # if prop == "rename":
            #     continue

            # prop_txt = prop
            # if prop in renamed_from:
            #     fn_i = self._add_footnote(f"Renamed from {renamed_from[prop]}")
            #     prop_txt += _unicode_superscript(fn_i)

            # if isinstance(val, blk_cont.SimpleWrapper):
            #     val = val()

            # row_layout = QHBoxLayout()

            # label = QLabel(f"<b>{prop_txt}:</b>")
            # label.setMinimumWidth(120)
            # row_layout.addWidget(label, stretch=0)
            # self.prop_labels[prop] = label

            # if isinstance(val, Block):
            #     btn_txt = "Go to Block"
            #     edit_btn = QPushButton(btn_txt)
            #     edit_btn.clicked.connect(lambda _, v=val: self.win.go_to_block(v))
            #     row_layout.addWidget(edit_btn, stretch=1)

            # else:
            #     txt = val.serialize() if hasattr(val, "serialize") else str(val)
            #     line_edit = QLineEdit(txt)
            #     line_edit.setCursorPosition(0)

            #     editor, enabler = _get_editor(val, self, prop)
            #     line_edit.setEnabled(enabler(txt))
            #     def on_apply(p=prop, e=line_edit, en=enabler):
            #         self._on_primitive_edit(p, e, en)

            #     def direct_edit(apply=on_apply):
            #         try:
            #             apply()
            #         except Exception:
            #             # TODO: pass to user
            #             pass


            #     line_edit.editingFinished.connect(on_apply)
            #     row_layout.addWidget(line_edit, stretch=1)

            #     if editor:
            #         editor = editor(self, line_edit, on_apply, prop)
            #         self.editor_map["props"][prop] = editor
            #         edit_btn = QPushButton("✏️")
            #         edit_btn.clicked.connect(lambda *_, p=prop: self.activate_prop_editor(p))
            #         row_layout.addWidget(edit_btn, stretch=0)

            # if prop not in req_props:
            #     del_btn = QPushButton("🗑")
            #     del_btn.clicked.connect(lambda *_, p=prop: self.delete_prop(p))
            #     row_layout.addWidget(del_btn, stretch=0)

            # comment_btn = QPushButton("🗩")
            # comment_editor = CommentEditorWidget(self, self.blk, prop, comment_btn)
            # self.editor_map["comments"][prop] = comment_editor
            # comment_editor.refresh_editor()
            # comment_btn.clicked.connect(lambda *_, p=prop: self.activate_comment_editor(p))
            # row_layout.addWidget(comment_btn, stretch=0)

            # row_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
            # self.prop_layout.addLayout(row_layout)

    def _add_prop_row(self, prop, val, renamed_from, req_props, staged=False):

        prop_txt = prop
        if prop in renamed_from:
            fn_i = self._add_footnote(f"Renamed from {renamed_from[prop]}")
            prop_txt += _unicode_superscript(fn_i)

        if staged:
            #unicode tag
            prop_txt = "🏷️" + prop_txt

        if isinstance(val, blk_cont.SimpleWrapper):
            val = val()


        row_layout = QHBoxLayout()

        label = QLabel(f"<b>{prop_txt}:</b>")
        label.setMinimumWidth(120)
        row_layout.addWidget(label, stretch=0)
        self.prop_labels[prop] = label

        if isinstance(val, Block):
            btn_txt = "Go to Block"
            edit_btn = QPushButton(btn_txt)
            edit_btn.clicked.connect(lambda _, v=val: self.win.go_to_block(v))
            row_layout.addWidget(edit_btn, stretch=1)

        else:
            txt = val.serialize() if hasattr(val, "serialize") else str(val)
            line_edit = QLineEdit(txt)
            self.line_edits[prop] = line_edit
            line_edit.setCursorPosition(0)

            editor, enabler = _get_editor(val, self, prop)
            line_edit.setEnabled(enabler(txt))
            def on_apply(p=prop, e=line_edit, en=enabler):
                self._on_primitive_edit(p, e, en)

            line_edit.editingFinished.connect(on_apply)
            row_layout.addWidget(line_edit, stretch=1)

            if editor:
                editor = editor(self, line_edit, on_apply, prop)
                self.editor_map["props"][prop] = editor
                edit_btn = QPushButton("✏️")
                edit_btn.clicked.connect(lambda *_, p=prop: self.activate_prop_editor(p))
                row_layout.addWidget(edit_btn, stretch=0)

        if prop not in req_props:
            del_btn = QPushButton("🗑")
            del_btn.clicked.connect(lambda *_, p=prop: self.delete_prop(p))
            row_layout.addWidget(del_btn, stretch=0)

        if not staged:
            comment_btn = QPushButton("🗩")
            comment_editor = CommentEditorWidget(self, self.blk, prop, comment_btn)
            self.editor_map["comments"][prop] = comment_editor
            comment_editor.refresh_editor()
            comment_btn.clicked.connect(lambda *_, p=prop: self.activate_comment_editor(p))
            row_layout.addWidget(comment_btn, stretch=0)

        if hasattr(self.blk, "_val_exceptions") and prop in self.blk._val_exceptions:
            if not self.failed_found:
                self.failed_found = True
                hint = QLabel("Properties marked with ❌ are invalid and need to be fixed "
                              "before this block can be completed. Click the ❌ button to "
                              "see more details.")
                hint.setWordWrap(True)

                self.prop_layout.insertWidget(0, hint, stretch=0)

            failed_btn = QPushButton("❌")
            exc = self.blk._val_exceptions[prop]

            # let handler display on raise
            def on_raise(*_,e=exc, p=prop):
                raise Exception(f"A validator failed for the property '{p}'. "
                                "View more details below.") from e

            failed_btn.clicked.connect(on_raise)
            row_layout.addWidget(failed_btn, stretch=0)


        row_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
        self.prop_layout.addLayout(row_layout)

    def to_line_edit(self, prop_name):
        if prop_name not in self.line_edits:
            return
        line_edit = self.line_edits[prop_name]
        line_edit.setFocus()
        line_edit.selectAll()

    def next_line(self, prop_name):
        line_props = list(self.line_edits.keys())
        if prop_name not in line_props:
            return
        line_idx = line_props.index(prop_name)
        line_idx = (line_idx + 1) % len(line_props)
        next_prop = line_props[line_idx]
        self.to_line_edit(next_prop)

    def _add_missing_prop(self, prop_name):
        #TODO: handle default values for non-primitives
        val=""

        row_layout = QHBoxLayout()
        row_layout.addStretch()
        label = QLabel(f"<b>{prop_name}</b>")
        row_layout.addWidget(label, stretch=0)
        self.prop_labels[prop_name] = label

        add_btn = QPushButton("+")
        add_btn.clicked.connect(lambda *_, p=prop_name, v=val: self.add_prop(p, val=v))
        row_layout.addWidget(add_btn, stretch=0)

        row_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
        self.prop_layout.addLayout(row_layout)

        self.missing_prop_rows[prop_name] = row_layout

    def add_prop(self, prop_name, val=""):
        from ....blocks import SingleBlock, ListBlock
        from .._utils import _clear_layout


        validators = self.blk.get_validators()
        pending_refresh = None
        validator = validators.get(prop_name, None)
        if isinstance(validator, type) and issubclass(validator, ListBlock):
            def pending_refresh(self,p=prop_name):
                setattr(self.blk, p, [])
                self.win._flag_edited(self.blk)

        elif isinstance(validator, type) and issubclass(validator, SingleBlock):
            def pending_refresh(self, p=prop_name):
                self.stage_template(p)

        if pending_refresh is not None:
            return self.hard_refresh(pending_refresh)(self)

        row_layout = self.missing_prop_rows.pop(prop_name, None)
        if row_layout is not None:
            _clear_layout(row_layout, delete=True)

        if (len(self.missing_prop_rows) == 0 and
            hasattr(self, "opt_header") and
            self.opt_header is not None):
            self.opt_header.setParent(None)
            self.opt_header.deleteLater()
            self.opt_header = None

        self._add_prop_row(prop_name, val, {}, {})
        self.activate_prop_editor(prop_name)

    def stage_template(self,prop_name):
        win = self.win
        blk = self.blk
        item = win.tree_items.get(blk, None)
        blk.stage_template(prop_name)
        if item is not None and "+" not in item.text():
            item.setText(f"{item.text()}+")

    def delete_prop(self, prop):
        if not self.win._custom_popup(
            "Delete Property",
            f"Are you sure you want to delete the property '{prop}'?\n"
            "Deleted properties cannot be recovered after any changes are saved.",
            ("Yes", True),
            cancel=True):
            return

        pending_delete = None
        if hasattr(self.blk, prop):
            def pending_delete(s,p=prop):
                delattr(s.blk, p)
                self.win._flag_edited(s.blk)
        elif prop in self.blk._staged_templates:
            def pending_delete(s,p=prop):
                s.blk._staged_templates.pop(p)
                s.win._flag_edited(s.blk)
        if pending_delete is not None:
            return self.hard_refresh(pending_delete)(self)

        # shouldn't get here
        raise Exception(f"Could not find property to delete: {prop}. Try Window > Refresh.")

    def _on_primitive_edit(self, prop:str, line_edit:QLineEdit, enabler:callable):
        new_text = line_edit.text()

        old_val = getattr(self.blk, prop, "")
        old_txt = old_val.serialize() if hasattr(old_val, "serialize") else str(old_val)

        if new_text == old_txt:
            return

        error = None
        try:
            setattr(self.blk, prop, new_text)

            enabled = enabler(new_text)
            line_edit.setEnabled(enabled)

            self.win._flag_edited(self.blk)
            label = self.prop_labels[prop]
            if "*" not in label.text():
                label.setText("*" + label.text())

        except Exception as e:
            line_edit.setText(old_txt)
            error = e

        if error is not None:
            raise error

        self.next_line(prop)

active instance-attribute

active = QTabWidget()

base_layout instance-attribute

base_layout = base_layout

blk instance-attribute

blk = blk

editor instance-attribute

editor = editor

editor_map instance-attribute

editor_map = {'props': {}, 'comments': {}, 'misc': {}}

footnote_iter instance-attribute

footnote_iter = _footnote_iter()

footnotes instance-attribute

footnotes = QVBoxLayout()

inactive instance-attribute

inactive = QLabel(
    "Select a property or comment editor to inspect it in more detail.\n\nGreyed-out properties can only be edited in the inspector."
)

line_edits instance-attribute

line_edits = {}

missing_prop_rows instance-attribute

missing_prop_rows = {}

prop_labels instance-attribute

prop_labels = {}

prop_map class-attribute instance-attribute

prop_map = None

scroll_content instance-attribute

scroll_content = scroll_content

sidebar instance-attribute

sidebar = sidebar

win instance-attribute

win = window

__init__

__init__(label, blk, window)
Source code in FoSpy/ui/app/block_widgets/_base.py
def __init__(self, label:str,blk:SingleBlock, window:MainWindow):
    self.win = window
    self.blk = blk
    parent = window.splitter
    self.editor_map = {"props": {}, "comments": {}, "misc": {}}
    self.footnote_iter = _footnote_iter()
    self.missing_prop_rows = {}
    self.line_edits = {}

    super().__init__(parent)


    self.header_row, base_layout = _add_header(self, label, "Properties")
    self.base_layout = base_layout

    custom_btn_layout = QHBoxLayout()
    base_layout.addLayout(custom_btn_layout)

    custom_txt_btn = QPushButton("Add Custom Property")
    custom_txt_btn.clicked.connect(lambda *_: self.add_custom_prop())
    custom_btn_layout.addWidget(custom_txt_btn)

    custom_blk_btn = QPushButton("Add Custom Block")
    custom_blk_btn.clicked.connect(lambda *_: self.add_custom_block())
    custom_btn_layout.addWidget(custom_blk_btn)
    custom_btn_layout.addStretch()

    if hasattr(blk, "rename"):
        rename_btn = QPushButton("Rename Properties")
        rename_btn.clicked.connect(
            lambda *_, b=blk.rename: self.win.go_to_block(b)
        )
        self.header_row.addWidget(rename_btn)

    main_layout = QHBoxLayout()
    main_layout.setContentsMargins(0, 0, 0, 0)

    base_layout.addLayout(main_layout)

    self.footnotes = QVBoxLayout()
    self.footnotes.setContentsMargins(0, 0, 0, 0)
    base_layout.addLayout(self.footnotes)

    sidebar = QVBoxLayout()
    self.sidebar = sidebar
    sidebar.setContentsMargins(*SIDEBAR_MARGINS)
    main_layout.addLayout(sidebar, stretch=0)

    editor = QStackedWidget()
    self.editor = editor
    main_layout.addWidget(editor, stretch=1)

    self.inactive = QLabel(
        "Select a property or comment editor to inspect it in more detail.\n\n"
        "Greyed-out properties can only be edited in the inspector."
        )
    self.inactive.setWordWrap(True)
    editor.addWidget(self.inactive)

    self.active = QTabWidget()
    editor.addWidget(self.active)

    self.deactivate_editor()

    sidebar_w = SIDEBAR_WIDTH

    scroll_w = sidebar_w - SIDEBAR_MARGINS[0] - SIDEBAR_MARGINS[2]

    scroll = QScrollArea(self)
    scroll.setWidgetResizable(True)
    scroll.setFrameShape(QScrollArea.Shape.NoFrame)
    scroll.setMinimumWidth(scroll_w)
    scroll.setContentsMargins(0,0,0,0)

    scroll_content = QWidget()
    scroll_content.setMinimumWidth(scroll_w)
    self.scroll_content = scroll_content

    scroll.setWidget(scroll_content)
    sidebar.addWidget(scroll)

    self.prop_labels = {}

    self._refresh_properties()

_add_footnote

_add_footnote(txt)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _add_footnote(self, txt):
    i = next(self.footnote_iter)

    footnote = QLabel(f"<sup>{i}</sup> {txt}")
    self.footnotes.addWidget(footnote)

    return i

_add_missing_prop

_add_missing_prop(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _add_missing_prop(self, prop_name):
    #TODO: handle default values for non-primitives
    val=""

    row_layout = QHBoxLayout()
    row_layout.addStretch()
    label = QLabel(f"<b>{prop_name}</b>")
    row_layout.addWidget(label, stretch=0)
    self.prop_labels[prop_name] = label

    add_btn = QPushButton("+")
    add_btn.clicked.connect(lambda *_, p=prop_name, v=val: self.add_prop(p, val=v))
    row_layout.addWidget(add_btn, stretch=0)

    row_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
    self.prop_layout.addLayout(row_layout)

    self.missing_prop_rows[prop_name] = row_layout

_add_prop_row

_add_prop_row(
    prop, val, renamed_from, req_props, staged=False
)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _add_prop_row(self, prop, val, renamed_from, req_props, staged=False):

    prop_txt = prop
    if prop in renamed_from:
        fn_i = self._add_footnote(f"Renamed from {renamed_from[prop]}")
        prop_txt += _unicode_superscript(fn_i)

    if staged:
        #unicode tag
        prop_txt = "🏷️" + prop_txt

    if isinstance(val, blk_cont.SimpleWrapper):
        val = val()


    row_layout = QHBoxLayout()

    label = QLabel(f"<b>{prop_txt}:</b>")
    label.setMinimumWidth(120)
    row_layout.addWidget(label, stretch=0)
    self.prop_labels[prop] = label

    if isinstance(val, Block):
        btn_txt = "Go to Block"
        edit_btn = QPushButton(btn_txt)
        edit_btn.clicked.connect(lambda _, v=val: self.win.go_to_block(v))
        row_layout.addWidget(edit_btn, stretch=1)

    else:
        txt = val.serialize() if hasattr(val, "serialize") else str(val)
        line_edit = QLineEdit(txt)
        self.line_edits[prop] = line_edit
        line_edit.setCursorPosition(0)

        editor, enabler = _get_editor(val, self, prop)
        line_edit.setEnabled(enabler(txt))
        def on_apply(p=prop, e=line_edit, en=enabler):
            self._on_primitive_edit(p, e, en)

        line_edit.editingFinished.connect(on_apply)
        row_layout.addWidget(line_edit, stretch=1)

        if editor:
            editor = editor(self, line_edit, on_apply, prop)
            self.editor_map["props"][prop] = editor
            edit_btn = QPushButton("✏️")
            edit_btn.clicked.connect(lambda *_, p=prop: self.activate_prop_editor(p))
            row_layout.addWidget(edit_btn, stretch=0)

    if prop not in req_props:
        del_btn = QPushButton("🗑")
        del_btn.clicked.connect(lambda *_, p=prop: self.delete_prop(p))
        row_layout.addWidget(del_btn, stretch=0)

    if not staged:
        comment_btn = QPushButton("🗩")
        comment_editor = CommentEditorWidget(self, self.blk, prop, comment_btn)
        self.editor_map["comments"][prop] = comment_editor
        comment_editor.refresh_editor()
        comment_btn.clicked.connect(lambda *_, p=prop: self.activate_comment_editor(p))
        row_layout.addWidget(comment_btn, stretch=0)

    if hasattr(self.blk, "_val_exceptions") and prop in self.blk._val_exceptions:
        if not self.failed_found:
            self.failed_found = True
            hint = QLabel("Properties marked with ❌ are invalid and need to be fixed "
                          "before this block can be completed. Click the ❌ button to "
                          "see more details.")
            hint.setWordWrap(True)

            self.prop_layout.insertWidget(0, hint, stretch=0)

        failed_btn = QPushButton("❌")
        exc = self.blk._val_exceptions[prop]

        # let handler display on raise
        def on_raise(*_,e=exc, p=prop):
            raise Exception(f"A validator failed for the property '{p}'. "
                            "View more details below.") from e

        failed_btn.clicked.connect(on_raise)
        row_layout.addWidget(failed_btn, stretch=0)


    row_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
    self.prop_layout.addLayout(row_layout)

_get_tabs

_get_tabs()
Source code in FoSpy/ui/app/block_widgets/_base.py
def _get_tabs(self):
    return [
        self.active.widget(i) for
        i in range(self.active.count())
    ]

_on_primitive_edit

_on_primitive_edit(prop, line_edit, enabler)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _on_primitive_edit(self, prop:str, line_edit:QLineEdit, enabler:callable):
    new_text = line_edit.text()

    old_val = getattr(self.blk, prop, "")
    old_txt = old_val.serialize() if hasattr(old_val, "serialize") else str(old_val)

    if new_text == old_txt:
        return

    error = None
    try:
        setattr(self.blk, prop, new_text)

        enabled = enabler(new_text)
        line_edit.setEnabled(enabled)

        self.win._flag_edited(self.blk)
        label = self.prop_labels[prop]
        if "*" not in label.text():
            label.setText("*" + label.text())

    except Exception as e:
        line_edit.setText(old_txt)
        error = e

    if error is not None:
        raise error

    self.next_line(prop)

_refresh_properties

_refresh_properties(pending=lambda: None)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _refresh_properties(self, pending:callable=lambda:None):
    if self.active.count() > 0 and not self.win._custom_popup(
        "Refresh Required",
        "This action requires a refresh of the current block. This will close all open editor tabs. Continue?",
        ("Continue", True),
        cancel=True
    ):
        return


    pending()

    if hasattr(self, "prop_layout"):
        dummy = QWidget()
        dummy.setLayout(self.prop_layout)
        dummy.deleteLater()

    prop_layout = QVBoxLayout(self.scroll_content)
    prop_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
    self.prop_layout = prop_layout

    prop_dict = self.blk.get_prop_dict()
    prop_dict.pop("rename", None)
    rename_dict = self.blk.rename_dict()
    renamed_from = {v:k for k,v in rename_dict.items()}

    req_props = self.blk.get_req_validators().keys()
    opt_props = self.blk.get_validators()
    staged_templates = self.blk._staged_templates

    self.failed_found = False
    for prop, val in prop_dict.items():
        opt_props.pop(prop, None)
        self._add_prop_row(prop, val, renamed_from, req_props)

    for prop, val in staged_templates.items():
        opt_props.pop(prop, None)
        self._add_prop_row(prop, val, renamed_from, req_props, staged=True)

    opt_props.pop('ext', None)
    opt_props.pop('rename', None)

    if len(opt_props) > 0:
        self.opt_header = QLabel("<h4>Optional Properties Available:</h4>")
        self.opt_header.setAlignment(Qt.AlignmentFlag.AlignRight)
        prop_layout.addWidget(self.opt_header)

    for opt in opt_props:
        self._add_missing_prop(opt)

_register_misc_editor

_register_misc_editor(
    editor, label="Misc Editor", editor_id=None
)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _register_misc_editor(self, editor, label="Misc Editor", editor_id:Sentinel=None):
    if editor_id is None:
        # instantiate unique ID object
        from ..window import Sentinel
        editor_id = Sentinel("misc editor id")

    self.editor_map["misc"][editor_id] = (editor, label)

    return editor_id

activate_comment_editor

activate_comment_editor(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def activate_comment_editor(self, prop_name):
    editor = self.editor_map["comments"][prop_name]
    self.activate_editor(editor, label=f"🗩 {prop_name}")

activate_editor

activate_editor(editor, label='MISSING')
Source code in FoSpy/ui/app/block_widgets/_base.py
def activate_editor(self, editor, label="MISSING"):
    tabs = [
        self.active.widget(i) for
        i in range(self.active.count())
    ]

    if hasattr(editor, "btn"):
        txt = editor.btn.text()
        if "*" not in txt:
            editor.btn.setText(txt+"*")

    if editor not in tabs:
        self.active.addTab(editor, label)

    self.active.setCurrentIndex(self.active.indexOf(editor))
    self.editor.setCurrentWidget(self.active)

activate_misc_editor

activate_misc_editor(editor_id)
Source code in FoSpy/ui/app/block_widgets/_base.py
def activate_misc_editor(self, editor_id:Sentinel):
    editor, label = self.editor_map["misc"][editor_id]
    self.activate_editor(editor, label)

activate_prop_editor

activate_prop_editor(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def activate_prop_editor(self, prop_name):
    editor = self.editor_map["props"][prop_name]
    self.activate_editor(editor, label=f"✏️ {prop_name}")

add_custom_block

add_custom_block()
Source code in FoSpy/ui/app/block_widgets/_base.py
def add_custom_block(self):
    from ....blocks import SingleBlock, ListBlock

    validators = self.blk.get_validators()

    aliases = self.blk._aliases

    alias_opts = {
        aliases[k].__name__: k for k in sorted(aliases.keys())
    }

    prop_name = None
    while prop_name is None:
        results = self.win._get_text_inputs("New Custom Property",
            "Enter the name of the property to add:\n"
            "(Letters and underscores only)",
            "Property Name",
            **{"Block Type": alias_opts}
        )

        if results is None:
            return

        prop_name = results["Property Name"].replace(" ", "_").replace("-","_")
        alias = results["Block Type"]

        if prop_name in validators or hasattr(self.blk, prop_name):
            self.win._custom_popup(
                "Can't add custom property",
                "That property is already expected in this block.",
                cancel=False
            )
            prop_name = None

    prop_alias = prop_name + "$" + alias
    def pending_refresh(s, p=prop_alias):
        s.blk.stage_template(p)

    self.hard_refresh(pending_refresh)(self)

add_custom_prop

add_custom_prop()
Source code in FoSpy/ui/app/block_widgets/_base.py
def add_custom_prop(self):
    validators = self.blk.get_validators()

    prop_name = None
    while prop_name is None:
        prop_name = self.win._get_text_inputs("New Custom Property",
            "Enter the name of the property to add:\n"
            "(Letters and underscores only)",
            "Property Name"
        ).replace(" ", "_").replace("-","_")

        if prop_name is None:
            return

        if prop_name in validators or hasattr(self.blk, prop_name):
            self.win._custom_popup(
                "Can't add custom property",
                "That property is already expected in this block.",
                cancel=False
            )
            prop_name = None

    def pending_refresh(s, p=prop_name):
        setattr(s.blk, p, "")

    self.hard_refresh(pending_refresh)(self)

add_prop

add_prop(prop_name, val='')
Source code in FoSpy/ui/app/block_widgets/_base.py
def add_prop(self, prop_name, val=""):
    from ....blocks import SingleBlock, ListBlock
    from .._utils import _clear_layout


    validators = self.blk.get_validators()
    pending_refresh = None
    validator = validators.get(prop_name, None)
    if isinstance(validator, type) and issubclass(validator, ListBlock):
        def pending_refresh(self,p=prop_name):
            setattr(self.blk, p, [])
            self.win._flag_edited(self.blk)

    elif isinstance(validator, type) and issubclass(validator, SingleBlock):
        def pending_refresh(self, p=prop_name):
            self.stage_template(p)

    if pending_refresh is not None:
        return self.hard_refresh(pending_refresh)(self)

    row_layout = self.missing_prop_rows.pop(prop_name, None)
    if row_layout is not None:
        _clear_layout(row_layout, delete=True)

    if (len(self.missing_prop_rows) == 0 and
        hasattr(self, "opt_header") and
        self.opt_header is not None):
        self.opt_header.setParent(None)
        self.opt_header.deleteLater()
        self.opt_header = None

    self._add_prop_row(prop_name, val, {}, {})
    self.activate_prop_editor(prop_name)

deactivate_editor

deactivate_editor(editor=None)
Source code in FoSpy/ui/app/block_widgets/_base.py
def deactivate_editor(self, editor=None):
    tabs = self._get_tabs()

    if editor in tabs:
        self.active.removeTab(self.active.indexOf(editor))

    if self.active.count() == 0 or editor is None:
        self.editor.setCurrentWidget(self.inactive)

    if hasattr(editor, "btn"):
        editor.btn.setText(editor.btn.text().replace("*",""))

delete_prop

delete_prop(prop)
Source code in FoSpy/ui/app/block_widgets/_base.py
def delete_prop(self, prop):
    if not self.win._custom_popup(
        "Delete Property",
        f"Are you sure you want to delete the property '{prop}'?\n"
        "Deleted properties cannot be recovered after any changes are saved.",
        ("Yes", True),
        cancel=True):
        return

    pending_delete = None
    if hasattr(self.blk, prop):
        def pending_delete(s,p=prop):
            delattr(s.blk, p)
            self.win._flag_edited(s.blk)
    elif prop in self.blk._staged_templates:
        def pending_delete(s,p=prop):
            s.blk._staged_templates.pop(p)
            s.win._flag_edited(s.blk)
    if pending_delete is not None:
        return self.hard_refresh(pending_delete)(self)

    # shouldn't get here
    raise Exception(f"Could not find property to delete: {prop}. Try Window > Refresh.")

hard_refresh staticmethod

hard_refresh(func)
Source code in FoSpy/ui/app/block_widgets/_base.py
@staticmethod
def hard_refresh(func):
    def decorated(self, *args, **kwargs):
        def pending(f=func, a=args, k=kwargs):
            f(self, *a, **k)

        self.win.hard_refresh(self.blk, func=pending)
    return decorated

next_line

next_line(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def next_line(self, prop_name):
    line_props = list(self.line_edits.keys())
    if prop_name not in line_props:
        return
    line_idx = line_props.index(prop_name)
    line_idx = (line_idx + 1) % len(line_props)
    next_prop = line_props[line_idx]
    self.to_line_edit(next_prop)

stage_template

stage_template(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def stage_template(self,prop_name):
    win = self.win
    blk = self.blk
    item = win.tree_items.get(blk, None)
    blk.stage_template(prop_name)
    if item is not None and "+" not in item.text():
        item.setText(f"{item.text()}+")

to_line_edit

to_line_edit(prop_name)
Source code in FoSpy/ui/app/block_widgets/_base.py
def to_line_edit(self, prop_name):
    if prop_name not in self.line_edits:
        return
    line_edit = self.line_edits[prop_name]
    line_edit.setFocus()
    line_edit.selectAll()

_add_header

_add_header(widget, label, view_name=None)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _add_header(widget:QWidget,label, view_name=None):
    blk = widget.blk

    base_layout = QVBoxLayout(widget)
    base_layout.setContentsMargins(0, 0, 0, 0)
    widget.setLayout(base_layout)

    header_row = QHBoxLayout()
    header_row.setContentsMargins(0, 0, 0, 0)

    preamble = f"{view_name} | " if view_name else ""
    post = None

    prop = blk.get_parent_prop()
    if prop is not None and "[" not in prop:
        rename_dict = blk._parent_block.rename_dict()
        if prop in rename_dict.values():
            renamed_from = next(k for k,v in rename_dict.items() if v == prop)
            label += " |"
            post = f"<i>(Renamed from {renamed_from})</i>"

    header = QLabel(f"<h3>{preamble}{label}</h3>")
    header_row.addWidget(header)
    if post is not None:
        header_row.addWidget(QLabel(post))

    base_layout.addLayout(header_row)

    subhead = QLabel(f"<h4>Block Type: <code>{type(blk).__name__}</code></h4>")
    pathtxt = blk.get_prop_path().replace("<","&lt;").replace(">","&gt;")
    pathhead = QLabel(f"<h4>Full Path: <code>{pathtxt}</code></h4>")
    base_layout.addWidget(subhead)
    base_layout.addWidget(pathhead)

    return header_row, base_layout

_footnote_iter

_footnote_iter()
Source code in FoSpy/ui/app/block_widgets/_base.py
def _footnote_iter():
    i = 1
    while True:
        yield i
        i += 1

_get_editor

_get_editor(value, blk_widget=None, prop=None)
Source code in FoSpy/ui/app/block_widgets/_utils.py
def _get_editor(value, blk_widget=None, prop=None):
    prop_map = blk_widget.prop_map if (
        blk_widget is not None and
        hasattr(blk_widget, "prop_map")
    ) else None

    builder = _get_widget(value, prop_map, prop)

    if builder is _widget_not_found:
        if hasattr(value, "serialize") and callable(value.serialize):
            return _get_editor(value.serialize())

        return _get_editor(str(value))

    editor, enabler = builder

    if not callable(enabler):
        def static_enabler(val, e=enabler):
            return e
        enabler = static_enabler

    return editor, enabler

_get_label

_get_label(blk, i=None)
Source code in FoSpy/ui/app/_utils.py
def _get_label(blk, i=None):        
    id_key, id_txt = blk.get_id()
    label = f"{i} - " if i is not None else ""
    label += id_txt

    if id_key is None:
        label += " Object"

    return label

_get_template_label

_get_template_label(blk)
Source code in FoSpy/ui/app/_utils.py
def _get_template_label(blk):
    id_key, id_txt = blk.get_id()

    label = "🏷️"
    label += id_txt

    if id_key is None:
        label += " Object"

    return label

_get_widget

_get_widget(blk, prop_map=None, prop=None)
Source code in FoSpy/ui/app/block_widgets/_utils.py
def _get_widget(blk, prop_map=None, prop=None):
    if isinstance(blk, TemplateBlock):
        return _get_template_widget(blk, prop_map, prop)
    if prop_map is not None:
        if prop in prop_map:
            return prop_map[prop]
        elif "__all__" in prop_map:
            return prop_map["__all__"]

    from . import widget_map

    for k, v in widget_map.items():
        if isinstance(blk, k):
            return v

    return _widget_not_found

_unicode_superscript

_unicode_superscript(i)
Source code in FoSpy/ui/app/block_widgets/_base.py
def _unicode_superscript(i:int):
    uni_map = ["⁰","¹","²","³","⁴","⁵","⁶","⁷","⁸","⁹"]

    base = i // 10

    if i >= 10:
        i = i % 10

    txt = uni_map[i]
    if base > 0:
        txt += _unicode_superscript(base)

    return txt