Skip to content

window

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

AVAILABLE_THEMES module-attribute

AVAILABLE_THEMES = (
    ["auto"] if hasattr(qdarktheme, "setup_theme") else []
)

DEFAULT_THEME module-attribute

DEFAULT_THEME = (
    cfg_theme
    if cfg_theme in AVAILABLE_THEMES
    else AVAILABLE_THEMES[0]
)

DLG_ESCAPE module-attribute

DLG_ESCAPE = Sentinel('Dialog Escape Flag', bool_val=False)

WIDGET_DATA_ROLE module-attribute

WIDGET_DATA_ROLE = Qt.ItemDataRole.UserRole + 1

WINDOW_DIMENSIONS module-attribute

WINDOW_DIMENSIONS = (1200, 900)

WINDOW_TITLE module-attribute

WINDOW_TITLE = 'FoSpy - FoS File Viewer'

cfg module-attribute

cfg = None

cfg_theme module-attribute

cfg_theme = cfg.APP.theme

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}>"

TextContentWidget

Bases: QWidget

A simple content widget to display a title and description.

Source code in FoSpy/ui/app/window.py
class TextContentWidget(QWidget):
    """A simple content widget to display a title and description."""
    def __init__(self, title, description, parent=None):
        super().__init__(parent)

        layout = QVBoxLayout(self)
        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

        label = QLabel(f"<h2>{title}</h2>")
        desc = QLabel(description)

        layout.addWidget(label)
        layout.addWidget(desc)

        self.layout = layout

layout instance-attribute

layout = layout

__init__

__init__(title, description, parent=None)
Source code in FoSpy/ui/app/window.py
def __init__(self, title, description, parent=None):
    super().__init__(parent)

    layout = QVBoxLayout(self)
    layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

    label = QLabel(f"<h2>{title}</h2>")
    desc = QLabel(description)

    layout.addWidget(label)
    layout.addWidget(desc)

    self.layout = layout

_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