Skip to content

console

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.console

CONSOLE_DIM module-attribute

CONSOLE_DIM = (800, 600)

CONSOLE_FONT module-attribute

CONSOLE_FONT = QFont('Monospace')

CONSOLE_STYLE module-attribute

CONSOLE_STYLE = "background-color: #1E1E1E; color: #ffffff;"

VARS_MAX_H module-attribute

VARS_MAX_H = 150

VAR_NAME_W module-attribute

VAR_NAME_W = 200

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

ProtectedConsole

Bases: code.InteractiveConsole

Source code in FoSpy/ui/app/console.py
class ProtectedConsole(code.InteractiveConsole):
    def __init__(self, *protected_vars:str, **all_vars):
        if "read_only" in all_vars:
            raise ValueError("read_only is reserved for internal use")

        free_vars = all_vars.copy()
        protected = {}
        for v in protected_vars:
            if v in protected:
                raise ValueError(f"Duplicate protected variable '{v}'")
            if v not in free_vars:
                raise ValueError(f"Cannot protect variable '{v}', it does not exist")

            protected[v] = free_vars.pop(v)

        self.read_only = ReadOnly(**protected)

        local_vars = ReadOnlyRedirect(self.read_only, **free_vars)

        super().__init__(locals=local_vars)

    def push(self, line):
        try:
            tree = ast.parse(line)
        except SyntaxError:
            # delegate to base handling
            return super().push(line)

        assignments = (
            ast.Assign,
            ast.AugAssign,
            ast.AnnAssign,
            ast.NamedExpr
        )

        if any(
            isinstance(node, assignments)
            and (
                (
                    hasattr(node, "targets") and any(
                        self._is_protected(t)
                        for t in node.targets
                    )
                )
                or
                (
                    hasattr(node, "target") and
                    self._is_protected(node.target)
                )
            )
            for node in ast.walk(tree)
        ):
            return False

        return super().push(line)

    def _is_protected(self, target):
        if isinstance(target, ast.Name):
            return self._read_only(target.id)

        if isinstance(target, ast.Tuple):
            return any(self._is_protected(t) for t in target.elts)

        if isinstance(target, ast.Starred):
            return self._is_protected(target.value)

        return False

    def _read_only(self, name):
        if name == "read_only" or hasattr(self.read_only, name):
            self.log(f"Cannot assign to read-only variable '{name}'")
            return True
        return False

    def add_protected(self, **protected_vars):
        self.read_only.override(**protected_vars)
        self.log("Added/Updated Protected Variables:",
                 *[f"    {k}" for k in protected_vars])

    def get_protected(self):
        return self.read_only.get_registry()

    def log(self, *txt):
        if not txt:
            return

        if len(txt) == 1:
            return print("[console] " + txt[0])

        print("[console/]")
        for t in txt:
            print(t)
        print("[/console]")

read_only instance-attribute

read_only = ReadOnly(**protected)

__init__

__init__(*protected_vars, **all_vars)
Source code in FoSpy/ui/app/console.py
def __init__(self, *protected_vars:str, **all_vars):
    if "read_only" in all_vars:
        raise ValueError("read_only is reserved for internal use")

    free_vars = all_vars.copy()
    protected = {}
    for v in protected_vars:
        if v in protected:
            raise ValueError(f"Duplicate protected variable '{v}'")
        if v not in free_vars:
            raise ValueError(f"Cannot protect variable '{v}', it does not exist")

        protected[v] = free_vars.pop(v)

    self.read_only = ReadOnly(**protected)

    local_vars = ReadOnlyRedirect(self.read_only, **free_vars)

    super().__init__(locals=local_vars)

_is_protected

_is_protected(target)
Source code in FoSpy/ui/app/console.py
def _is_protected(self, target):
    if isinstance(target, ast.Name):
        return self._read_only(target.id)

    if isinstance(target, ast.Tuple):
        return any(self._is_protected(t) for t in target.elts)

    if isinstance(target, ast.Starred):
        return self._is_protected(target.value)

    return False

_read_only

_read_only(name)
Source code in FoSpy/ui/app/console.py
def _read_only(self, name):
    if name == "read_only" or hasattr(self.read_only, name):
        self.log(f"Cannot assign to read-only variable '{name}'")
        return True
    return False

add_protected

add_protected(**protected_vars)
Source code in FoSpy/ui/app/console.py
def add_protected(self, **protected_vars):
    self.read_only.override(**protected_vars)
    self.log("Added/Updated Protected Variables:",
             *[f"    {k}" for k in protected_vars])

get_protected

get_protected()
Source code in FoSpy/ui/app/console.py
def get_protected(self):
    return self.read_only.get_registry()

log

log(*txt)
Source code in FoSpy/ui/app/console.py
def log(self, *txt):
    if not txt:
        return

    if len(txt) == 1:
        return print("[console] " + txt[0])

    print("[console/]")
    for t in txt:
        print(t)
    print("[/console]")

push

push(line)
Source code in FoSpy/ui/app/console.py
def push(self, line):
    try:
        tree = ast.parse(line)
    except SyntaxError:
        # delegate to base handling
        return super().push(line)

    assignments = (
        ast.Assign,
        ast.AugAssign,
        ast.AnnAssign,
        ast.NamedExpr
    )

    if any(
        isinstance(node, assignments)
        and (
            (
                hasattr(node, "targets") and any(
                    self._is_protected(t)
                    for t in node.targets
                )
            )
            or
            (
                hasattr(node, "target") and
                self._is_protected(node.target)
            )
        )
        for node in ast.walk(tree)
    ):
        return False

    return super().push(line)

PythonConsole

Bases: QDialog

Source code in FoSpy/ui/app/console.py
class PythonConsole(QDialog):
    def __init__(self, parent:MainWindow=None, persistent=True, **local_vars:Any|tuple[Any, str]):
        super().__init__(parent)
        self.persistent=persistent
        self.indent = 0

        # attach to parent
        if parent is not None and (
            not hasattr(parent, "__python_console__") or
            parent.__python_console__ is None
        ):
            parent.__python_console__ = self

        self.setWindowTitle("FoSpy GUI - Python Console")
        self.resize(*CONSOLE_DIM)

        layout = QVBoxLayout(self)

        menu_bar = QMenuBar(self)
        layout.setMenuBar(menu_bar)

        console_menu = menu_bar.addMenu("&Console")

        if persistent:
            hide_action = QAction("Hide", self)
            hide_action.triggered.connect(self.close)
            console_menu.addAction(hide_action)

        restart_action = QAction("Restart", self)
        restart_action.triggered.connect(self.restart)
        console_menu.addAction(restart_action)

        close_action = QAction("Close", self)
        close_action.triggered.connect(self.exit_dlg)
        console_menu.addAction(close_action)

        header = QLabel("<h2>FoSpy GUI - Live Python Console</h2>")
        layout.addWidget(header, stretch=0)

        subheader = QLabel("<h3>Available Variables:</h3>")
        layout.addWidget(subheader, stretch=0)

        var_header_layout = QHBoxLayout()
        var_name = QLabel("<h4>Variable Name</h4>")
        var_name.setMinimumWidth(VAR_NAME_W)
        var_name.setAlignment(Qt.AlignmentFlag.AlignCenter)

        var_desc = QLabel("<h4>Description</h4>")
        var_desc.setAlignment(Qt.AlignmentFlag.AlignCenter)

        var_header_layout.addWidget(var_name, stretch=0)
        var_header_layout.addWidget(var_desc, stretch=1)
        layout.addLayout(var_header_layout, stretch=0)

        var_container = QWidget()
        self.var_layout = QVBoxLayout(var_container)
        self.var_layout.setContentsMargins(0, 0, 0, 0)

        var_scroll = QScrollArea(self)
        var_scroll.setWidgetResizable(True)
        var_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
        var_scroll.setContentsMargins(0, 0, 0, 0)
        var_scroll.setWidget(var_container)
        var_scroll.setMinimumHeight(VARS_MAX_H)
        layout.addWidget(var_scroll, stretch=0)

        self.output = QTextEdit(self)
        self.output.setReadOnly(True)
        self.output.setFont(CONSOLE_FONT)
        self.output.setStyleSheet(CONSOLE_STYLE)
        layout.addWidget(self.output, stretch=1)

        input_layout = QHBoxLayout()
        input_label = QLabel(">>>")
        self.input = QLineEdit()
        self.input.setFont(CONSOLE_FONT)
        self.input.setStyleSheet(CONSOLE_STYLE)
        self.input.returnPressed.connect(self.execute_line)

        input_layout.addWidget(input_label)
        input_layout.addWidget(self.input, stretch=1)
        layout.addLayout(input_layout, stretch=0)

        self.console_vars = {
            "__console__": (self, "The Python Console"),
        }
        for key, value in local_vars.items():
            if (isinstance(value, tuple) and
                len(value) == 2 and
                isinstance(value[1], str)):
                value, desc = value
            else:
                desc = "Unknown"

            self.console_vars[key] = (value, desc)

        self.console = ProtectedConsole()
        self.console.run_command = self.output.append
        self.buffer = self._buffer()
        self.refresh_vars()

    def _buffer(self):
        while True:
            # cache excepttion hook
            cached_hook = sys.excepthook
            sys.excepthook = self.handle_exception

            buf = io.StringIO()
            old_stdout = sys.stdout
            old_stderr = sys.stderr
            sys.stdout = buf
            sys.stderr = buf

            yield buf

            sys.stdout = old_stdout
            sys.stderr = old_stderr
            sys.excepthook = cached_hook

            out = buf.getvalue()
            if out.strip():
                self.output.append(out)

            yield False

    def open_buffer(self):
        return next(b for b in self.buffer if b)

    def flush(self):
        return next(b for b in self.buffer if not b)

    @staticmethod
    def single_buffer(func):
        def decorated(self, *args, **kwargs):
            self.open_buffer()
            func(self, *args, **kwargs)
            self.flush()
        return decorated

    @single_buffer
    def add_protected(self, **protected_vars):
        self.console.add_protected(**protected_vars)

    def refresh_vars(self):
        self.flush()
        _clear_layout(self.var_layout)

        current_vars = {
            k: (v, "Unknown") for k, v in
            self.console.get_protected().items()
        }

        current_vars.update(self.console_vars)

        new_registry = {}
        for key, (val, desc) in current_vars.items():
            new_registry[key] = val

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

            key_label = QLabel(key)
            key_label.setMinimumWidth(VAR_NAME_W)
            key_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            key_label.setFont(CONSOLE_FONT)
            row_layout.addWidget(key_label, stretch=0)

            desc_label = QLabel(desc)
            desc_label.setWordWrap(True)
            row_layout.addWidget(desc_label, stretch=1)
            self.var_layout.addLayout(row_layout)

        self.add_protected(**new_registry)
        self.var_layout.addStretch()

    @single_buffer
    def execute_line(self):
        src = self.input.text()
        self.input.clear()

        push = True
        if src.strip() == "":
            self.indent = self.indent - 1 if self.indent > 0 else 0

            if self.indent > 0:
                push=False

            src = src.strip()

        self.output.append(f">>>{' .' * self.indent} {src}")

        if not push:
            return

        src = "    " * self.indent + src.strip()
        if src.strip().endswith(":"):
            self.indent += 1
            self.output.append(f">>>{' .' * self.indent}")

        self.console.push(src)

    def handle_exception(self, exctype, value, tb):
        # format full traceback
        formatted = "".join(traceback.format_exception(exctype, value, tb))

        self.output.append(formatted)

    def restart(self):

        if not self.exit_dlg(title="Restart Python Console"):
            return

        self.exit_override()
        self.parent()._open_console()

    def exit_dlg(self, title="Exit Python Console"):
        options = [
            (title, self.exit_override),
        ]

        if self.persistent:
            options.append(("Keep Output and Hide", self.close))


        resp = self.parent()._custom_popup(
            title,
            f"{title}?\n\n"
            "All console output will be lost.",
            *options,
            cancel=True
        )

        if resp:
            resp()
            return resp == self.exit_override

        return False


    def exit_override(self):
        self.persistent = False
        self.close()

    def closeEvent(self, event):
        if self.persistent:
            event.ignore()
            self.hide()
        else:
            if (hasattr(self.parent(), "__python_console__") and
                self.parent().__python_console__ is self):
                self.parent().__python_console__ = None
            super().closeEvent(event)

        win = self.parent()
        file = win.root_block

        win._flag_edited(file)
        win.refresh_tree()

buffer instance-attribute

buffer = self._buffer()

console instance-attribute

console = ProtectedConsole()

console_vars instance-attribute

console_vars = {"__console__": (self, "The Python Console")}

indent instance-attribute

indent = 0

input instance-attribute

input = QLineEdit()

output instance-attribute

output = QTextEdit(self)

persistent instance-attribute

persistent = persistent

var_layout instance-attribute

var_layout = QVBoxLayout(var_container)

__init__

__init__(parent=None, persistent=True, **local_vars)
Source code in FoSpy/ui/app/console.py
def __init__(self, parent:MainWindow=None, persistent=True, **local_vars:Any|tuple[Any, str]):
    super().__init__(parent)
    self.persistent=persistent
    self.indent = 0

    # attach to parent
    if parent is not None and (
        not hasattr(parent, "__python_console__") or
        parent.__python_console__ is None
    ):
        parent.__python_console__ = self

    self.setWindowTitle("FoSpy GUI - Python Console")
    self.resize(*CONSOLE_DIM)

    layout = QVBoxLayout(self)

    menu_bar = QMenuBar(self)
    layout.setMenuBar(menu_bar)

    console_menu = menu_bar.addMenu("&Console")

    if persistent:
        hide_action = QAction("Hide", self)
        hide_action.triggered.connect(self.close)
        console_menu.addAction(hide_action)

    restart_action = QAction("Restart", self)
    restart_action.triggered.connect(self.restart)
    console_menu.addAction(restart_action)

    close_action = QAction("Close", self)
    close_action.triggered.connect(self.exit_dlg)
    console_menu.addAction(close_action)

    header = QLabel("<h2>FoSpy GUI - Live Python Console</h2>")
    layout.addWidget(header, stretch=0)

    subheader = QLabel("<h3>Available Variables:</h3>")
    layout.addWidget(subheader, stretch=0)

    var_header_layout = QHBoxLayout()
    var_name = QLabel("<h4>Variable Name</h4>")
    var_name.setMinimumWidth(VAR_NAME_W)
    var_name.setAlignment(Qt.AlignmentFlag.AlignCenter)

    var_desc = QLabel("<h4>Description</h4>")
    var_desc.setAlignment(Qt.AlignmentFlag.AlignCenter)

    var_header_layout.addWidget(var_name, stretch=0)
    var_header_layout.addWidget(var_desc, stretch=1)
    layout.addLayout(var_header_layout, stretch=0)

    var_container = QWidget()
    self.var_layout = QVBoxLayout(var_container)
    self.var_layout.setContentsMargins(0, 0, 0, 0)

    var_scroll = QScrollArea(self)
    var_scroll.setWidgetResizable(True)
    var_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
    var_scroll.setContentsMargins(0, 0, 0, 0)
    var_scroll.setWidget(var_container)
    var_scroll.setMinimumHeight(VARS_MAX_H)
    layout.addWidget(var_scroll, stretch=0)

    self.output = QTextEdit(self)
    self.output.setReadOnly(True)
    self.output.setFont(CONSOLE_FONT)
    self.output.setStyleSheet(CONSOLE_STYLE)
    layout.addWidget(self.output, stretch=1)

    input_layout = QHBoxLayout()
    input_label = QLabel(">>>")
    self.input = QLineEdit()
    self.input.setFont(CONSOLE_FONT)
    self.input.setStyleSheet(CONSOLE_STYLE)
    self.input.returnPressed.connect(self.execute_line)

    input_layout.addWidget(input_label)
    input_layout.addWidget(self.input, stretch=1)
    layout.addLayout(input_layout, stretch=0)

    self.console_vars = {
        "__console__": (self, "The Python Console"),
    }
    for key, value in local_vars.items():
        if (isinstance(value, tuple) and
            len(value) == 2 and
            isinstance(value[1], str)):
            value, desc = value
        else:
            desc = "Unknown"

        self.console_vars[key] = (value, desc)

    self.console = ProtectedConsole()
    self.console.run_command = self.output.append
    self.buffer = self._buffer()
    self.refresh_vars()

_buffer

_buffer()
Source code in FoSpy/ui/app/console.py
def _buffer(self):
    while True:
        # cache excepttion hook
        cached_hook = sys.excepthook
        sys.excepthook = self.handle_exception

        buf = io.StringIO()
        old_stdout = sys.stdout
        old_stderr = sys.stderr
        sys.stdout = buf
        sys.stderr = buf

        yield buf

        sys.stdout = old_stdout
        sys.stderr = old_stderr
        sys.excepthook = cached_hook

        out = buf.getvalue()
        if out.strip():
            self.output.append(out)

        yield False

add_protected

add_protected(**protected_vars)
Source code in FoSpy/ui/app/console.py
@single_buffer
def add_protected(self, **protected_vars):
    self.console.add_protected(**protected_vars)

closeEvent

closeEvent(event)
Source code in FoSpy/ui/app/console.py
def closeEvent(self, event):
    if self.persistent:
        event.ignore()
        self.hide()
    else:
        if (hasattr(self.parent(), "__python_console__") and
            self.parent().__python_console__ is self):
            self.parent().__python_console__ = None
        super().closeEvent(event)

    win = self.parent()
    file = win.root_block

    win._flag_edited(file)
    win.refresh_tree()

execute_line

execute_line()
Source code in FoSpy/ui/app/console.py
@single_buffer
def execute_line(self):
    src = self.input.text()
    self.input.clear()

    push = True
    if src.strip() == "":
        self.indent = self.indent - 1 if self.indent > 0 else 0

        if self.indent > 0:
            push=False

        src = src.strip()

    self.output.append(f">>>{' .' * self.indent} {src}")

    if not push:
        return

    src = "    " * self.indent + src.strip()
    if src.strip().endswith(":"):
        self.indent += 1
        self.output.append(f">>>{' .' * self.indent}")

    self.console.push(src)

exit_dlg

exit_dlg(title='Exit Python Console')
Source code in FoSpy/ui/app/console.py
def exit_dlg(self, title="Exit Python Console"):
    options = [
        (title, self.exit_override),
    ]

    if self.persistent:
        options.append(("Keep Output and Hide", self.close))


    resp = self.parent()._custom_popup(
        title,
        f"{title}?\n\n"
        "All console output will be lost.",
        *options,
        cancel=True
    )

    if resp:
        resp()
        return resp == self.exit_override

    return False

exit_override

exit_override()
Source code in FoSpy/ui/app/console.py
def exit_override(self):
    self.persistent = False
    self.close()

flush

flush()
Source code in FoSpy/ui/app/console.py
def flush(self):
    return next(b for b in self.buffer if not b)

handle_exception

handle_exception(exctype, value, tb)
Source code in FoSpy/ui/app/console.py
def handle_exception(self, exctype, value, tb):
    # format full traceback
    formatted = "".join(traceback.format_exception(exctype, value, tb))

    self.output.append(formatted)

open_buffer

open_buffer()
Source code in FoSpy/ui/app/console.py
def open_buffer(self):
    return next(b for b in self.buffer if b)

refresh_vars

refresh_vars()
Source code in FoSpy/ui/app/console.py
def refresh_vars(self):
    self.flush()
    _clear_layout(self.var_layout)

    current_vars = {
        k: (v, "Unknown") for k, v in
        self.console.get_protected().items()
    }

    current_vars.update(self.console_vars)

    new_registry = {}
    for key, (val, desc) in current_vars.items():
        new_registry[key] = val

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

        key_label = QLabel(key)
        key_label.setMinimumWidth(VAR_NAME_W)
        key_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        key_label.setFont(CONSOLE_FONT)
        row_layout.addWidget(key_label, stretch=0)

        desc_label = QLabel(desc)
        desc_label.setWordWrap(True)
        row_layout.addWidget(desc_label, stretch=1)
        self.var_layout.addLayout(row_layout)

    self.add_protected(**new_registry)
    self.var_layout.addStretch()

restart

restart()
Source code in FoSpy/ui/app/console.py
def restart(self):

    if not self.exit_dlg(title="Restart Python Console"):
        return

    self.exit_override()
    self.parent()._open_console()

single_buffer staticmethod

single_buffer(func)
Source code in FoSpy/ui/app/console.py
@staticmethod
def single_buffer(func):
    def decorated(self, *args, **kwargs):
        self.open_buffer()
        func(self, *args, **kwargs)
        self.flush()
    return decorated

ReadOnly

Source code in FoSpy/ui/app/console.py
class ReadOnly:
    def __init__(self, **attrs):
        self._locked = True

        if "_registry" in attrs:
            raise ValueError("read_only._registry is reserved for internal use")

        self.override(_registry={},**attrs)

    def __setattr__(self, name, value):
        if name == "_locked":
            if not isinstance(value, bool):
                raise TypeError(f"Expected bool for '_locked', got {type(value)}")
            return super().__setattr__(name, value)

        if name == "_registry":
            if not isinstance(value, dict):
                raise TypeError(f"Expected dict for '_registry', got {type(value)}")
            return super().__setattr__(name, value)

        if self._locked:
            raise AttributeError(f"Attribute {name} is read-only")
        super().__setattr__(name, value)

    def override(self, **attrs):
        self._locked = False
        for k, v in attrs.items():
            setattr(self, k, v)
            if k != "_registry":
                self.get_registry()[k] = v
        self._locked = True

    def get_registry(self):
        return self.__getattribute__("_registry", override=True)

    def __getattribute__(self, name, override=False):
        if name == "_registry" and not override:
            raise AttributeError("You cannot access read_only._registry directly")

        return super().__getattribute__(name)

_locked instance-attribute

_locked = True

__getattribute__

__getattribute__(name, override=False)
Source code in FoSpy/ui/app/console.py
def __getattribute__(self, name, override=False):
    if name == "_registry" and not override:
        raise AttributeError("You cannot access read_only._registry directly")

    return super().__getattribute__(name)

__init__

__init__(**attrs)
Source code in FoSpy/ui/app/console.py
def __init__(self, **attrs):
    self._locked = True

    if "_registry" in attrs:
        raise ValueError("read_only._registry is reserved for internal use")

    self.override(_registry={},**attrs)

__setattr__

__setattr__(name, value)
Source code in FoSpy/ui/app/console.py
def __setattr__(self, name, value):
    if name == "_locked":
        if not isinstance(value, bool):
            raise TypeError(f"Expected bool for '_locked', got {type(value)}")
        return super().__setattr__(name, value)

    if name == "_registry":
        if not isinstance(value, dict):
            raise TypeError(f"Expected dict for '_registry', got {type(value)}")
        return super().__setattr__(name, value)

    if self._locked:
        raise AttributeError(f"Attribute {name} is read-only")
    super().__setattr__(name, value)

get_registry

get_registry()
Source code in FoSpy/ui/app/console.py
def get_registry(self):
    return self.__getattribute__("_registry", override=True)

override

override(**attrs)
Source code in FoSpy/ui/app/console.py
def override(self, **attrs):
    self._locked = False
    for k, v in attrs.items():
        setattr(self, k, v)
        if k != "_registry":
            self.get_registry()[k] = v
    self._locked = True

ReadOnlyRedirect

Bases: dict

Source code in FoSpy/ui/app/console.py
class ReadOnlyRedirect(dict):
    def __init__(self, read_only:ReadOnly, *args, **kwargs):

        super().__init__(*args, read_only=read_only, **kwargs)
        self.read_only = read_only

    def __getitem__(self, key):
        if key in self:
            return super().__getitem__(key)

        if hasattr(self.read_only, key):
            return getattr(self.read_only, key)

        raise KeyError(key)

read_only instance-attribute

read_only = read_only

__getitem__

__getitem__(key)
Source code in FoSpy/ui/app/console.py
def __getitem__(self, key):
    if key in self:
        return super().__getitem__(key)

    if hasattr(self.read_only, key):
        return getattr(self.read_only, key)

    raise KeyError(key)

__init__

__init__(read_only, *args, **kwargs)
Source code in FoSpy/ui/app/console.py
def __init__(self, read_only:ReadOnly, *args, **kwargs):

    super().__init__(*args, read_only=read_only, **kwargs)
    self.read_only = read_only

_clear_layout

_clear_layout(layout, delete=False)

Recursively delete all widgets, child layouts, and spacers inside a layout

Source code in FoSpy/ui/app/_utils.py
def _clear_layout(layout, delete=False):
    """Recursively delete all widgets, child layouts, and spacers inside a layout"""
    if layout is None:
        return

    while layout.count():
        item = layout.takeAt(0)

        # Case 1: widget
        widget = item.widget()
        if widget is not None:
            widget.setParent(None)
            widget.deleteLater()
            continue

        # Case 2: nested layout
        child_layout = item.layout()
        if child_layout is not None:
            _clear_layout(child_layout, delete=True)
            continue

    if delete:
        layout.setParent(None)
        layout.deleteLater()