From c8af239fc9e2a57cef96ac828cb60322a9a3182d Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Tue, 4 Nov 2025 08:09:44 -0800 Subject: [PATCH 01/10] Overhaul workbox tabs - Supporting linking files - Support backing up workbox files, with ability to scroll forward and backward thru previous versions. Only workboxes / prefs with changes are saved - Colorize tabs indicating state (with toolTips) - Support auto-sync between multiple instances of PrEditor in same core - Support updating prefs args / values based on json file - Enforce unique tab names --- preditor/gui/drag_tab_bar.py | 342 +++++++++++++- preditor/gui/group_tab_widget/__init__.py | 208 +++++++-- .../group_tab_widget/grouped_tab_models.py | 1 - .../group_tab_widget/grouped_tab_widget.py | 88 +++- .../gui/group_tab_widget/one_tab_widget.py | 2 - preditor/gui/loggerwindow.py | 432 ++++++++++++++++-- preditor/gui/ui/loggerwindow.ui | 49 +- preditor/gui/workbox_mixin.py | 350 ++++++++++++-- preditor/gui/workbox_text_edit.py | 13 +- preditor/gui/workboxwidget.py | 78 +++- preditor/prefs.py | 301 ++++++++++++ .../resource/pref_updates/pref_updates.json | 11 + preditor/resource/stylesheet/Bright.css | 11 + preditor/resource/stylesheet/Dark.css | 11 + preditor/scintilla/documenteditor.py | 85 ++-- 15 files changed, 1779 insertions(+), 203 deletions(-) create mode 100644 preditor/resource/pref_updates/pref_updates.json diff --git a/preditor/gui/drag_tab_bar.py b/preditor/gui/drag_tab_bar.py index a9287e30..8e104437 100644 --- a/preditor/gui/drag_tab_bar.py +++ b/preditor/gui/drag_tab_bar.py @@ -1,8 +1,38 @@ from __future__ import absolute_import +from functools import partial +from pathlib import Path + +import six from Qt.QtCore import QByteArray, QMimeData, QPoint, QRect, Qt -from Qt.QtGui import QCursor, QDrag, QPixmap, QRegion -from Qt.QtWidgets import QInputDialog, QMenu, QTabBar +from Qt.QtGui import QColor, QCursor, QDrag, QPainter, QPalette, QPixmap, QRegion +from Qt.QtWidgets import ( + QApplication, + QFileDialog, + QInputDialog, + QMenu, + QStyle, + QStyleOptionTab, + QTabBar, +) + +from preditor import osystem + +from . import QtPropertyInit + + +class TabStates: + """Nice names for the Tab states for coloring""" + + Normal = 0 + Linked = 1 + Changed = 2 + ChangedLinked = 3 + Orphaned = 4 + OrphanedLinked = 5 + Dirty = 6 + DirtyLinked = 7 + MissingLinked = 8 class DragTabBar(QTabBar): @@ -12,14 +42,21 @@ class DragTabBar(QTabBar): In most cases you should use `install_tab_widget` to create and add this TabBar to a QTabWidget. It takes care of enabling usability features of QTabWidget's. - Args: - mime_type (str, optional): Only accepts dropped tabs that implement this - Mime Type. Tabs dragged off of this TabBar will have this Mime Type - implemented. - Based on code by ARussel: https://forum.qt.io/post/420469 + """ + # These Qt Properties can be customized using style sheets. + normalColor = QtPropertyInit('_normalColor', QColor("lightgrey")) + linkedColor = QtPropertyInit('_linkedColor', QColor("turquoise")) + missingLinkedColor = QtPropertyInit('_missingLinkedColor', QColor("red")) + dirtyColor = QtPropertyInit('_dirtyColor', QColor("yellow")) + dirtyLinkedColor = QtPropertyInit('_dirtyLinkedColor', QColor("goldenrod")) + changedColor = QtPropertyInit('_changedColor', QColor("darkorchid")) + changedLinkedColor = QtPropertyInit('_changedLinkedColor', QColor("darkviolet")) + orphanedColor = QtPropertyInit('_orphanedColor', QColor("crimson")) + orphanedLinkedColor = QtPropertyInit('_orphanedLinkedColor', QColor("firebrick")) + def __init__(self, parent=None, mime_type='DragTabBar'): super(DragTabBar, self).__init__(parent=parent) self.setAcceptDrops(True) @@ -28,8 +65,148 @@ def __init__(self, parent=None, mime_type='DragTabBar'): self._context_menu_tab = -1 self.mime_type = mime_type - def mouseMoveEvent(self, event): # noqa: N802 + self.fg_color_map = {} + self.bg_color_map = {} + + def updateColors(self): + """This cannot be called during __init__, otherwise all bg colors will + be default, and not read from the style sheet. So instead, the first + time we need self.bg_color_map, we check if it has values, and call this + method if it doesn't. + """ + self.bg_color_map = { + TabStates.Normal: self.normalColor, + TabStates.Changed: self.changedColor, + TabStates.ChangedLinked: self.changedLinkedColor, + TabStates.Orphaned: self.orphanedColor, + TabStates.OrphanedLinked: self.orphanedLinkedColor, + TabStates.Dirty: self.dirtyColor, + TabStates.DirtyLinked: self.dirtyLinkedColor, + TabStates.Linked: self.linkedColor, + TabStates.MissingLinked: self.missingLinkedColor, + } + self.fg_color_map = { + "0": "white", + "1": "black", + } + + def get_color_and_tooltip(self, index): + """Determine the color and tooltip based on the state of the workbox. + + Args: + index (int): The index of the tab holding the workbox + + Returns: + color, toolTip (QColor, str): The QColor and toolTip string to apply + to the tab being painted + """ + state = TabStates.Normal + toolTip = "" + if self.parent(): + widget = self.parent().widget(index) + + filename = None + if hasattr(widget, "text"): + filename = widget.__filename__() or widget._filename_pref + + if widget.__changed_by_instance__(): + if filename: + state = TabStates.ChangedLinked + toolTip = ( + "Linked workbox has been updated by saving in another " + "PrEditor and has had unsaved changes auto-saved to a" + " previous version.\nAccess with Ctrl-Alt-[ shortcut." + ) + else: + state = TabStates.Changed + toolTip = ( + "Workbox has been updated by saving in another PrEditor " + "instance, and has had it's unsaved changes auto-saved to " + "a previous version.\nAccess with Ctrl-Alt-[ shortcut." + ) + elif widget.__orphaned_by_instance__(): + if filename: + state = TabStates.OrphanedLinked + toolTip = ( + "Linked workbox is either newly added, or orphaned by " + "saving in another PrEditor instance" + ) + else: + state = TabStates.Orphaned + toolTip = ( + "Workbox is either newly added, or orphaned by " + "saving in another PrEditor instance" + ) + elif widget.__is_dirty__(): + if filename: + state = TabStates.DirtyLinked + toolTip = "Linked workbox has unsaved changes." + else: + state = TabStates.Dirty + toolTip = "Workbox has unsaved changes, or it's name has changed." + elif filename: + if Path(filename).is_file(): + state = TabStates.Linked + toolTip = "Linked to file on disk" + else: + state = TabStates.MissingLinked + toolTip = "Linked file is missing" + + if hasattr(widget, "__workbox_id__"): + workbox_id = widget.__workbox_id__() + toolTip += "\n\n{}".format(workbox_id) + + color = self.bg_color_map.get(state) + return color, toolTip + + def paintEvent(self, event): + """Override of .paintEvent to handle custom tab colors and toolTips. + + If self.bg_color_map has not yet been populated, do so by calling + self.updateColors(). We do not call self.updateColor in this class's + __init__ because the QtPropertyInit won't be able to read the properties + from the stylesheet at that point. + + Args: + event (QEvent): The event passed to this event handler. + """ + if not self.bg_color_map: + self.updateColors() + + style = self.style() + painter = QPainter(self) + option = QStyleOptionTab() + + isLight = self.normalColor.value() >= 128 + + # Update the the parent GroupTabWidget + self.parent().parent().parent().update() + + for index in range(self.count()): + # color_name, toolTip = self.get_color_and_tooltip(index) + color, toolTip = self.get_color_and_tooltip(index) + self.setTabToolTip(index, toolTip) + + # Get colors + # color = QColor(color_name) + if isLight: + fillColor = color.lighter(175) + color = color.darker(250) + else: + fillColor = color.darker(250) + color = color.lighter(175) + # Pick white or black for text, based on lightness of fillColor + fg_idx = int(fillColor.value() >= 128) + fg_color_name = self.fg_color_map.get(str(fg_idx)) + fg_color = QColor(fg_color_name) + + self.initStyleOption(option, index) + option.palette.setColor(QPalette.ColorRole.WindowText, fg_color) + option.palette.setColor(QPalette.ColorRole.Window, color) + option.palette.setColor(QPalette.ColorRole.Button, fillColor) + style.drawControl(QStyle.ControlElement.CE_TabBarTab, option, painter) + def mouseMoveEvent(self, event): # noqa: N802 if not self._mime_data: return super(DragTabBar, self).mouseMoveEvent(event) @@ -143,6 +320,8 @@ def rename_tab(self): name, success = QInputDialog.getText(self, 'Rename Tab', msg, text=current) name = self.parent().get_next_available_tab_name(name) + if not name.strip(): + return if success: self.setTabText(self._context_menu_tab, name) @@ -155,18 +334,159 @@ def tab_menu(self, pos, popup=True): This method sets the tab index the user right clicked on in the variable `_context_menu_tab`. This can be used in the triggered QAction methods.""" - self._context_menu_tab = self.tabAt(pos) + index = self.tabAt(pos) + self._context_menu_tab = index if self._context_menu_tab == -1: return menu = QMenu(self) - act = menu.addAction('Rename') - act.triggered.connect(self.rename_tab) + + grouped_tab = self.parentWidget() + workbox = grouped_tab.widget(self._context_menu_tab) + + # Show File-related actions depending if filename already set. Don't include + # Rename if the workbox is linked to a file. + if hasattr(workbox, 'filename'): + if not workbox.filename(): + act = menu.addAction('Rename') + act.triggered.connect(self.rename_tab) + + act = menu.addAction('Link File') + act.triggered.connect(partial(self.link_file, workbox)) + + act = menu.addAction('Save and Link File') + act.triggered.connect(partial(self.save_and_link_file, workbox)) + else: + if Path(workbox.filename()).is_file(): + act = menu.addAction('Explore File') + act.triggered.connect(partial(self.explore_file, workbox)) + + act = menu.addAction('Unlink File') + act.triggered.connect(partial(self.unlink_file, workbox)) + + act = menu.addAction('Save As') + act.triggered.connect(partial(self.save_and_link_file, workbox)) + else: + act = menu.addAction('Explore File') + act.triggered.connect(partial(self.explore_file, workbox)) + + act = menu.addAction('Re-link File') + act.triggered.connect(partial(self.link_file, workbox)) + + act = menu.addAction('Unlink File') + act.triggered.connect(partial(self.unlink_file, workbox)) + else: + act = menu.addAction('Rename') + act.triggered.connect(self.rename_tab) + + act = menu.addAction('Copy Workbox Name') + act.triggered.connect(partial(self.copy_workbox_name, workbox, index)) + + act = menu.addAction('Copy Workbox Id') + act.triggered.connect(partial(self.copy_workbox_id, workbox, index)) if popup: menu.popup(self.mapToGlobal(pos)) return menu + def link_file(self, workbox): + """Link the given workbox to a file on disk. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + """ + filename = workbox.filename() + filename, _other = QFileDialog.getOpenFileName(directory=filename) + if filename and Path(filename).is_file(): + workbox.__load__(filename) + workbox._filename_pref = filename + workbox._filename = filename + name = Path(filename).name + + self.setTabText(self._context_menu_tab, name) + self.update() + self.window().setWorkboxFontBasedOnConsole(workbox=workbox) + + workbox.__save_prefs__(saveLinkedFile=False, force=True) + + def save_and_link_file(self, workbox): + """Save the given workbox as a file on disk, and link the workbox to it. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + """ + filename = workbox.filename() + directory = six.text_type(Path(filename).parent) if filename else "" + success = workbox.saveAs(directory=directory) + if not success: + return + + filename = workbox.filename() + workbox._filename_pref = filename + workbox._filename = filename + workbox.__set_last_workbox_name__(workbox.__workbox_name__()) + name = Path(filename).name + + self.setTabText(self._context_menu_tab, name) + self.update() + self.window().setWorkboxFontBasedOnConsole(workbox=workbox) + + def explore_file(self, workbox): + """Open a system file explorer at the path of the linked file. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + """ + path = Path(workbox._filename_pref) + if path.exists(): + osystem.explore(str(path)) + elif path.parent.exists(): + osystem.explore(str(path.parent)) + + def unlink_file(self, workbox): + """Disconnect a file link. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + """ + workbox.updateFilename("") + workbox._filename_pref = "" + + name = self.parent().default_title + self.setTabText(self._context_menu_tab, name) + + def copy_workbox_name(self, workbox, index): + """Copy the workbox name to clipboard. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + index (index): The index of the clicked tab + """ + try: + name = workbox.__workbox_name__() + except AttributeError: + group = self.parent().widget(index) + curIndex = group.currentIndex() + workbox = group.widget(curIndex) + name = workbox.__workbox_name__() + QApplication.clipboard().setText(name) + + def copy_workbox_id(self, workbox, index): + """Copy the workbox id to clipboard. + + Args: + workbox (WorkboxMixin): The workbox contained in the clicked tab + index (index): The index of the clicked tab + """ + try: + workbox_id = workbox.__workbox_id__() + except AttributeError: + group = self.parent().widget(index) + curIndex = group.currentIndex() + workbox = group.widget(curIndex) + workbox_id = workbox.__workbox_id__() + QApplication.clipboard().setText(workbox_id) + @classmethod def install_tab_widget(cls, tab_widget, mime_type='DragTabBar', menu=True): """Creates and returns a instance of DragTabBar and installs it on the diff --git a/preditor/gui/group_tab_widget/__init__.py b/preditor/gui/group_tab_widget/__init__.py index 49a09fc5..a5e90a5f 100644 --- a/preditor/gui/group_tab_widget/__init__.py +++ b/preditor/gui/group_tab_widget/__init__.py @@ -1,13 +1,13 @@ from __future__ import absolute_import -import os +from pathlib import Path from Qt.QtCore import Qt from Qt.QtGui import QIcon from Qt.QtWidgets import QHBoxLayout, QMessageBox, QToolButton, QWidget from ... import resourcePath -from ...prefs import prefs_path +from ...prefs import VersionTypes, get_backup_version_info from ..drag_tab_bar import DragTabBar from ..workbox_text_edit import WorkboxTextEdit from .grouped_tab_menu import GroupTabMenu @@ -87,26 +87,27 @@ def add_new_tab(self, group, title=None, prefs=None): GroupedTabWidget: The tab group for this group. WorkboxMixin: The new text editor. """ - parent = None if not group: group = self.get_next_available_tab_name(self.default_title) elif group is True: group = self.currentIndex() + + parent = None if isinstance(group, int): group_title = self.tabText(group) parent = self.widget(group) elif isinstance(group, str): - group_title = group.replace(" ", "_") + group_title = group index = self.index_for_text(group) if index != -1: parent = self.widget(index) if not parent: - parent, group_title = self.default_tab(group_title) + parent, group_title = self.default_tab(group_title, prefs) self.addTab(parent, group_title) # Create the first editor tab and make it visible - editor = parent.add_new_editor(title) + editor = parent.add_new_editor(title, prefs) self.setCurrentIndex(self.indexOf(parent)) self.window().focusToWorkbox() return parent, editor @@ -143,13 +144,6 @@ def close_tab(self, index): QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, ) if ret == QMessageBox.StandardButton.Yes: - # Clean up all temp files created by this group's editors if they - # are not using actual saved files. - tab_widget = self.widget(self.currentIndex()) - for editor_index in range(tab_widget.count()): - editor = tab_widget.widget(editor_index) - editor.__remove_tempfile__() - super(GroupTabWidget, self).close_tab(index) def current_groups_widget(self): @@ -168,6 +162,64 @@ def default_tab(self, title=None, prefs=None): ) return widget, title + def append_orphan_workboxes_to_prefs(self, prefs, existing_by_group): + """If prefs are saved in a different PrEditor instance (in this same core) + there may be a workbox which is either: + - new in this instance + - removed in the saved other instance + Any of these workboxes are 'orphaned'. Rather than just deleting it, we + alert the user, so that work can be saved. + + We also add any orphan workboxes to the window's boxesOrphanedViaInstance + dict, in the form `workbox_id: workbox`. + + Args: + prefs (dict): The 'workboxes' section of the PrEditor prefs + existing_by_group (dict): The existing workbox's info (as returned + by self.all_widgets(), by group. + + Returns: + prefs (dict): The 'workboxes' section of the PrEditor prefs, updated + """ + groups = prefs.get("groups") + for group_name, workbox_infos in existing_by_group.items(): + prefs_group = None + for temp_group in groups: + temp_name = temp_group.get("name") + if temp_name == group_name: + prefs_group = temp_group + break + + # If the orphan's group doesn't yet exist, we prepare to make it + new_group = None + if not prefs_group: + new_group = dict(name=group_name, tabs=[]) + + cur_group = prefs_group or new_group + cur_tabs = cur_group.get("tabs") + + for workbox_info in workbox_infos: + # Create workbox_dict + workbox = workbox_info[0] + name = workbox_info[2] + + workbox_id = workbox.__workbox_id__() + + workbox_dict = dict( + name=name, + workbox_id=workbox_id, + filename=workbox.__filename__(), + backup_file=workbox.__backup_file__(), + orphaned_by_instance=True, + ) + + self.window().boxesOrphanedViaInstance[workbox_id] = workbox + + cur_tabs.append(workbox_dict) + if new_group: + groups.append(cur_group) + return prefs + def restore_prefs(self, prefs): """Adds tab groups and tabs, restoring the selected tabs. If a tab is linked to a file that no longer exists, will not be added. Restores the @@ -189,16 +241,16 @@ def restore_prefs(self, prefs): "filename": "C:\\temp\\invalid_asdfdfd.py", // Name of the editor's tab [Optional] "name": "invalid_asdfdfd.py", - "tempfile": null + "workbox_id": null }, { // This tab should be active for the group. "current": true, "filename": null, "name": "Workbox", - // If tempfile is not null, this file is loaded. + // If workbox_id is not null, this file is loaded. // Ignored if filename is not null. - "tempfile": "workbox_2yrwctco_a.py" + "workbox_id": "workbox_2yrwctco_a.py" } ] } @@ -206,16 +258,46 @@ def restore_prefs(self, prefs): } ``` """ + selected_workbox_id = None + current_workbox = self.window().current_workbox() + if current_workbox: + selected_workbox_id = current_workbox.__workbox_id__() + + # When re-running restore_prefs (ie after another instance saved + # workboxes, and we are reloading them here, get the workbox_ids of all + # workboxes defined in prefs + pref_workbox_ids = [] + for group in prefs.get('groups', []): + for tab in group.get('tabs', []): + pref_workbox_ids.append(tab.get("workbox_id", None)) + + # Collect data about workboxes which already exist (if we are re-running + # this method after workboxes exist, ie another PrEditor instance has + # changed contents and we are now matching those changes. + existing_by_id = {} + existing_by_group = {} + for workbox_info in list(self.all_widgets()): + workbox = workbox_info[0] + workbox_id = workbox.__workbox_id__() + group_name = workbox_info[1] + existing_by_id[workbox.__workbox_id__()] = workbox_info + + # If we had a workbox, but what we are about to load doesn't include + # it, add it back in so it will be shown. + if workbox_id not in pref_workbox_ids: + existing_by_group.setdefault(group_name, []).append(workbox_info) + + prefs = self.append_orphan_workboxes_to_prefs(prefs, existing_by_group) self.clear() - workbox_dir = prefs_path('workboxes', core_name=self.core_name) current_group = None + workboxes_missing_id = [] for group in prefs.get('groups', []): current_tab = None - group_name = group['name'] tab_widget = None + group_name = group['name'] group_name = self.get_next_available_tab_name(group_name) for tab in group.get('tabs', []): @@ -227,22 +309,60 @@ def restore_prefs(self, prefs): # preferences save. # By not restoring tabs for deleted files we prevent accidentally # restoring a tab with empty text. - filename = tab.get('filename') - temp_name = tab.get('tempfile') - if filename: - if not os.path.exists(filename): + + loadable = False + + name = tab['name'] + + workbox_id = tab.get('workbox_id', None) + # If user went back to before PrEditor used workbox_id, and + # back, the workbox may not be loadable. First, try to recover + # it from the backup_file. If not recoverable, collect and + # notify user. + if workbox_id is None: + bak_file = tab.get('backup_file', None) + if bak_file: + workbox_id = str(Path(bak_file).parent) + else: + missing_name = f"{group_name}/{name}" + workboxes_missing_id.append(missing_name) continue - if not temp_name: - continue - temp_name = os.path.join(workbox_dir, temp_name) - if not os.path.exists(temp_name): + + orphaned_by_instance = tab.get('orphaned_by_instance', False) + + # Support legacy arg for emergency backwards compatibility + tempfile = tab.get('tempfile', None) + # Get various possible saved filepaths. + filename_pref = tab.get('filename', "") + if filename_pref: + if Path(filename_pref).is_file(): + loadable = True + + # See if there are any workbox backups available + backup_file, _, count = get_backup_version_info( + self.window().name, workbox_id, VersionTypes.Last, "" + ) + if count: + loadable = True + if not loadable: continue # There is a file on disk, add the tab, creating the group # tab if it hasn't already been created. - name = tab['name'] - tab_widget, editor = self.add_new_tab(group_name, name) - editor.__restore_prefs__(tab) + prefs = dict( + workbox_id=workbox_id, + filename=filename_pref, + backup_file=backup_file, + existing_editor_info=existing_by_id.pop(workbox_id, None), + orphaned_by_instance=orphaned_by_instance, + tempfile=tempfile, + ) + tab_widget, editor = self.add_new_tab( + group_name, title=name, prefs=prefs + ) + + editor.__set_last_workbox_name__(editor.__workbox_name__()) + editor.__determine_been_changed_by_instance__() # If more than one tab in this group is listed as current, only # respect the first @@ -265,6 +385,29 @@ def restore_prefs(self, prefs): if current_group is None and group.get('current'): current_group = self.indexOf(tab_widget) + if selected_workbox_id: + for widget_info in self.all_widgets(): + widget, _, _, group_idx, tab_idx = widget_info + if widget.__workbox_id__() == selected_workbox_id: + self.setCurrentIndex(group_idx) + grouped = self.widget(group_idx) + grouped.setCurrentIndex(tab_idx) + break + + # If any workboxes could not be loaded because they had no stored + # workbox_id, notify user. This likely only happens if user goes back + # to older PrEditor, and back. + if workboxes_missing_id: + suffix = "" if len(workboxes_missing_id) == 1 else "es" + workboxes_missing_id.insert(0, "") + missing_names = "\n\t".join(workboxes_missing_id) + msg = ( + f"The following workbox{suffix} somehow did not have a " + f"workbox_id stored, and therefore could not be loaded:" + f"{missing_names}" + ) + print(msg) + # Restore the current group for this widget if current_group is None: # If there is no longer a current tab, default to the first tab @@ -292,11 +435,8 @@ def save_prefs(self, prefs=None): current_editor = tab_widget.currentIndex() for j in range(tab_widget.count()): current = True if j == current_editor else None - tabs.append( - tab_widget.widget(j).__save_prefs__( - name=tab_widget.tabText(j), current=current - ) - ) + workbox = tab_widget.widget(j) + tabs.append(workbox.__save_prefs__(current=current)) groups.append(group) diff --git a/preditor/gui/group_tab_widget/grouped_tab_models.py b/preditor/gui/group_tab_widget/grouped_tab_models.py index dda635d3..e02b884d 100644 --- a/preditor/gui/group_tab_widget/grouped_tab_models.py +++ b/preditor/gui/group_tab_widget/grouped_tab_models.py @@ -88,7 +88,6 @@ def setFuzzySearch(self, search): def filterAcceptsRow(self, sourceRow, sourceParent): if self.filterKeyColumn() == 0 and self._fuzzy_regex: - index = self.sourceModel().index(sourceRow, 0, sourceParent) data = self.sourceModel().data(index) ret = bool(self._fuzzy_regex.search(data)) diff --git a/preditor/gui/group_tab_widget/grouped_tab_widget.py b/preditor/gui/group_tab_widget/grouped_tab_widget.py index aad06189..f8b3a4f4 100644 --- a/preditor/gui/group_tab_widget/grouped_tab_widget.py +++ b/preditor/gui/group_tab_widget/grouped_tab_widget.py @@ -5,6 +5,7 @@ from Qt.QtWidgets import QMessageBox, QToolButton from ... import resourcePath +from ...prefs import VersionTypes from ..drag_tab_bar import DragTabBar from ..workbox_text_edit import WorkboxTextEdit from .one_tab_widget import OneTabWidget @@ -29,11 +30,63 @@ def __init__(self, editor_kwargs, editor_cls=None, core_name=None, *args, **kwar self.default_title = "Workbox01" + def __tab_widget__(self): + """Return the tab widget which contains this group + + Returns: + GroupTabWidget: The tab widget which contains this group + """ + return self.parent().parent() + + def __changed_by_instance__(self): + """Returns if any of this groups editors have been changed by another + PrEditor instance's prefs save. + + Returns: + changed (bool) + """ + changed = False + for workbox_idx in range(self.count()): + workbox = self.widget(workbox_idx) + if workbox.__changed_by_instance__(): + changed = True + break + return changed + + def __orphaned_by_instance__(self): + """Returns if any of this groups editors have been orphaned by another + PrEditor instance's prefs save. + + Returns: + orphaned (bool) + """ + orphaned = False + for workbox_idx in range(self.count()): + workbox = self.widget(workbox_idx) + if workbox.__orphaned_by_instance__(): + orphaned = True + break + return orphaned + + def __is_dirty__(self): + """Returns if any of this groups editors are dirty. + + Returns: + is_dirty (bool) + """ + is_dirty = False + for workbox_idx in range(self.count()): + workbox = self.widget(workbox_idx) + if workbox.__is_dirty__(): + is_dirty = True + break + return is_dirty + def add_new_editor(self, title=None, prefs=None): title = title or self.default_title title = self.get_next_available_tab_name(title) - editor, title = self.default_tab(title) + editor, title = self.default_tab(title, prefs=prefs) index = self.addTab(editor, title) self.setCurrentIndex(index) return editor @@ -66,15 +119,42 @@ def close_tab(self, index): ) if ret == QMessageBox.StandardButton.Yes: # If the tab was saved to a temp file, remove it from disk - editor = self.widget(index) - editor.__remove_tempfile__() + _editor = self.widget(index) # noqa: F841 + # Keep track of deleted tabs, make re-openable + # Maybe also move workbox dir to a 'removed workboxes' dir super(GroupedTabWidget, self).close_tab(index) def default_tab(self, title=None, prefs=None): title = title or self.default_title kwargs = self.editor_kwargs if self.editor_kwargs else {} - editor = self.editor_cls(parent=self, core_name=self.core_name, **kwargs) + editor = None + orphaned_by_instance = False + if prefs: + editor_info = prefs.pop("existing_editor_info", None) + if editor_info: + editor = editor_info[0] + orphaned_by_instance = prefs.pop("orphaned_by_instance", False) + else: + prefs = {} + + if editor: + editor.__load_workbox_version_text__(VersionTypes.Last) + + editor.__set_tab_widget__(self) + editor.__set_last_saved_text__(editor.text()) + editor.__set_last_workbox_name__(editor.__workbox_name__()) + + filename = prefs.get("filename", None) + editor.__set_filename__(filename) + + editor.__determine_been_changed_by_instance__() + self.window().setWorkboxFontBasedOnConsole(editor) + else: + editor = self.editor_cls( + parent=self, core_name=self.core_name, **prefs, **kwargs + ) + editor.__set_orphaned_by_instance__(orphaned_by_instance) return editor, title def showEvent(self, event): # noqa: N802 diff --git a/preditor/gui/group_tab_widget/one_tab_widget.py b/preditor/gui/group_tab_widget/one_tab_widget.py index f677b67b..1ba1ad57 100644 --- a/preditor/gui/group_tab_widget/one_tab_widget.py +++ b/preditor/gui/group_tab_widget/one_tab_widget.py @@ -30,8 +30,6 @@ def get_next_available_tab_name(self, name): Returns: name (str): The name, or updated name if needed """ - name = name.replace(" ", "_") - existing_names = [self.tabText(i) for i in range(self.count())] # Use regex to find the last set of digits. If found, the base name is diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index b4e47d6a..8e190e60 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -1,20 +1,23 @@ from __future__ import absolute_import, print_function +import copy import itertools import json import logging import os import re +import shutil import sys import warnings from builtins import bytes from datetime import datetime, timedelta from functools import partial +from pathlib import Path import __main__ import Qt as Qt_py from Qt import QtCompat, QtCore, QtWidgets -from Qt.QtCore import QByteArray, Qt, QTimer, Signal, Slot +from Qt.QtCore import QByteArray, QFileSystemWatcher, Qt, QTimer, Signal, Slot from Qt.QtGui import QCursor, QFont, QIcon, QKeySequence, QTextCursor from Qt.QtWidgets import ( QApplication, @@ -52,6 +55,9 @@ logger = logging.getLogger(__name__) +PRUNE_PATTERN = r"(?P\w*)-{}\.".format(prefs.DATETIME_PATTERN.pattern) +PRUNE_PATTERN = re.compile(PRUNE_PATTERN) + class WorkboxPages: """Nice names for the uiWorkboxSTACK indexes.""" @@ -67,6 +73,9 @@ class LoggerWindow(Window): def __init__(self, parent, name=None, run_workbox=False, standalone=False): super(LoggerWindow, self).__init__(parent=parent) self.name = name if name else get_core_name() + + self._logToFilePath = None + self._stylesheet = 'Bright' self.setupStatusTimer() @@ -94,7 +103,7 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): # create the workbox tabs self._currentTab = -1 - self._reloadRequested = set() + # Setup delayable system self.delayable_engine = DelayableEngine.instance('logger', self) @@ -198,6 +207,7 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiNextTabACT.triggered.connect(self.nextTab) self.uiPrevTabACT.triggered.connect(self.prevTab) + # Navigate workbox versions self.uiTab1ACT.triggered.connect(partial(self.gotoTabByIndex, 1)) self.uiTab2ACT.triggered.connect(partial(self.gotoTabByIndex, 2)) self.uiTab3ACT.triggered.connect(partial(self.gotoTabByIndex, 3)) @@ -220,6 +230,9 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiRunFirstWorkboxACT.triggered.connect(self.run_first_workbox) + self.latestTimeStrsForBoxesChangedViaInstance = {} + self.boxesOrphanedViaInstance = {} + self.uiFocusNameACT.triggered.connect(self.show_focus_name) self.uiCommentToggleACT.triggered.connect(self.comment_toggle) @@ -253,6 +266,7 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): for menu in menus: menu.hovered.connect(self.handleMenuHovered) + """Set various icons""" self.uiClearLogACT.setIcon(QIcon(resourcePath('img/close-thick.png'))) self.uiNewWorkboxACT.setIcon(QIcon(resourcePath('img/file-plus.png'))) self.uiCloseWorkboxACT.setIcon(QIcon(resourcePath('img/file-remove.png'))) @@ -272,6 +286,11 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.loadPlugins() self.setWindowTitle(self.defineWindowTitle()) + # Start the filesystem monitor + self.openFileMonitor = QFileSystemWatcher(self) + self.openFileMonitor.fileChanged.connect(self.linkedFileChanged) + self.setFileMonitoringEnabled(self.prefsPath(), True) + self.restorePrefs() # add stylesheet menu options. @@ -287,6 +306,20 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.setWorkboxFontBasedOnConsole() self.setEditorChooserFontBasedOnConsole() + # Scroll thru workbox versions + self.uiShowFirstWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.First) + ) + self.uiShowPreviousWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Previous) + ) + self.uiShowNextWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Next) + ) + self.uiShowLastWorkboxVersionACT.triggered.connect( + partial(self.change_to_workbox_version_text, prefs.VersionTypes.Last) + ) + self.setup_run_workbox() if not standalone: @@ -310,12 +343,13 @@ def apply_options(self): self.uiEditorChooserWGT.editor_name() ) if editor_cls_name is None: + self.update_workbox_stack() return if editor_cls_name != self.editor_cls_name: self.editor_cls_name = editor_cls_name self.uiWorkboxTAB.editor_cls = editor_cls # We need to change the editor, save all prefs - self.recordPrefs() + self.recordPrefs(manual=True) # Clear the uiWorkboxTAB self.uiWorkboxTAB.clear() # Restore prefs to populate the tabs @@ -439,6 +473,35 @@ def workbox_for_name(cls, name, show=False, visible=False): return workbox + def workbox_for_id(self, workbox_id, show=False, visible=False): + """Used to find a workbox for a given id. + + Args: + workbox_id(str): The workbox id for which to match when searching + for the workbox + show (bool, optional): If a workbox is found, call `__show__` on it + to ensure that it is initialized and its text is loaded. + visible (bool, optional): Make the this workbox visible if found. + """ + # pred = self.instance() + workbox = None + for box_info in self.uiWorkboxTAB.all_widgets(): + temp_box = box_info[0] + if temp_box.__workbox_id__() == workbox_id: + workbox = temp_box + break + + if workbox: + if show: + workbox.__show__() + if visible: + grp_idx, tab_idx = workbox.__group_tab_index__() + self.uiWorkboxTAB.setCurrentIndex(grp_idx) + group = self.uiWorkboxTAB.widget(grp_idx) + group.setCurrentIndex(tab_idx) + + return workbox + def run_first_workbox(self): workbox = self.uiWorkboxTAB.widget(0).widget(0) self.run_workbox("", workbox=workbox) @@ -482,6 +545,60 @@ def setup_run_workbox(self): """ __main__.run_workbox = self.run_workbox + def change_to_workbox_version_text(self, versionType): + """Change the current workbox's text to a previously saved version, based + on versionType, which can be First, Previous, Next, SecondToLast, or Last. + + If we are already at the start or end of the stack of files, and trying + to go further, do nothing. + + Args: + versionType (prefs.VersionTypes): Enum describing which version to + fetch + + """ + tab_group = self.uiWorkboxTAB.currentWidget() + + workbox_widget = tab_group.currentWidget() + + idx, count = prefs.get_backup_file_index_and_count( + self.name, + workbox_widget.__workbox_id__(), + backup_file=workbox_widget.__backup_file__(), + ) + + # For ease of reading, set these variables. + forFirst = versionType == prefs.VersionTypes.First + forPrevious = versionType == prefs.VersionTypes.Previous + forNext = versionType == prefs.VersionTypes.Next + forLast = versionType == prefs.VersionTypes.Last + isFirstWorkbox = idx is None or idx == 0 + isLastWorkbox = idx is None or idx + 1 == count + isDirty = workbox_widget.__is_dirty__() + + # If we are on last workbox and it's dirty, do the user a solid, and + # save any thing they've typed. + if isLastWorkbox and isDirty: + workbox_widget.__save_prefs__() + isFirstWorkbox = False + + # If we are at either end of stack, and trying to go further, do nothing + if isFirstWorkbox and (forFirst or forPrevious): + return + if isLastWorkbox and (forNext or forLast): + return + + filename, idx, count = workbox_widget.__load_workbox_version_text__(versionType) + + # Get rid of the hash part of the filename + match = prefs.DATETIME_PATTERN.search(filename) + if match: + filename = match.group() + + txt = "{} [{}/{}]".format(filename, idx, count) + self.setStatusText(txt) + self.autoHideStatusText() + def openSetPreferredTextEditorDialog(self): dlg = SetTextEditorPathDialog(parent=self) dlg.exec() @@ -651,17 +768,17 @@ def setFontSize(self, newSize): self.setWorkboxFontBasedOnConsole() self.setEditorChooserFontBasedOnConsole() - def setWorkboxFontBasedOnConsole(self): + def setWorkboxFontBasedOnConsole(self, workbox=None): """If the current workbox's font is different to the console's font, set it to match. """ font = self.console().font() - workboxGroup = self.uiWorkboxTAB.currentWidget() - if workboxGroup is None: - return - - workbox = workboxGroup.currentWidget() + if workbox is None: + workboxGroup = self.uiWorkboxTAB.currentWidget() + if workboxGroup is None: + return + workbox = workboxGroup.currentWidget() if workbox is None: return @@ -710,6 +827,165 @@ def clearLogToFile(self): if self._stds: self._stds[0].clear(stamp=True) + def prune_backup_files(self, sub_dir=None): + """Prune the backup files to uiMaxNumBackupsSPIN value, per workbox + + Args: + sub_dir (str, optional): The subdir to operate on. + """ + if sub_dir is None: + sub_dir = 'workboxes' + + directory = Path(prefs.prefs_path(sub_dir, core_name=self.name)) + files = list(directory.rglob("*.*")) + + files_by_name = {} + for file in files: + match = PRUNE_PATTERN.search(str(file)) + if not match: + continue + name = match.groupdict().get("name") + + parent = file.parent.name + name = parent + "/" + name + files_by_name.setdefault(name, []).append(file) + + for _name, files in files_by_name.items(): + files.sort(key=lambda f: str(f).lower()) + files.reverse() + for file in files[self.max_num_backups :]: + file.unlink() + + # Remove any empty directories + for file in directory.iterdir(): + if not file.is_dir(): + continue + + # rmdir only operates on empty dirs. Try / except is faster than + # getting number of files, ie len(list(file.iterdir())) + try: + file.rmdir() + except OSError: + pass + + def getBoxesChangedByInstance(self, timeOffset=0.05): + """If a separate PrEditor instance has saved it's prefs, and we are now + updating this instances, determine which workboxes have been changed. + If we find some that are, save it, but fake the timestamp to be juuust + before the new one. This way, the user retains unsaved work, and can + still browse to the version the workbox contents. + + Args: + timeOffset (float, optional): Description + """ + self.latestTimeStrsForBoxesChangedViaInstance = {} + + for editor_info in self.uiWorkboxTAB.all_widgets(): + editor, group_name, tab_name, group_idx, tab_idx = editor_info + if not editor.__is_dirty__(): + continue + + core_name = self.name + workbox_id = editor.__workbox_id__() + versionType = prefs.VersionTypes.Last + latest_filepath, idx, count = prefs.get_backup_version_info( + core_name, workbox_id, versionType + ) + latest_filepath = prefs.get_relative_path(self.name, latest_filepath) + + if latest_filepath != editor.__backup_file__(): + stem = Path(latest_filepath).stem + match = prefs.DATETIME_PATTERN.search(stem) + if not match: + continue + + datetimeStr = match.group() + origStamp = datetime.strptime(datetimeStr, prefs.DATETIME_FORMAT) + + newStamp = origStamp - timedelta(seconds=timeOffset) + newStamp = newStamp.strftime(prefs.DATETIME_FORMAT) + + self.latestTimeStrsForBoxesChangedViaInstance[workbox_id] = newStamp + editor.__set_changed_by_instance__(True) + + def setFileMonitoringEnabled(self, filename, state): + """Enables/Disables open file change monitoring. If enabled, A dialog will pop + up when ever the open file is changed externally. If file monitoring is + disabled in the IDE settings it will be ignored. + + Returns: + bool: + """ + # if file monitoring is enabled and we have a file name then set up the file + # monitoring + if not filename: + return + + filename = Path(filename).as_posix() + + if state: + self.openFileMonitor.addPath(filename) + else: + self.openFileMonitor.removePath(filename) + + def fileMonitoringEnabled(self, filename): + """Returns whether the provide filename is currently being watched, ie + is listed in self.openFileMonitor.files() + + Args: + filename (str): The filename to determine if being watched + + Returns: + bool: Whether filename is being watched. + """ + if not filename: + return False + + filename = Path(filename).as_posix() + watched_files = self.openFileMonitor.files() + return filename in watched_files + + def prefsPath(self, name='preditor_pref.json'): + """Get the path to this core's prefs, for the given name + + Args: + name (str, optional): This name is appended to the found prefs path, + defaults to 'preditor_pref.json' + + Returns: + path (str): The determined filepath + """ + path = prefs.prefs_path(name, core_name=self.name) + return path + + def linkedFileChanged(self, filename): + """Slot for responding to the file watcher's signal. Handle updating this + PrEditor instance accordingly. + + Args: + filename (str): The file which triggered the file changed signal + """ + prefs_path = Path(self.prefsPath()).as_posix() + + # Either handle prefs or workbox + if filename == prefs_path: + # First, save workbox prefs. Don't save preditor.prefs because that + # would just overwrite whatever changes we are responding to. + self.getBoxesChangedByInstance() + self.recordWorkboxPrefs() + # Now restore prefs, which will use the updated preditor prefs (from + # another preditor instance) + self.restorePrefs(skip_geom=True) + else: + for info in self.uiWorkboxTAB.all_widgets(): + editor, _, _, group_idx, editor_idx = info + if not editor.filename(): + continue + if Path(editor.filename()).as_posix() == Path(filename).as_posix(): + editor.__save_prefs__(saveLinkedFile=False) + editor.__reload_file__() + editor.__save_prefs__(saveLinkedFile=False, force=True) + def closeEvent(self, event): self.recordPrefs() # Save the logger configuration @@ -780,14 +1056,15 @@ def recordPrefs(self, manual=False): if not manual and not self.uiAutoSaveSettingssACT.isChecked(): return - pref = self.load_prefs() + origPref = self.load_prefs() + pref = copy.deepcopy(origPref) geo = self.geometry() pref.update( { 'loggergeom': [geo.x(), geo.y(), geo.width(), geo.height()], 'windowState': QtCompat.enumValue(self.windowState()), - 'SplitterVertical': self.uiEditorVerticalACT.isChecked(), - 'SplitterSize': self.uiSplitterSPLIT.sizes(), + 'splitterVertical': self.uiEditorVerticalACT.isChecked(), + 'splitterSize': self.uiSplitterSPLIT.sizes(), 'tabIndent': self.uiIndentationsTabsACT.isChecked(), 'copyIndentsAsSpaces': self.uiCopyTabsToSpacesACT.isChecked(), 'hintingEnabled': self.uiConsoleAutoCompleteEnabledACT.isChecked(), @@ -819,6 +1096,7 @@ def recordPrefs(self, manual=False): self.uiHighlightExactCompletionACT.isChecked() ), 'dont_ask_again': self.dont_ask_again, + 'max_num_backups': self.max_num_backups, } ) @@ -842,23 +1120,104 @@ def recordPrefs(self, manual=False): if plugin_pref: pref["plugins"][name] = plugin_pref - self.save_prefs(pref) + # Only save if different from previous pref. + if pref != origPref: + self.save_prefs(pref) + self.setStatusText("Prefs saved") + else: + self.setStatusText("No changed prefs to save") + self.autoHideStatusText() + + def auto_backup_prefs(self, filename, onlyFirst=False): + """Auto backup prefs for logger window itself. + + TODO: Implement method to easily scroll thru backups. Maybe difficult, due the + myriad combinations of workboxes and workboxes version. Maybe ignore workboxes, + and just to the dialog prefs and/or existing workbox names + + Args: + filename (str): The filename to backup + onlyFirst (bool, optional): Flag to create initial backup, and not + subsequent ones. Used when dialog launched for the first time. + """ + path = Path(filename) + name = path.name + stem = path.stem + bak_path = prefs.create_stamped_path(self.name, name, sub_dir='prefs_bak') + + # If we are calling from load_prefs, onlyFirst will be True, so we can + # autoBack the prefs the first time. In that case, we'll also do a full + # prefs backup (ie as if pressing the "Backup" button. + existing = list(Path(bak_path).parent.glob("{}*.json".format(stem))) + if onlyFirst: + # If there are already prefs backup files, we do not need to proceed. + if len(existing): + return + self.backupPreferences() + + if path.is_file(): + shutil.copy(path, bak_path) + + self.setStatusText("Prefs saved") + self.autoHideStatusText() def load_prefs(self): - filename = prefs.prefs_path('preditor_pref.json', core_name=self.name) + filename = self.prefsPath() + self.setStatusText('Loaded Prefs: {} '.format(filename)) + self.autoHideStatusText() + + prefs_dict = {} + self.auto_backup_prefs(filename, onlyFirst=True) if os.path.exists(filename): with open(filename) as fp: - return json.load(fp) - return {} + prefs_dict = json.load(fp) + + prefs_dict = self.transition_to_new_prefs(prefs_dict) - def save_prefs(self, pref): + return prefs_dict + + def transition_to_new_prefs(self, prefs_dict): + self.prefs_updates = prefs.get_prefs_updates() + + orig_prefs_dict = copy.deepcopy(prefs_dict) + + new_prefs_dict = prefs.update_prefs_args( + self.name, prefs_dict, self.prefs_updates + ) + + if new_prefs_dict != orig_prefs_dict: + self.save_prefs(new_prefs_dict, at_prefs_update=True) + + return new_prefs_dict + + def save_prefs(self, pref, at_prefs_update=False): # Save preferences to disk - filename = prefs.prefs_path('preditor_pref.json', core_name=self.name) - dirname = os.path.dirname(filename) - if not os.path.exists(dirname): - os.makedirs(dirname) - with open(filename, 'w') as fp: - json.dump(pref, fp, indent=4) + filename = self.prefsPath() + path = Path(filename) + path.parent.mkdir(exist_ok=True, parents=True) + + # Write to temp file first, then copy over, because we may have a + # QFileSystemWatcher for the prefs file, and the 2 lines "with open" + # and "json.dump" triggers 2 file changed signals. + temp_stem = path.stem + "_TEMP" + temp_name = temp_stem + path.suffix + temp_path = path.with_name(temp_name) + with open(temp_path, 'w') as fp: + json.dump(pref, fp, indent=4, sort_keys=True) + + self.setFileMonitoringEnabled(self.prefsPath(), False) + shutil.copy(temp_path, path) + self.setFileMonitoringEnabled(self.prefsPath(), True) + temp_path.unlink() + + self.auto_backup_prefs(filename) + + # We may have just updated prefs, and are saving that update. In this + # case, do not prune or remove old folder, because we don't have the correct + # max number values set yet spinner values. + if not at_prefs_update: + self.prune_backup_files(sub_dir='workboxes') + self.prune_backup_files(sub_dir='prefs_bak') def maybeDisplayDialog(self, dialog): """If user hasn't previously opted to not show this particular dialog again, @@ -891,9 +1250,23 @@ def restartLogger(self): args = ["-m", "preditor"] + args QtCore.QProcess.startDetached(cmd, args) - def restorePrefs(self): + def recordWorkboxPrefs(self): + self.uiWorkboxTAB.save_prefs() + + def restoreWorkboxPrefs(self, pref): + workbox_prefs = pref.get('workbox_prefs', {}) + try: + self.uiWorkboxTAB.hide() + self.uiWorkboxTAB.restore_prefs(workbox_prefs) + finally: + self.uiWorkboxTAB.show() + + def restorePrefs(self, skip_geom=False): pref = self.load_prefs() + workbox_path = self.prefsPath("workboxes") + Path(workbox_path).mkdir(exist_ok=True) + # Editor selection self.editor_cls_name = pref.get('editor_cls') if self.editor_cls_name: @@ -906,8 +1279,11 @@ def restorePrefs(self): self.uiWorkboxTAB.core_name = self.name self.uiEditorChooserWGT.set_editor_name(self.editor_cls_name) + # Workboxes + self.restoreWorkboxPrefs(pref) + # Geometry - if 'loggergeom' in pref: + if 'loggergeom' in pref and not skip_geom: self.setGeometry(*pref['loggergeom']) self.uiEditorVerticalACT.setChecked(pref.get('SplitterVertical', False)) self.adjustWorkboxOrientation(self.uiEditorVerticalACT.isChecked()) @@ -973,7 +1349,7 @@ def restorePrefs(self): self.setStyleSheet(self._stylesheet) self.uiConsoleTXT.flash_time = pref.get('flash_time', 1.0) - self.uiWorkboxTAB.restore_prefs(pref.get('workbox_prefs', {})) + self.max_num_backups = pref.get('max_num_backups', 99) hintingEnabled = pref.get('hintingEnabled', True) self.uiConsoleAutoCompleteEnabledACT.setChecked(hintingEnabled) @@ -1256,6 +1632,10 @@ def update_workbox_stack(self): self.uiWorkboxSTACK.setCurrentIndex(index) + @Slot() + def update_window_settings(self): + self.buildClosedWorkBoxMenu() + def shutdown(self): # close out of the ide system diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index 0697cfaa..95870ec1 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -6,8 +6,8 @@ 0 0 - 796 - 406 + 794 + 404 @@ -36,7 +36,7 @@ - 0 + 1 @@ -74,6 +74,12 @@ 100 + + + Courier + 16 + + @@ -322,6 +328,11 @@ + + + + + @@ -994,6 +1005,38 @@ at the indicated line in the specified text editor. Highlight Exact Completion + + + Show First Workbox Version + + + Ctrl+Alt+Shift+[ + + + + + Show Previous Workbox Version + + + Ctrl+Alt+[ + + + + + Show Next Workbox Version + + + Ctrl+Alt+] + + + + + Show Last Workbox Version + + + Ctrl+Alt+Shift+] + + true diff --git a/preditor/gui/workbox_mixin.py b/preditor/gui/workbox_mixin.py index 106c1660..1ce4fa8d 100644 --- a/preditor/gui/workbox_mixin.py +++ b/preditor/gui/workbox_mixin.py @@ -4,12 +4,20 @@ import os import tempfile import textwrap +from pathlib import Path import chardet from Qt.QtCore import Qt from Qt.QtWidgets import QStackedWidget -from ..prefs import prefs_path +from ..prefs import ( + VersionTypes, + create_stamped_path, + get_backup_version_info, + get_full_path, + get_prefs_dir, + get_relative_path, +) class WorkboxName(str): @@ -55,17 +63,72 @@ class WorkboxMixin(object): """When a user is picking this Workbox class, show a warning with this text.""" def __init__( - self, parent=None, tempfile=None, filename=None, core_name=None, **kwargs + self, + parent=None, + console=None, + workbox_id=None, + filename=None, + backup_file=None, + tempfile=None, + delayable_engine='default', + core_name=None, + **kwargs, ): super(WorkboxMixin, self).__init__(parent=parent, **kwargs) - self._filename_pref = filename self._is_loaded = False + self._show_blank = False self._tempdir = None - self._tempfile = tempfile + self.core_name = core_name self._tab_widget = parent + self.__set_last_saved_text__("") + # You would think we should also __set_last_workbox_name_ here, but we + # wait until __show__ so that we know the tab exists, and has tabText + self._last_workbox_name = None + + self.__set_orphaned_by_instance__(False) + self.__set_changed_by_instance__(False) + self._changed_saved = False + + def __set_last_saved_text__(self, text): + """Store text as last_saved_text on this workbox so checking if if_dirty + is quick. + + Args: + text (str): The text to define as last_saved_text + """ + self._last_saved_text = text + self.__tab_widget__().tabBar().update() + + def __last_saved_text__(self): + """Returns the last_saved_text on this workbox + + Returns: + last_saved_text (str): The _last_saved_text on this workbox + """ + return self._last_saved_text + + def __set_last_workbox_name__(self, name=None): + """Store text as last_workbox_name on this workbox so checking if + if_dirty is quick. + + Args: + name (str): The name to define as last_workbox_name + """ + if name is None: + name = self.__workbox_name__(workbox=self) + self._last_workbox_name = name + + def __last_workbox_name__(self): + """Returns the last_workbox_name on this workbox + + Returns: + last_workbox_name (str): The last_workbox_name on this workbox + """ + return self._last_workbox_name + def __tab_widget__(self): """Return the tab widget which contains this workbox @@ -74,6 +137,10 @@ def __tab_widget__(self): """ return self._tab_widget + def __set_tab_widget__(self, tab_widget): + """Set this workbox's _tab_widget to the provided tab_widget""" + self._tab_widget = tab_widget + def __auto_complete_enabled__(self): raise NotImplementedError("Mixin method not overridden.") @@ -139,7 +206,7 @@ def __exec_selected__(self, truncate=True): ret = repr(ret) self.__console__().startOutputLine() if truncate: - print(self.truncate_middle(ret, 100)) + print(self.__truncate_middle__(ret, 100)) else: print(ret) @@ -147,14 +214,32 @@ def __file_monitoring_enabled__(self): """Returns True if this workbox supports file monitoring. This allows the editor to update its text if the linked file is changed on disk.""" - return False + raise NotImplementedError("Mixin method not overridden.") def __set_file_monitoring_enabled__(self, state): - pass + """Enables/Disables open file change monitoring. If enabled, A dialog will pop + up when ever the open file is changed externally. If file monitoring is + disabled in the IDE settings it will be ignored. + + Returns: + bool: + """ + # if file monitoring is enabled and we have a file name then set up the file + # monitoring + raise NotImplementedError("Mixin method not overridden.") def __filename__(self): raise NotImplementedError("Mixin method not overridden.") + def __set_filename__(self, filename): + """Set this workboxes linked filename to the provided filename + + Args: + filename (str): The filename to link to + """ + self._filename = filename + self._filename_pref = filename + def __font__(self): raise NotImplementedError("Mixin method not overridden.") @@ -336,7 +421,20 @@ def __set_text__(self, txt): """ self._is_loaded = True - def truncate_middle(self, s, n, sep=' ... '): + def __is_dirty__(self): + """Returns if this workbox has unsaved changes, either to it's contents + or it's name. + + Returns: + is_dirty (bool): Whether or not this workbox has unsaved changes + """ + is_dirty = ( + self.__text__() != self.__last_saved_text__() + or self.__workbox_name__(workbox=self) != self.__last_workbox_name__() + ) + return is_dirty + + def __truncate_middle__(self, s, n, sep=' ... '): """Truncates the provided text to a fixed length, putting the sep in the middle. https://www.xormedia.com/string-truncate-middle-with-ellipsis/ """ @@ -356,65 +454,211 @@ def __unix_end_lines__(cls, txt): def __restore_prefs__(self, prefs): self._filename_pref = prefs.get('filename') - self._tempfile = prefs.get('tempfile') + self._workbox_id = prefs.get('workbox_id') - def __save_prefs__(self, name, current=None): + def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): ret = {} + # Hopefully the alphabetical sorting of this dict is preserved in py3 # to make it easy to diff the json pref file if ever required. if current is not None: ret['current'] = current ret['filename'] = self._filename_pref - ret['name'] = name - ret['tempfile'] = self._tempfile + ret['name'] = self.__workbox_name__().workbox + ret['workbox_id'] = self._workbox_id + if self._tempfile: + ret['tempfile'] = self._tempfile + + if self._backup_file: + ret['backup_file'] = get_relative_path(self.core_name, self._backup_file) if not self._is_loaded: return ret - if self._filename_pref: - self.__save__() - else: - if not self._tempfile: - self._tempfile = self.__create_tempfile__() - ret['tempfile'] = self._tempfile - self.__write_file__( - self.__tempfile__(create=True), - self.__text__(), + fullpath = get_full_path( + self.core_name, self._workbox_id, backup_file=self._backup_file + ) + + time_str = None + if self._changed_by_instance: + time_str = self.window().latestTimeStrsForBoxesChangedViaInstance.get( + self._workbox_id, None ) - return ret + if self._changed_saved: + self.window().latestTimeStrsForBoxesChangedViaInstance.pop( + self._workbox_id, None + ) + self._changed_saved = False - def __tempdir__(self, create=False): - if self._tempdir is None: - self._tempdir = prefs_path('workboxes', core_name=self.core_name) + backup_exists = self._backup_file and Path(fullpath).is_file() + if self.__is_dirty__() or not backup_exists or force: + full_path = create_stamped_path( + self.core_name, self._workbox_id, time_str=time_str + ) - if create and not os.path.exists(self._tempdir): - os.makedirs(self._tempdir) + full_path = str(full_path) + self.__write_file__(full_path, self.__text__()) - return self._tempdir + self._backup_file = get_relative_path(self.core_name, full_path) + ret['backup_file'] = self._backup_file - def __tempfile__(self, create=False): - if self._tempfile: - return os.path.join(self.__tempdir__(create=create), self._tempfile) + if time_str: + self._changed_saved = True + + if time_str: + self.__set_changed_by_instance__(False) + if self.window().boxesOrphanedViaInstance.pop(self._workbox_id, None): + self.__set_orphaned_by_instance__(False) + + # If workbox is linked to file on disk, save it + if self._filename_pref and saveLinkedFile: + self._filename = self._filename_pref + self.__save__() + ret['workbox_id'] = self._workbox_id + + self.__set_last_workbox_name__(self.__workbox_name__()) + self.__set_last_saved_text__(self.__text__()) + + return ret - def __create_tempfile__(self): - """Creates a temporary file to be used by `__tempfile__` to store this - editors text contents stored in `__tempdir__`.""" + @classmethod + def __create_workbox_id__(cls, core_name): + """Creates a __workbox_id__ to store this editors text contents stored + in workbox_dir.""" with tempfile.NamedTemporaryFile( - prefix='workbox_', - suffix='.py', - dir=self.__tempdir__(create=True), - delete=False, + prefix="workbox_", + dir=get_prefs_dir(core_name=core_name), + delete=True, ) as fle: name = fle.name return os.path.basename(name) - def __remove_tempfile__(self): - """Removes `__tempfile__` if it is being used.""" - tempfile = self.__tempfile__() - if tempfile and os.path.exists(tempfile): - os.remove(tempfile) + def __workbox_id__(self): + """Returns this workbox's workbox_id + + Returns: + workbox_id (str) + """ + return self._workbox_id + + def __backup_file__(self): + """Returns this workbox's backup file + + Returns: + _backup_file (str) + """ + return self._backup_file + + def __set_changed_by_instance__(self, state): + """Set whether this workbox has been determined to have been changed by + a secondary PrEditor instance (in the same core). + + Args: + state (bool): Whether this workbox has been determined to have been + changed by a secondary PrEditor instance being saved. + """ + self._changed_by_instance = state + + def __changed_by_instance__(self): + """Returns whether this workbox has been determined to have been changed by + a secondary PrEditor instance (in the same core). + + Returns: + changed_by_instance (bool): Whether this workbox has been determined + to have been changed by a secondary PrEditor instance being saved. + """ + return self._changed_by_instance + + def __set_orphaned_by_instance__(self, state): + """Set whether this workbox has been determined to have been orphaned by + a secondary PrEditor instance (in the same core). + + Args: + state (bool): Whether this workbox has been determined to have been + orphaned by a secondary PrEditor instance being saved. + """ + self._orphaned_by_instance = state + + def __orphaned_by_instance__(self): + """Returns whether this workbox has been determined to have been orphaned by + a secondary PrEditor instance (in the same core). + + Returns: + changed_by_instance (bool): Whether this workbox has been determined + to have been orphaned by a secondary PrEditor instance being saved. + """ + return self._orphaned_by_instance + + def __determine_been_changed_by_instance__(self): + """Determine whether this workbox has been changed by a secondary PrEditor + instance saving it's prefs. It sets the internal property + self._changed_by_instance to indicate the result. + """ + if not self._workbox_id: + self._workbox_id = self.__create_workbox_id__(self.core_name) + + if self._workbox_id in self.window().latestTimeStrsForBoxesChangedViaInstance: + self.window().latestTimeStrsForBoxesChangedViaInstance.get(self._workbox_id) + self._changed_by_instance = True + else: + self._changed_by_instance = False + + def __get_workbox_version_text__(self, filename, versionType): + """Get the text of this workboxes previously saved versions. It's based + on versionType, which can be First, Previous, Next, SecondToLast, or Last + + Args: + filename (str): Description + versionType (prefs.VersionTypes): Enum describing which version to + fetch + + Returns: + txt, filepath, idx, count (str, str, int, int): The found files' text, + it's filepath, the index of this file in the stack of files, and + the total count of files for this workbox. + """ + backup_file = get_full_path( + self.core_name, self._workbox_id, backup_file=self._backup_file + ) + + filepath, idx, count = get_backup_version_info( + self.core_name, filename, versionType, backup_file + ) + txt = "" + if filepath and Path(filepath).is_file(): + _encoding, txt = self.__open_file__(str(filepath)) + + return txt, filepath, idx, count + + def __load_workbox_version_text__(self, versionType): + """Get the text of this workboxes previously saved versions, and set it + in the workbox. It's based on versionType, which can be First, Previous, + Next, SecondToLast, or Last + + Args: + versionType (prefs.VersionTypes): Enum describing which version to + fetch + + Returns: + filename, idx, count (str, int, int): The found files' filepath, the + index of this file in the stack of files, and the total count of + files for this workbox. + """ + data = self.__get_workbox_version_text__(self._workbox_id, versionType) + txt, filepath, idx, count = data + + if filepath: + filepath = get_relative_path(self.core_name, filepath) + + self._backup_file = str(filepath) + + self.__set_text__(txt, update_last_saved_text=False) + self.__tab_widget__().tabBar().update() + + filename = Path(filepath).name + return filename, idx, count @classmethod def __open_file__(cls, filename, strict=True): @@ -455,11 +699,22 @@ def __show__(self): return self._is_loaded = True - if self._filename_pref: + count = None + if self._filename_pref and Path(self._filename_pref).is_file(): self.__load__(self._filename_pref) - elif self._tempfile: - _, txt = self.__open_file__(self.__tempfile__(), strict=False) - self.__set_text__(txt) + return + else: + core_name = self.window().name + versionType = VersionTypes.Last + filepath, idx, count = get_backup_version_info( + core_name, self._workbox_id, versionType, "" + ) + + if count: + self.__load_workbox_version_text__(VersionTypes.Last) + self.__set_last_saved_text__(self.__text__()) + + self.__set_last_workbox_name__() def process_shortcut(self, event, run=True): """Check for workbox shortcuts and optionally call them. @@ -482,7 +737,6 @@ def process_shortcut(self, event, run=True): # Number pad enter, or Shift + Return pressed, execute selected # Ctrl+ Shift+Return pressed, execute selected without truncating output if run: - # self.__exec_selected__() # Collect what was pressed key = event.key() modifiers = event.modifiers() diff --git a/preditor/gui/workbox_text_edit.py b/preditor/gui/workbox_text_edit.py index 271b8049..2dcd5d7e 100644 --- a/preditor/gui/workbox_text_edit.py +++ b/preditor/gui/workbox_text_edit.py @@ -23,7 +23,16 @@ class WorkboxTextEdit(WorkboxMixin, QTextEdit): ) def __init__( - self, parent=None, console=None, delayable_engine='default', core_name=None + self, + parent=None, + console=None, + workbox_id=None, + filename=None, + backup_file=None, + tempfile=None, + delayable_engine='default', + core_name=None, + **kwargs, ): super(WorkboxTextEdit, self).__init__(parent=parent, core_name=core_name) self._filename = None @@ -96,7 +105,7 @@ def __tab_width__(self): def __text__(self, line=None, start=None, end=None): return self.toPlainText() - def __set_text__(self, text): + def __set_text__(self, text, update_last_saved_text=True): super(WorkboxTextEdit, self).__set_text__(text) self.setPlainText(text) diff --git a/preditor/gui/workboxwidget.py b/preditor/gui/workboxwidget.py index 3e8381f9..41cbaebc 100644 --- a/preditor/gui/workboxwidget.py +++ b/preditor/gui/workboxwidget.py @@ -2,6 +2,7 @@ import re import time +from pathlib import Path from Qt.QtCore import Qt from Qt.QtGui import QIcon @@ -16,7 +17,15 @@ class WorkboxWidget(WorkboxMixin, DocumentEditor): def __init__( - self, parent=None, console=None, delayable_engine='default', core_name=None + self, + parent=None, + console=None, + workbox_id=None, + filename=None, + backup_file=None, + tempfile=None, + delayable_engine='default', + core_name=None, ): self.__set_console__(console) self._searchFlags = 0 @@ -28,13 +37,24 @@ def __init__( parent, delayable_engine=delayable_engine, core_name=core_name ) + if workbox_id: + self._workbox_id = workbox_id + else: + self._workbox_id = WorkboxMixin.__create_workbox_id__(self.core_name) + + self._filename_pref = filename + self._backup_file = backup_file + self._tempfile = tempfile + # Store the software name so we can handle custom keyboard shortcuts based on # software self._software = core.objectName() # Used to remove any trailing whitespace when running selected text self.regex = re.compile(r'\s+$') self.initShortcuts() - self.setLanguage('Python') + + self._defaultLanguage = "Python" + self.setLanguage(self._defaultLanguage) # Default to unix newlines self.setEolMode(QsciScintilla.EolMode.EolUnix) if hasattr(self.window(), "setWorkboxFontBasedOnConsole"): @@ -53,6 +73,7 @@ def __set_auto_complete_enabled__(self, state): def __clear__(self): self.clear() + self.__set_last_saved_text__(self.__text__()) def __comment_toggle__(self): self.commentToggle() @@ -77,11 +98,10 @@ def __exec_all__(self): self.__console__().executeString(txt, filename=title) def __file_monitoring_enabled__(self): - return self._fileMonitoringActive + return self.window().fileMonitoringEnabled(self.__filename__()) def __set_file_monitoring_enabled__(self, state): - self.setAutoReloadOnChange(state) - self.enableFileWatching(state) + self.window().setFileMonitoringEnabled(self.__filename__(), state) def __filename__(self): return self.filename() @@ -111,8 +131,27 @@ def __set_indentations_use_tabs__(self, state): def __insert_text__(self, txt): self.insert(txt) - def __load__(self, filename): + def __load__(self, filename, update_last_save=True): self.load(filename) + self.__set_last_saved_text__(self.__text__()) + + # Determine workbox name so we can store it + workbox_name = self.__workbox_name__() + group_name = workbox_name.group + + new_name = Path(filename).name + + new_workbox_name = "{}/{}".format(group_name, new_name) + self.__set_last_workbox_name__(new_workbox_name) + + def __reload_file__(self): + # loading the file too quickly misses any changes + time.sleep(0.1) + font = self.__font__() + self.reloadChange() + self.__set_last_saved_text__(self.__text__()) + self.__set_last_workbox_name__(self.__workbox_name__()) + self.__set_font__(font) def __margins_font__(self): return self.marginsFont() @@ -135,18 +174,13 @@ def __marker_clear_all__(self): # self._marker has not been created yet pass - def __reload_file__(self): - # loading the file too quickly misses any changes - time.sleep(0.1) - font = self.__font__() - self.reloadChange() - self.__set_font__(font) - def __remove_selected_text__(self): self.removeSelectedText() def __save__(self): self.save() + self.__set_last_saved_text__(self.__text__()) + self.__set_last_workbox_name__(self.__workbox_name__()) def __selected_text__(self, start_of_line=False, selectText=False): line, s, end, e = self.getSelection() @@ -199,9 +233,12 @@ def __text__(self, line=None, start=None, end=None): self.text(start, end) return self.text() - def __set_text__(self, txt): + def __set_text__(self, txt, update_last_saved_text=True): """Replace all of the current text with txt.""" self.setText(txt) + self._is_loaded = True + if update_last_saved_text: + self.__set_last_saved_text__(self.__text__()) @classmethod def __write_file__(cls, filename, txt, encoding=None): @@ -225,14 +262,13 @@ def keyPressEvent(self, event): truncation, but no modifiers are registered when Enter is pressed (unlike when Return is pressed), so this combination is not detectable. """ - if self._software == 'softimage': - super(WorkboxWidget, self).keyPressEvent(event) + self.__tab_widget__().tabBar().update() + + if self.process_shortcut(event): + return else: - if self.process_shortcut(event): - return - else: - # Send regular keystroke - super(WorkboxWidget, self).keyPressEvent(event) + # Send regular keystroke + super(WorkboxWidget, self).keyPressEvent(event) def initShortcuts(self): """Use this to set up shortcuts when the DocumentEditor""" diff --git a/preditor/prefs.py b/preditor/prefs.py index 0ef3ee95..2868a644 100644 --- a/preditor/prefs.py +++ b/preditor/prefs.py @@ -4,12 +4,34 @@ """ from __future__ import absolute_import +import datetime +import json import os +import re +import shutil import sys +from pathlib import Path + +import six + +from . import resourcePath # cache of all the preferences _cache = {} +DATETIME_FORMAT = "%Y-%m-%d-%H-%M-%S-%f" +DATETIME_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{6}") + + +class VersionTypes: + """Nice names for the workbox version types.""" + + First = 0 + Previous = 1 + Next = 2 + TwoBeforeLast = 3 + Last = 4 + def backup(): """Saves a copy of the current preferences to a zip archive.""" @@ -53,6 +75,48 @@ def existing(): return sorted(next(os.walk(root))[1], key=lambda i: i.lower()) +def get_full_path(core_name, workbox_id, backup_file=None): + """Get the full path for the given workbox_id in the given core. If + backup_file is provided, use that. + + Args: + core_name (str): The current core_name + workbox_id (str): The current workbox_id + backup_file (str, optional): The backup_file (ie with time stamped path) + + Returns: + full_path (str): The constructed full path + """ + workbox_dir = get_prefs_dir(core_name) + workbox_dir = get_prefs_dir(core_name=core_name) + if backup_file: + full_path = Path(workbox_dir) / backup_file + else: + full_path = Path(workbox_dir) / workbox_id / workbox_id + full_path = str(full_path.with_suffix(".py")) + return full_path + + +def get_relative_path(core_name, path): + """Get the file path relative to the current core's prefs path. If path is + not relative to working_dir, return the original path + + Args: + core_name (str): The current core_name + path (str): The full path + + Returns: + rel_path (rel_path): The determined relative path. + """ + workbox_dir = get_prefs_dir(core_name) + workbox_dir = get_prefs_dir(core_name=core_name) + try: + rel_path = str(Path(path).relative_to(workbox_dir)) + except ValueError: + rel_path = path + return rel_path + + def prefs_path(filename=None, core_name=None): """The path PrEditor's preferences are saved as a json file. @@ -72,3 +136,240 @@ def prefs_path(filename=None, core_name=None): if filename: ret = os.path.join(ret, filename) return ret + + +def get_prefs_dir(sub_dir='workboxes', core_name=None, create=False): + """Get the prefs path including the given sub directory, and optionally + create the file on disk. + + Args: + sub_dir (str, optional): The needed sub directory, defaults to 'workboxes' + core_name (str, optional): The current core_name + create (bool, optional): Whether to create directories on disk + + Returns: + prefs_dir (str): The determined path + """ + prefs_dir = prefs_path(sub_dir, core_name=core_name) + if create: + Path(prefs_dir).mkdir(parents=True, exist_ok=True) + return prefs_dir + + +def create_stamped_path(core_name, filepath, sub_dir='workboxes', time_str=None): + """For the given filepath, generate a filepath which includes a time stamp, + which is either based on the current time, or passed explicitly (ie to + re-order backups when prefs are saved in another instance of PrEditor in the + same core. + + Args: + core_name (str): The current core_name + filepath (str): The filepath we need to create a time-stamped path for + sub_dir (str, optional): The needed sub directory, defaults to 'workboxes' + time_str (None, optional): A specific time-stamp to use, otherwise generate + a new one from current time. + + Returns: + path (str): The created stamped path + """ + path = Path(get_prefs_dir(sub_dir=sub_dir, core_name=core_name, create=True)) + filepath = Path(filepath) + stem = filepath.stem + name = filepath.name + suffix = filepath.suffix or ".py" + + if sub_dir == "workboxes": + path = path / stem + + if not time_str: + now = datetime.datetime.now() + time_str = now.strftime(DATETIME_FORMAT) + name = "{}-{}".format(stem, time_str) + + path = path / name + path = path.with_suffix(suffix) + + path.parent.mkdir(exist_ok=True) + + path = six.text_type(path) + return path + + +def get_file_group(core_name, workbox_id): + """Get the backup files for the given workbox_id, for the given core_name + + Args: + core_name (str): The current core_name + workbox_id (str): The current workbox_id + + Returns: + TYPE: Description + """ + directory = Path(get_prefs_dir(core_name=core_name, sub_dir='workboxes')) + workbox_dir = directory / workbox_id + workbox_dir.mkdir(exist_ok=True) + files = sorted(list(workbox_dir.iterdir())) + return files + + +def get_backup_file_index_and_count(core_name, workbox_id, backup_file, files=None): + """For the given core_name and workbox_id, find the (zero-based)index + backup_file is within that workbox's backup files, plus the total count of + backup files. + + Args: + core_name (str): The current core_name + workbox_id (str): The current workbox_id + backup_file (None, optional): The currently loaded backup file. + files (None, optional): If we already found the files on disk, pass them + in here + Returns: + idx, count (int, int): The zero-based index of backup_file, and file count + """ + idx = None + files = files or get_file_group(core_name, workbox_id) + count = len(files) + if not files: + return None, None + + backup_file = Path(backup_file) if backup_file else None + if not backup_file: + return None, None + + backup_file = get_full_path(core_name, workbox_id, backup_file) + backup_file = Path(backup_file) + + if backup_file in files: + idx = files.index(backup_file) + return idx, count + + +def get_backup_version_info(core_name, workbox_id, versionType, backup_file=None): + """For the given core_name and workbox_id, find the filename based on versionType, + potentially relative to backup_file . Include the (one-based) index filename is + within that workbox's backup files, plus the total count of backup files. + + Args: + core_name (str): The current core_name + workbox_id (str): The current workbox_id + versionType (TYPE): The VersionType (ie First, Previous, Next, Last) + backup_file (None, optional): The currently loaded backup file. + + Returns: + filepath, display_idx, count (str, int, int): The found filepath, it's + (one-based) index within backup files, but count of backup files. + """ + files = get_file_group(core_name, workbox_id) + if not files: + return ("", "", 0) + count = len(files) + + idx = len(files) - 1 + if versionType == VersionTypes.First: + idx = 0 + elif versionType == VersionTypes.Last: + idx = len(files) - 1 + else: + idx, count = get_backup_file_index_and_count( + core_name, workbox_id, backup_file, files=files + ) + if idx is not None: + if versionType == VersionTypes.TwoBeforeLast: + idx -= 2 + idx = max(idx, 0) + elif versionType == VersionTypes.Previous: + idx -= 1 + idx = max(idx, 0) + elif versionType == VersionTypes.Next: + idx += 1 + idx = min(idx, count - 1) + + filepath = six.text_type(files[idx]) + display_idx = idx + 1 + return filepath, display_idx, count + + +def get_prefs_updates(): + """Get any defined updates to prefs args / values + + Returns: + updates (dict): The dict of defined updates + """ + updates = {} + path = resourcePath(r"pref_updates\pref_updates.json") + with open(path, 'r') as f: + updates = json.load(f) + try: + with open(path, 'r') as f: + updates = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + pass + return updates + + +def update_pref_args(core_name, pref_dict, old_name, update_data): + """Update an individual pref name and/or value. + + Args: + core_name (str): The current core_name + pref_dict (TYPE): The pref to update + old_name (TYPE): Original pref name, which may be updated + update_data (TYPE): Dict to define ways to update the values, which + currently only supports str.replace. + """ + workbox_dir = Path(get_prefs_dir(core_name=core_name, create=True)) + + if old_name == "tempfile": + orig_pref = pref_dict.get(old_name) + else: + orig_pref = pref_dict.pop(old_name) + pref = orig_pref[:] if isinstance(orig_pref, list) else orig_pref + + if isinstance(pref, six.text_type): + replacements = update_data.get("replace", []) + for replacement in replacements: + pref = pref.replace(*replacement) + + existing_backup_file = pref_dict.get("backup_file", None) + if not existing_backup_file and old_name == "tempfile": + newfilepath = create_stamped_path(core_name, pref) + orig_filepath = workbox_dir / orig_pref + if orig_filepath.is_file(): + orig_filepath = six.text_type(orig_filepath) + + if not Path(newfilepath).is_file(): + shutil.copy(orig_filepath, newfilepath) + newfilepath = six.text_type(Path(newfilepath).relative_to(workbox_dir)) + + pref_dict.update({"backup_file": newfilepath}) + + pref_name = update_data.get("new_name", old_name) + pref_dict.update({pref_name: pref}) + + +def update_prefs_args(core_name, prefs_dict, prefs_updates): + """Update all the PrEditor prefs, as defined in prefs_updates + + Args: + core_name (str): The current core_name + prefs_dict (TYPE): The PrEditor prefs to update + prefs_updates (TYPE): The update definition dict + + Returns: + prefs_dict (dict): The updated dict + """ + for old_name, data in prefs_updates.items(): + if old_name not in prefs_dict: + continue + + if old_name == "workbox_prefs": + for sub_old_name, sub_data in prefs_updates["workbox_prefs"].items(): + for group_dict in prefs_dict["workbox_prefs"]["groups"]: + for tab_dict in group_dict["tabs"]: + if sub_old_name not in tab_dict: + continue + update_pref_args(core_name, tab_dict, sub_old_name, sub_data) + else: + update_pref_args(core_name, prefs_dict, old_name, data) + + return prefs_dict diff --git a/preditor/resource/pref_updates/pref_updates.json b/preditor/resource/pref_updates/pref_updates.json new file mode 100644 index 00000000..9d2d8fed --- /dev/null +++ b/preditor/resource/pref_updates/pref_updates.json @@ -0,0 +1,11 @@ +{ + "SplitterSize": {"new_name": "splitterSize"}, + "SplitterVertical": {"new_name": "splitterVertical"}, + "workbox_prefs": { + "tempfile": { + "new_name": "workbox_id", + "replace": [[".py", ""]] + } + }, + "version": 2.0 +} diff --git a/preditor/resource/stylesheet/Bright.css b/preditor/resource/stylesheet/Bright.css index fcf44e3a..6b6a1908 100644 --- a/preditor/resource/stylesheet/Bright.css +++ b/preditor/resource/stylesheet/Bright.css @@ -63,3 +63,14 @@ ConsolePrEdit { qproperty-stdoutColor: rgb(17, 154, 255); qproperty-stringColor: rgb(255, 128, 0); } + +DragTabBar { + qproperty-changedColor: "darkorchid"; + qproperty-changedLinkedColor: "darkviolet"; + qproperty-dirtyColor: "orange"; + qproperty-dirtyLinkedColor: "darkorange"; + qproperty-linkedColor: "royalblue"; + qproperty-missingLinkedColor: "deeppink"; + qproperty-orphanedColor: "crimson"; + qproperty-orphanedLinkedColor: "firebrick"; +} diff --git a/preditor/resource/stylesheet/Dark.css b/preditor/resource/stylesheet/Dark.css index 111b72ce..64c59f65 100644 --- a/preditor/resource/stylesheet/Dark.css +++ b/preditor/resource/stylesheet/Dark.css @@ -197,3 +197,14 @@ ConsolePrEdit { qproperty-stdoutColor: rgb(22, 160, 250); qproperty-stringColor: rgb(240, 135, 0); } + +DragTabBar { + qproperty-changedColor: "orchid"; + qproperty-changedLinkedColor: "violet"; + qproperty-dirtyColor: "orange"; + qproperty-dirtyLinkedColor: "sandybrown"; + qproperty-linkedColor: rgb(0, 160, 0); + qproperty-missingLinkedColor: rgb(255, 50, 140); + qproperty-orphanedColor: "pink"; + qproperty-orphanedLinkedColor: "plum"; +} diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index a5cc0352..024027f2 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -85,9 +85,9 @@ def __init__(self, parent, filename='', lineno=0, delayable_engine='default'): self._filename = '' self.additionalFilenames = [] self._language = '' + self._defaultLanguage = "" self._lastSearch = '' self._encoding = 'utf-8' - self._fileMonitoringActive = False self._marginsFont = self._defaultFont self._lastSearchDirection = SearchDirection.First self._saveTimer = 0.0 @@ -272,7 +272,7 @@ def clear(self): def closeEvent(self, event): self.disableTitleUpdate() # unsubcribe the file from the open file monitor - self.enableFileWatching(False) + self.__set_file_monitoring_enabled__(False) super(DocumentEditor, self).closeEvent(event) def closeEditor(self): @@ -581,28 +581,6 @@ def editPermaHighlight(self): if success: self.setPermaHighlight(text.split(' ')) - def enableFileWatching(self, state): - """Enables/Disables open file change monitoring. If enabled, A dialog will pop - up when ever the open file is changed externally. If file monitoring is - disabled in the IDE settings it will be ignored. - - Returns: - bool: - """ - # if file monitoring is enabled and we have a file name then set up the file - # monitoring - window = self.window() - self._fileMonitoringActive = False - if hasattr(window, 'openFileMonitor'): - fm = window.openFileMonitor() - if fm: - if state: - fm.addPath(self._filename) - self._fileMonitoringActive = True - else: - fm.removePath(self._filename) - return self._fileMonitoringActive - def disableTitleUpdate(self): self.modificationChanged.connect(self.refreshTitle) @@ -690,7 +668,6 @@ def language(self): def languageChosen(self, action): self.setLanguage(action.text()) self.updateColorScheme() - self._fileMonitoringActive = False window = self.window() if hasattr(window, 'uiLanguageDDL'): window.uiLanguageDDL.blockSignals(True) @@ -706,7 +683,7 @@ def load(self, filename): self._encoding, text = WorkboxMixin.__open_file__(filename) self.setText(text) self.updateFilename(filename) - self.enableFileWatching(True) + self.__set_file_monitoring_enabled__(True) self.setEolMode(self.detectEndLine(self.text())) return True return False @@ -1079,7 +1056,7 @@ def reloadChange(self): logger.debug( 'The file was deleted, But the user left it in the editor', ) - self.enableFileWatching(False) + self.__set_file_monitoring_enabled__(False) return True logger.debug('Defaulting to reload message') return self.reloadDialog( @@ -1089,7 +1066,7 @@ def reloadChange(self): def reloadDialog(self, message, title='Reload File...'): if not self._dialogShown: self._dialogShown = True - if self._autoReloadOnChange or not self.isModified(): + if self.autoReloadOnChange() or not self.isModified(): result = QMessageBox.StandardButton.Yes else: result = QMessageBox.question( @@ -1160,12 +1137,13 @@ def save(self): self.saveAs(filename, setFilename=False) return ret - def saveAs(self, filename='', setFilename=True): + def saveAs(self, filename='', setFilename=True, directory=''): logger.debug(' Save As Called '.center(60, '-')) - newFile = False + + # Disable file watching so workbox doesn't reload and scroll to the top + self.__set_file_monitoring_enabled__(False) if not filename: - newFile = True - filename = self.filename() + filename = self.filename() or directory filename, extFilter = Qt_py.QtCompat.QFileDialog.getSaveFileName( self.window(), 'Save File as...', filename ) @@ -1189,8 +1167,8 @@ def saveAs(self, filename='', setFilename=True): # update the file if setFilename: self.updateFilename(filename) - if newFile: - self.enableFileWatching(True) + # Turn file watching back on. + self.__set_file_monitoring_enabled__(True) return True return False @@ -1269,9 +1247,6 @@ def setLanguage(self, language): self.delayable_engine.delayables['spell_check'].reset_session(self) def setLexer(self, lexer): - font = self.documentFont - if lexer: - font = lexer.font(0) # Backup values destroyed when we set the lexer marginFont = self.marginsFont() folds = self.contractedFolds() @@ -1305,11 +1280,6 @@ def setLexer(self, lexer): QsciScintilla.SCI_SETWORDCHARS, wordCharacters.encode('utf8') ) - if lexer: - lexer.setFont(font) - else: - self.setFont(font) - def setLineMarginWidth(self, width): self.setMarginWidth(self.SymbolMargin, width) @@ -1611,12 +1581,6 @@ def showMenu(self, pos, popup=True): act.setCheckable(True) act.setChecked(self.indentationsUseTabs()) - if self._fileMonitoringActive: - act = menu.addAction('Auto Reload file') - act.triggered.connect(self.setAutoReloadOnChange) - act.setCheckable(True) - act.setChecked(self._autoReloadOnChange) - if popup: menu.popup(self._clickPos) return menu @@ -1730,12 +1694,13 @@ def updateFilename(self, filename): filename = str(filename) extension = os.path.splitext(filename)[1] - # determine if we need to modify the language - if not self._filename or extension != os.path.splitext(self._filename)[1]: + if filename and extension != os.path.splitext(self._filename)[1]: self.setLanguage(lang.byExtension(extension)) + elif not self._filename: + self.setLanguage(self._defaultLanguage) # update the filename information - self._filename = os.path.abspath(filename) + self._filename = os.path.abspath(filename) if filename else "" self.setModified(False) try: @@ -2122,3 +2087,21 @@ def setUnmatchedBraceForegroundColor(self, color): '_paperSmartHighlight', QColor(155, 255, 155, 75) ) paperDecorator = QtPropertyInit('_paperDecorator', _defaultPaper) + + def __file_monitoring_enabled__(self): + """Returns True if this workbox supports file monitoring. + This allows the editor to update its text if the linked + file is changed on disk.""" + return False + + def __set_file_monitoring_enabled__(self, state): + """Enables/Disables open file change monitoring. If enabled, A dialog will pop + up when ever the open file is changed externally. If file monitoring is + disabled in the IDE settings it will be ignored. + + Returns: + bool: + """ + # if file monitoring is enabled and we have a file name then set up the file + # monitoring + pass From 534d23c51cfca093a9cccf2c45b94dcb4b7a1b1b Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Tue, 28 Oct 2025 20:01:26 -0700 Subject: [PATCH 02/10] Auto full-backup before transition to workbox_id --- preditor/gui/loggerwindow.py | 44 ++++++++++++++----- preditor/prefs.py | 9 ++++ .../resource/pref_updates/pref_updates.json | 2 +- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 8e190e60..ab899327 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -1146,14 +1146,10 @@ def auto_backup_prefs(self, filename, onlyFirst=False): bak_path = prefs.create_stamped_path(self.name, name, sub_dir='prefs_bak') # If we are calling from load_prefs, onlyFirst will be True, so we can - # autoBack the prefs the first time. In that case, we'll also do a full - # prefs backup (ie as if pressing the "Backup" button. + # autoBack the prefs the first time. existing = list(Path(bak_path).parent.glob("{}*.json".format(stem))) - if onlyFirst: - # If there are already prefs backup files, we do not need to proceed. - if len(existing): - return - self.backupPreferences() + if onlyFirst and len(existing): + return if path.is_file(): shutil.copy(path, bak_path) @@ -1172,11 +1168,31 @@ def load_prefs(self): with open(filename) as fp: prefs_dict = json.load(fp) - prefs_dict = self.transition_to_new_prefs(prefs_dict) - return prefs_dict - def transition_to_new_prefs(self, prefs_dict): + def autoBackupForTransition(self, prefs_dict): + """Since changing how workboxes are based to workbox_id is a major change, + do a full prefs backup the first time. This is based on the prefs attr + 'prefs_version'. If less than 2.0, it will perform a full backup. + + Args: + prefs_dict (dict): The (newly loaded) prefs. + """ + prefs_version = prefs_dict.get("prefs_version", 1.0) + if prefs_version < 2.0: + self.backupPreferences() + + def transitionToNewPrefs(self, prefs_dict): + """To facilitate renaming / changing prefs attrs, load a json dict which + defines the changes, and then apply them. This can usually include a + 'prefs_version' attr associated with the changes. + + Args: + prefs_dict (dict): The (newly loaded) prefs. + + Returns: + new_prefs_dict (dict): The updated prefs dict + """ self.prefs_updates = prefs.get_prefs_updates() orig_prefs_dict = copy.deepcopy(prefs_dict) @@ -1264,6 +1280,11 @@ def restoreWorkboxPrefs(self, pref): def restorePrefs(self, skip_geom=False): pref = self.load_prefs() + # Make changes to prefs attrs. Depending on the changes, perform a full + # auto-backup first. + self.autoBackupForTransition(pref) + pref = self.transitionToNewPrefs(pref) + workbox_path = self.prefsPath("workboxes") Path(workbox_path).mkdir(exist_ok=True) @@ -1373,6 +1394,8 @@ def restorePrefs(self, skip_geom=False): for name, plugin in self.plugins.items(): plugin.restore_prefs(name, pref.get("plugins", {}).get(name)) + self.restoreToolbars(pref=pref) + def restoreToolbars(self, pref=None): if pref is None: pref = self.load_prefs() @@ -1577,7 +1600,6 @@ def showEnvironmentVars(self): def showEvent(self, event): super(LoggerWindow, self).showEvent(event) - self.restoreToolbars() self.updateIndentationsUseTabs() self.updateCopyIndentsAsSpaces() diff --git a/preditor/prefs.py b/preditor/prefs.py index 2868a644..20c82da6 100644 --- a/preditor/prefs.py +++ b/preditor/prefs.py @@ -358,6 +358,13 @@ def update_prefs_args(core_name, prefs_dict, prefs_updates): Returns: prefs_dict (dict): The updated dict """ + + # Check if we have already updated to this prefs_update version + update_version = prefs_updates.get("prefs_version", 1.0) + prefs_version = prefs_dict.get("prefs_version", 1) + if prefs_version >= update_version: + return prefs_dict + for old_name, data in prefs_updates.items(): if old_name not in prefs_dict: continue @@ -372,4 +379,6 @@ def update_prefs_args(core_name, prefs_dict, prefs_updates): else: update_pref_args(core_name, prefs_dict, old_name, data) + prefs_dict["prefs_version"] = update_version + return prefs_dict diff --git a/preditor/resource/pref_updates/pref_updates.json b/preditor/resource/pref_updates/pref_updates.json index 9d2d8fed..7778805c 100644 --- a/preditor/resource/pref_updates/pref_updates.json +++ b/preditor/resource/pref_updates/pref_updates.json @@ -7,5 +7,5 @@ "replace": [[".py", ""]] } }, - "version": 2.0 + "prefs_version": 2.0 } From 8bd1619303258239df95e59396a229b28536b81a Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Tue, 21 Oct 2025 15:27:05 -0700 Subject: [PATCH 03/10] Update Find in Files to anchor workbox_name --- preditor/gui/find_files.py | 12 ++++++------ preditor/utils/text_search.py | 22 +++++++++------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/preditor/gui/find_files.py b/preditor/gui/find_files.py index f677f69b..85ff80de 100644 --- a/preditor/gui/find_files.py +++ b/preditor/gui/find_files.py @@ -79,12 +79,11 @@ def find(self): editor, group_name, tab_name, - group_index, - tab_index, + _group_index, + _tab_index, ) in manager.all_widgets(): path = "/".join((group_name, tab_name)) - workbox_id = '{},{}'.format(group_index, tab_index) - self.find_in_editor(editor, path, workbox_id) + self.find_in_editor(editor, path) self.insert_text( '\n{} matches in {} workboxes\n'.format( @@ -92,13 +91,14 @@ def find(self): ) ) - def find_in_editor(self, editor, path, workbox_id): + def find_in_editor(self, editor, path): # Ensure the editor text is loaded and get its raw text editor.__show__() text = editor.__text__() + workbox_name = editor.__workbox_name__() # Use the finder to check for matches - found = self.finder.search_text(text, path, workbox_id) + found = self.finder.search_text(text, path, workbox_name) if found: self.match_files_count += 1 diff --git a/preditor/utils/text_search.py b/preditor/utils/text_search.py index bf64b63d..c0cf2dc0 100644 --- a/preditor/utils/text_search.py +++ b/preditor/utils/text_search.py @@ -70,7 +70,7 @@ def indicate_line(self, line): """ def indicate_results( - self, line, line_num, path="undefined", workbox_id="undefined" + self, line, line_num, path="undefined", workbox_name="undefined" ): """Writes a single line adding markup for any matches on the line.""" tool_tip = "Open {} at line number {}".format(path, line_num) @@ -82,7 +82,7 @@ def indicate_results( # Otherwise print the next section of the line text if indicate: - self.callback_matching(text, workbox_id, line_num, tool_tip) + self.callback_matching(text, workbox_name, line_num, tool_tip) else: self.callback_non_matching(text) @@ -121,7 +121,7 @@ def margin(self, line_num, match_found): def matches(self, line): """Returns bool for if find_text is contained in this line.""" - def print_matching(self, text, workbox_id, line_num, tool_tip): + def print_matching(self, text, workbox_name, line_num, tool_tip): """Simple callback for `callback_matching` that prints text. The print does not insert an newline character. @@ -129,13 +129,11 @@ def print_matching(self, text, workbox_id, line_num, tool_tip): Args: text (str): The matching text to display. This will be inserted into a markdown link as the link text. - workbox_id (str): From `GroupTabWidget.all_widgets`, the group_tab_index - and widget_tab_index joined by a comma without a space. Used as - the url of the link. Example: `3,1`. + workbox_name (str): From editor.__workbox_name__() line_number (int): The line number the url should navigate to. tool_tip (str): Added as a title to the link to show up as a tool tip. """ - href = ', {}, {}'.format(workbox_id, line_num) + href = ', {}, {}'.format(workbox_name, line_num) print('[{}]({} "{}")'.format(text, href, tool_tip), end="") def print_non_matching(self, text): @@ -145,7 +143,7 @@ def print_non_matching(self, text): """ print(text, end="") - def search_text(self, text, path, workbox_id): + def search_text(self, text, path, workbox_name): """Search each line of text for matching text and write the the matches including context lines. @@ -153,9 +151,7 @@ def search_text(self, text, path, workbox_id): text (str): The text to search. path (str): The workbox name this text represents. Should be the Group_name and tab_name separated by a `/`. - workbox_id (str): From `GroupTabWidget.all_widgets`, the group_tab_index - and widget_tab_index joined by a comma without a space. Used as - the url of the link. Example: `3,1`. + workbox_name (str): From editor.__workbox_name__() """ # NOTE: splitlines discards the "newline at end of file" so it doesn't # show up in the final search results. @@ -177,14 +173,14 @@ def search_text(self, text, path, workbox_id): found = False for i, line in enumerate(lines): - info = dict(path=path, workbox_id=workbox_id) + info = dict(path=path, workbox_name=workbox_name) if self.matches(line): len_pre_history = len(pre_history) if not found: # Print the path on the first find self.callback_non_matching("# File: ") tool_tip = "Open {}".format(path) - self.callback_matching(path, workbox_id, 0, tool_tip) + self.callback_matching(path, workbox_name, 0, tool_tip) self.callback_non_matching("\n") found = True elif i - last_insert - 1 - len_pre_history > 0: From 540d1c18f393f5bdc1e605d007b8b23d9c26e456 Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Wed, 29 Oct 2025 11:11:28 -0700 Subject: [PATCH 04/10] Support resizing gui font --- preditor/gui/console.py | 16 +- preditor/gui/drag_tab_bar.py | 6 + preditor/gui/group_tab_widget/__init__.py | 26 ++- .../group_tab_widget/grouped_tab_widget.py | 3 - .../gui/group_tab_widget/one_tab_widget.py | 1 + preditor/gui/loggerwindow.py | 149 +++++++++++++++--- preditor/gui/ui/loggerwindow.ui | 54 +++++++ preditor/scintilla/documenteditor.py | 80 +++++----- 8 files changed, 246 insertions(+), 89 deletions(-) diff --git a/preditor/gui/console.py b/preditor/gui/console.py index 1d0c8ada..ad9afcdf 100644 --- a/preditor/gui/console.py +++ b/preditor/gui/console.py @@ -165,6 +165,11 @@ def codeHighlighter(self): """ return self._uiCodeHighlighter + def contextMenuEvent(self, event): + menu = self.createStandardContextMenu() + menu.setFont(self.window().font()) + menu.exec(self.mapToGlobal(event.pos())) + def doubleSingleShotSetScrollValue(self, origPercent): """This double QTimer.singleShot monkey business seems to be the only way to get scroll.maximum() to update properly so that we calc newValue @@ -241,17 +246,6 @@ def mouseReleaseEvent(self, event): QApplication.restoreOverrideCursor() return super(ConsolePrEdit, self).mouseReleaseEvent(event) - def wheelEvent(self, event): - """Override of wheelEvent to allow for font resizing by holding ctrl while""" - # scrolling. If used in LoggerWindow, use that wheel event - # May not want to import LoggerWindow, so perhaps - # check by str(type()) - ctrlPressed = event.modifiers() == Qt.KeyboardModifier.ControlModifier - if ctrlPressed and "LoggerWindow" in str(type(self.window())): - self.window().wheelEvent(event) - else: - QTextEdit.wheelEvent(self, event) - def keyReleaseEvent(self, event): """Override of keyReleaseEvent to determine when to end navigation of previous commands diff --git a/preditor/gui/drag_tab_bar.py b/preditor/gui/drag_tab_bar.py index 8e104437..6fed673b 100644 --- a/preditor/gui/drag_tab_bar.py +++ b/preditor/gui/drag_tab_bar.py @@ -11,6 +11,7 @@ QFileDialog, QInputDialog, QMenu, + QSizePolicy, QStyle, QStyleOptionTab, QTabBar, @@ -339,6 +340,7 @@ def tab_menu(self, pos, popup=True): if self._context_menu_tab == -1: return menu = QMenu(self) + menu.setFont(self.window().font()) grouped_tab = self.parentWidget() workbox = grouped_tab.widget(self._context_menu_tab) @@ -506,6 +508,10 @@ def install_tab_widget(cls, tab_widget, mime_type='DragTabBar', menu=True): tab_widget.setMovable(True) tab_widget.setDocumentMode(True) + sizePolicy = tab_widget.sizePolicy() + sizePolicy.setVerticalPolicy(QSizePolicy.Policy.Preferred) + tab_widget.setSizePolicy(sizePolicy) + if menu: bar.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) bar.customContextMenuRequested.connect(bar.tab_menu) diff --git a/preditor/gui/group_tab_widget/__init__.py b/preditor/gui/group_tab_widget/__init__.py index a5e90a5f..c4815592 100644 --- a/preditor/gui/group_tab_widget/__init__.py +++ b/preditor/gui/group_tab_widget/__init__.py @@ -3,10 +3,8 @@ from pathlib import Path from Qt.QtCore import Qt -from Qt.QtGui import QIcon -from Qt.QtWidgets import QHBoxLayout, QMessageBox, QToolButton, QWidget +from Qt.QtWidgets import QHBoxLayout, QMessageBox, QSizePolicy, QToolButton, QWidget -from ... import resourcePath from ...prefs import VersionTypes, get_backup_version_info from ..drag_tab_bar import DragTabBar from ..workbox_text_edit import WorkboxTextEdit @@ -16,10 +14,10 @@ DEFAULT_STYLE_SHEET = """ /* Make the two buttons in the GroupTabWidget take up the - same horizontal space as the GroupedTabWidget's buttons. */ + same horizontal space as the GroupedTabWidget's buttons. GroupTabWidget>QTabBar::tab{ max-height: 1.5em; -} +}*/ /* We have an icon, no need to show the menu indicator */ #group_tab_widget_menu_btn::menu-indicator{ width: 0px; @@ -51,26 +49,37 @@ def __init__(self, editor_kwargs=None, core_name=None, *args, **kwargs): corner = QWidget(self) lyt = QHBoxLayout(corner) lyt.setSpacing(0) - lyt.setContentsMargins(0, 0, 0, 0) + lyt.setContentsMargins(0, 5, 0, 0) corner.uiNewTabBTN = QToolButton(corner) corner.uiNewTabBTN.setObjectName('group_tab_widget_new_btn') corner.uiNewTabBTN.setText('+') - corner.uiNewTabBTN.setIcon(QIcon(resourcePath('img/file-plus.png'))) corner.uiNewTabBTN.released.connect(lambda: self.add_new_tab(None)) + lyt.addWidget(corner.uiNewTabBTN) corner.uiMenuBTN = QToolButton(corner) - corner.uiMenuBTN.setIcon(QIcon(resourcePath('img/chevron-down.png'))) + corner.uiMenuBTN.setText('\u2630') corner.uiMenuBTN.setObjectName('group_tab_widget_menu_btn') corner.uiMenuBTN.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) corner.uiCornerMENU = GroupTabMenu(self, parent=corner.uiMenuBTN) corner.uiMenuBTN.setMenu(corner.uiCornerMENU) + + self.adjustSizePolicy(corner) + self.adjustSizePolicy(corner.uiNewTabBTN) + self.adjustSizePolicy(corner.uiMenuBTN) + self.adjustSizePolicy(corner.uiCornerMENU) + lyt.addWidget(corner.uiMenuBTN) self.uiCornerBTN = corner self.setCornerWidget(self.uiCornerBTN, Qt.Corner.TopRightCorner) + def adjustSizePolicy(self, button): + sp = button.sizePolicy() + sp.setVerticalPolicy(QSizePolicy.Policy.Preferred) + button.setSizePolicy(sp) + def add_new_tab(self, group, title=None, prefs=None): """Adds a new tab to the requested group, creating the group if the group doesn't exist. @@ -110,6 +119,7 @@ def add_new_tab(self, group, title=None, prefs=None): editor = parent.add_new_editor(title, prefs) self.setCurrentIndex(self.indexOf(parent)) self.window().focusToWorkbox() + self.tabBar().setFont(self.window().font()) return parent, editor def all_widgets(self): diff --git a/preditor/gui/group_tab_widget/grouped_tab_widget.py b/preditor/gui/group_tab_widget/grouped_tab_widget.py index f8b3a4f4..efa0faab 100644 --- a/preditor/gui/group_tab_widget/grouped_tab_widget.py +++ b/preditor/gui/group_tab_widget/grouped_tab_widget.py @@ -1,10 +1,8 @@ from __future__ import absolute_import from Qt.QtCore import Qt -from Qt.QtGui import QIcon from Qt.QtWidgets import QMessageBox, QToolButton -from ... import resourcePath from ...prefs import VersionTypes from ..drag_tab_bar import DragTabBar from ..workbox_text_edit import WorkboxTextEdit @@ -24,7 +22,6 @@ def __init__(self, editor_kwargs, editor_cls=None, core_name=None, *args, **kwar self.uiCornerBTN = QToolButton(self) self.uiCornerBTN.setText('+') - self.uiCornerBTN.setIcon(QIcon(resourcePath('img/file-plus.png'))) self.uiCornerBTN.released.connect(lambda: self.add_new_editor()) self.setCornerWidget(self.uiCornerBTN, Qt.Corner.TopRightCorner) diff --git a/preditor/gui/group_tab_widget/one_tab_widget.py b/preditor/gui/group_tab_widget/one_tab_widget.py index 1ba1ad57..5863f3e6 100644 --- a/preditor/gui/group_tab_widget/one_tab_widget.py +++ b/preditor/gui/group_tab_widget/one_tab_widget.py @@ -60,6 +60,7 @@ def get_next_available_tab_name(self, name): def addTab(self, *args, **kwargs): # noqa: N802 ret = super(OneTabWidget, self).addTab(*args, **kwargs) self.update_closable_tabs() + self.tabBar().setFont(self.window().font()) return ret def close_tab(self, index): diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index ab899327..1de4d40f 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -23,9 +23,11 @@ QApplication, QFontDialog, QInputDialog, + QMenu, QMessageBox, QTextBrowser, QTextEdit, + QToolButton, QToolTip, QVBoxLayout, ) @@ -80,6 +82,11 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.setupStatusTimer() + # Define gui-resizing mods, which may need to be accessed by other modules. + ctrl = Qt.KeyboardModifier.ControlModifier + alt = Qt.KeyboardModifier.AltModifier + self.gui_font_mod = ctrl | alt + # Store the previous time a font-resize wheel event was triggered to prevent # rapid-fire WheelEvents. Initialize to the current time. self.previousFontResizeTime = datetime.now() @@ -156,13 +163,29 @@ def __init__(self, parent, name=None, run_workbox=False, standalone=False): self.uiAutoCompleteCaseSensitiveACT.toggled.connect(self.setCaseSensitive) self.uiSelectMonospaceFontACT.triggered.connect( - partial(self.selectFont, monospace=True) + partial(self.selectFont, origFont=None, monospace=True) ) self.uiSelectProportionalFontACT.triggered.connect( - partial(self.selectFont, proportional=True) + partial(self.selectFont, origFont=None, proportional=True) ) self.uiSelectAllFontACT.triggered.connect( - partial(self.selectFont, monospace=True, proportional=True) + partial(self.selectFont, origFont=None, monospace=True, proportional=True) + ) + self.uiSelectGuiFontsMENU.triggered.connect( + partial(self.selectGuiFont, monospace=True, proportional=True) + ) + + self.uiDecreaseCodeFontSizeACT.triggered.connect( + partial(self.adjustFontSize, "Code", -1) + ) + self.uiIncreaseCodeFontSizeACT.triggered.connect( + partial(self.adjustFontSize, "Code", 1) + ) + self.uiDecreaseGuiFontSizeACT.triggered.connect( + partial(self.adjustFontSize, "Gui", -1) + ) + self.uiIncreaseGuiFontSizeACT.triggered.connect( + partial(self.adjustFontSize, "Gui", 1) ) # Setup ability to cycle completer mode, and create action for each mode @@ -676,7 +699,19 @@ def getPrevCommand(self): def wheelEvent(self, event): """adjust font size on ctrl+scrollWheel""" - if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + mods = event.modifiers() + ctrl = Qt.KeyboardModifier.ControlModifier + shift = Qt.KeyboardModifier.ShiftModifier + alt = Qt.KeyboardModifier.AltModifier + + ctrlAlt = ctrl | alt + shiftAlt = shift | alt + + # Assign mods by functionality. Using shift | alt for gui, because just shift or + # just alt has existing functionality which also processes. + code_font_mod = ctrl + + if mods == code_font_mod or mods == self.gui_font_mod: # WheelEvents can be emitted in a cluster, but we only want one at a time # (ie to change font size by 1, rather than 2 or 3). Let's bail if previous # font-resize wheel event was within a certain threshhold. @@ -691,20 +726,43 @@ def wheelEvent(self, event): if hasattr(event, 'delta'): # Qt4 delta = event.delta() else: # QT5 - delta = event.angleDelta().y() + # Also holding alt reverses the data in angleDelta (!?), so transpose to + # get correct value + angleDelta = event.angleDelta() + if mods == alt or mods == ctrlAlt or mods == shiftAlt: + angleDelta = angleDelta.transposed() + delta = angleDelta.y() # convert delta to +1 or -1, depending delta = delta // abs(delta) minSize = 5 maxSize = 50 - font = self.console().font() + if mods == code_font_mod: + font = self.console().font() + elif mods == self.gui_font_mod: + font = self.font() newSize = font.pointSize() + delta newSize = max(min(newSize, maxSize), minSize) - self.setFontSize(newSize) + # If only ctrl was pressed, adjust code font size, otherwise adjust gui font + # size + if mods == self.gui_font_mod: + self.setGuiFont(newSize=newSize) + elif mods == code_font_mod: + self.setFontSize(newSize) else: Window.wheelEvent(self, event) + def adjustFontSize(self, kind, delta): + if kind == "Code": + size = self.console().font().pointSize() + size += delta + self.setFontSize(size) + else: + size = self.font().pointSize() + size += delta + self.setGuiFont(newSize=size) + def handleMenuHovered(self, action): """Qt4 doesn't have a ToolTipsVisible method, so we fake it""" # Don't show if it's just the text of the action @@ -722,7 +780,9 @@ def handleMenuHovered(self, action): menu = action.parent() QToolTip.showText(QCursor.pos(), text, menu) - def selectFont(self, monospace=False, proportional=False): + def selectFont( + self, origFont=None, monospace=False, proportional=False, doGui=False + ): """Present a QFontChooser dialog, offering, monospace, proportional, or all fonts, based on user choice. If a font is chosen, set it on the console and workboxes. @@ -730,7 +790,8 @@ def selectFont(self, monospace=False, proportional=False): Args: action (QAction): menu action associated with chosen font """ - origFont = self.console().font() + if origFont is None: + origFont = self.console().font() curFontFamily = origFont.family() if monospace and proportional: @@ -751,9 +812,45 @@ def selectFont(self, monospace=False, proportional=False): newFont, okClicked = QFontDialog.getFont(origFont, self, title, options=options) if okClicked: - self.console().setConsoleFont(newFont) - self.setWorkboxFontBasedOnConsole() - self.setEditorChooserFontBasedOnConsole() + if doGui: + self.setGuiFont(newFont=newFont) + else: + self.console().setConsoleFont(newFont) + self.setWorkboxFontBasedOnConsole() + self.setEditorChooserFontBasedOnConsole() + + def selectGuiFont(self, monospace=True, proportional=True): + font = self.font() + self.selectFont( + origFont=font, monospace=monospace, proportional=proportional, doGui=True + ) + + def setGuiFont(self, newSize=None, newFont=None): + current = self.uiWorkboxTAB.currentWidget() + if not current: + return + + tabbar_class = current.tabBar().__class__ + menubar_class = self.menuBar().__class__ + label_class = self.uiStatusLBL.__class__ + children = self.findChildren(tabbar_class, QtCore.QRegExp(".*")) + children.extend(self.findChildren(menubar_class, QtCore.QRegExp(".*"))) + children.extend(self.findChildren(label_class, QtCore.QRegExp(".*"))) + children.extend(self.findChildren(QToolButton, QtCore.QRegExp(".*"))) + children.extend(self.findChildren(QMenu, QtCore.QRegExp(".*"))) + children.extend(self.findChildren(QToolTip, QtCore.QRegExp(".*"))) + + for child in children: + if newFont is None: + newFont = child.font() + if newSize is None: + newSize = newFont.pointSize() + newFont.setPointSize(newSize) + child.setFont(newFont) + # child.resize() + self.setFont(newFont) + QToolTip.setFont(newFont) + # self.resize() def setFontSize(self, newSize): """Update the font size in the console and current workbox. @@ -763,6 +860,10 @@ def setFontSize(self, newSize): """ font = self.console().font() font.setPointSize(newSize) + # Also setPointSizeF, which is what gets written to prefs, to prevent + # needlessly writing prefs with only a change in pointSizeF precision. + font.setPointSizeF(font.pointSize()) + self.console().setConsoleFont(font) self.setWorkboxFontBasedOnConsole() @@ -1031,17 +1132,6 @@ def execSelected(self, truncate=True): if self.uiAutoPromptACT.isChecked(): self.console().startInputLine() - def keyPressEvent(self, event): - # Fix 'Maya : Qt tools lose focus' https://redmine.blur.com/issues/34430 - if event.modifiers() & ( - Qt.KeyboardModifier.AltModifier - | Qt.KeyboardModifier.ControlModifier - | Qt.KeyboardModifier.ShiftModifier - ): - pass - else: - super(LoggerWindow, self).keyPressEvent(event) - def clearExecutionTime(self): """Update status text with hyphens to indicate execution has begun.""" self.setStatusText('Exec: -.- Seconds') @@ -1075,6 +1165,7 @@ def recordPrefs(self, manual=False): 'wordWrap': self.uiWordWrapACT.isChecked(), 'clearBeforeRunning': self.uiClearBeforeRunningACT.isChecked(), 'toolbarStates': str(self.saveState().toHex(), 'utf-8'), + 'guiFont': self.font().toString(), 'consoleFont': self.console().font().toString(), 'uiAutoSaveSettingssACT': self.uiAutoSaveSettingssACT.isChecked(), 'uiAutoPromptACT': self.uiAutoPromptACT.isChecked(), @@ -1382,12 +1473,18 @@ def restorePrefs(self, skip_geom=False): # Ensure the correct workbox stack page is shown self.update_workbox_stack() - _font = pref.get('consoleFont', None) - if _font: + fontStr = pref.get('consoleFont', None) + if fontStr: font = QFont() - if QtCompat.QFont.fromString(font, _font): + if QtCompat.QFont.fromString(font, fontStr): self.console().setConsoleFont(font) + guiFontStr = pref.get('guiFont', None) + if guiFontStr: + guiFont = QFont() + if QtCompat.QFont.fromString(guiFont, guiFontStr): + self.setGuiFont(newFont=guiFont) + self.dont_ask_again = pref.get('dont_ask_again', []) # Allow any plugins to restore their own preferences diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index 95870ec1..f7634514 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -186,6 +186,11 @@ + + + + + @@ -1037,6 +1042,55 @@ at the indicated line in the specified text editor. Ctrl+Alt+Shift+] + + + Select Gui Font + + + + + Increase Code Font Size + + + ..or Ctrl+Scroll Up + + + Ctrl++ + + + + + Increase Gui Font Size + + + ..or Alt+Scroll Up + + + Ctrl+Alt++ + + + + + Decrease Code Font Size + + + ..or Ctrl+Scroll Down + + + Ctrl+- + + + + + Decrease Gui Font Size + + + ..or Alt+Scroll Down + + + Ctrl+Alt+- + + true diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index 024027f2..d69b21aa 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -81,6 +81,8 @@ def __init__(self, parent, filename='', lineno=0, delayable_engine='default'): self.pos = None self.anchor = None + self.ctrlIsPressed = False + # create custom properties self._filename = '' self.additionalFilenames = [] @@ -92,7 +94,6 @@ def __init__(self, parent, filename='', lineno=0, delayable_engine='default'): self._lastSearchDirection = SearchDirection.First self._saveTimer = 0.0 self._autoReloadOnChange = False - self._enableFontResizing = True # QSci doesnt provide accessors to these values, so store them internally self._foldMarginBackgroundColor = QColor(224, 224, 224) self._foldMarginForegroundColor = QColor(Qt.GlobalColor.white) @@ -857,6 +858,25 @@ def keyPressEvent(self, event): altPressed = modifiers == Qt.KeyboardModifier.AltModifier altReturnPressed = altPressed and retPressed + ctrlPressed = modifiers == Qt.KeyboardModifier.ControlModifier + plusPressed = key == Qt.Key.Key_Plus + minusPressed = key == Qt.Key.Key_Minus + + # We will have logger window handle the ctrl++ or ctrl+- shortcuts for + # font resizing, so we bypass the normal SCintilla functionality. If we + # don't the SCintilla font will get out-of sync with the console, and also + # not be accessible by workbox.font() or workbox.__font__(). + + # For some reason, documentEditor doesn't receive a single combination + # (ie ctrl++) instead receiving two separate keyPressEvents, one for + # ctrl, and one for plus (+). So, we must store that ctrl is pressed, + # and unset that when the key is released. + if ctrlPressed: + self.ctrlIsPressed = True + # To determine ctrl combo, check our stored value for the ctrl key. + ctrlPlusPressed = self.ctrlIsPressed and plusPressed + ctrlMinusPressed = self.ctrlIsPressed and minusPressed + if key == Qt.Key.Key_Backtab: self.unindentSelection() elif key == Qt.Key.Key_Escape: @@ -883,6 +903,12 @@ def keyPressEvent(self, event): # Reset autoIndent property self.setAutoIndent(autoIndent) + elif ctrlMinusPressed: + # LoggerWindow will handle this + self.window().uiDecreaseCodeFontSizeACT.trigger() + elif ctrlPlusPressed: + # LoggerWindow will handle this + self.window().uiIncreaseCodeFontSizeACT.trigger() else: return QsciScintilla.keyPressEvent(self, event) @@ -896,6 +922,9 @@ def keyReleaseEvent(self, event): # When using the menu key, show the right click menu at the text # cursor, not the mouse cursor, it is not in the correct place. self.showMenu(QPoint(x, y)) + + elif event.key() == Qt.Key.Key_Control: + self.ctrlIsPressed = False else: return super(DocumentEditor, self).keyReleaseEvent(event) @@ -1428,6 +1457,8 @@ def showAutoComplete(self, toggle=False): def showMenu(self, pos, popup=True): menu = QMenu(self) + menu.setFont(self.window().font()) + pos = self.mapToGlobal(pos) self._clickPos = pos @@ -1802,47 +1833,14 @@ def windowTitle(self): return title def wheelEvent(self, event): - if ( - self._enableFontResizing - and event.modifiers() == Qt.KeyboardModifier.ControlModifier + """Scroll-wheel based font resizing is handled by LoggerWindow, so prevent + the built-in QScintilla functionality. Only proceed if ctrl is not part + of the event modifiers. + """ + if not ( + event.modifiers() == self.window().gui_font_mod + or event.modifiers() == Qt.KeyboardModifier.ControlModifier ): - # If used in LoggerWindow, use that wheel event - # May not want to import LoggerWindow, so perhaps - # check by str(type()) - # if isinstance(self.window(), "LoggerWindow"): - if "LoggerWindow" in str(type(self.window())): - self.window().wheelEvent(event) - return - - font = self.documentFont - marginsFont = self.marginsFont() - lexer = self.lexer() - if lexer: - font = lexer.font(0) - try: - # Qt5 support - delta = event.angleDelta().y() - except Exception: - # Qt4 support - delta = event.delta() - if delta > 0: - font.setPointSize(font.pointSize() + 1) - marginsFont.setPointSize(marginsFont.pointSize() + 1) - else: - if font.pointSize() - 1 > 0: - font.setPointSize(font.pointSize() - 1) - if marginsFont.pointSize() - 1 > 0: - marginsFont.setPointSize(marginsFont.pointSize() - 1) - - self.setMarginsFont(marginsFont) - if lexer: - lexer.setFont(font) - else: - self.setFont(font) - - self.fontsChanged.emit(font, marginsFont) - event.accept() - else: super(DocumentEditor, self).wheelEvent(event) # expose properties for the designer From 05bd8faf1151b64340bb4c39804cbdf63a9f003e Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 7 Oct 2024 08:04:49 -0700 Subject: [PATCH 05/10] Generalize launching dialogs with matching font --- preditor/gui/loggerwindow.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 1de4d40f..672b4925 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -17,7 +17,7 @@ import __main__ import Qt as Qt_py from Qt import QtCompat, QtCore, QtWidgets -from Qt.QtCore import QByteArray, QFileSystemWatcher, Qt, QTimer, Signal, Slot +from Qt.QtCore import QByteArray, QFileSystemWatcher, QObject, Qt, QTimer, Signal, Slot from Qt.QtGui import QCursor, QFont, QIcon, QKeySequence, QTextCursor from Qt.QtWidgets import ( QApplication, @@ -624,6 +624,7 @@ def change_to_workbox_version_text(self, versionType): def openSetPreferredTextEditorDialog(self): dlg = SetTextEditorPathDialog(parent=self) + self.setDialogFont(dlg) dlg.exec() def focusToConsole(self): @@ -891,10 +892,17 @@ def setEditorChooserFontBasedOnConsole(self): """Set the EditorChooser font to match console. This helps with legibility when using EditorChooser. """ - font = self.console().font() - for child in self.uiEditorChooserWGT.children(): - if hasattr(child, "font"): - child.setFont(font) + self.setDialogFont(self.uiEditorChooserWGT) + + def setDialogFont(self, dialog): + """Helper for when creating a dialog to have the font match the PrEditor font + + Args: + dialog (QDialog): The dialog for which to set the font + """ + for thing in dialog.findChildren(QObject): + if hasattr(thing, "setFont"): + thing.setFont(self.font()) @classmethod def _genPrefName(cls, baseName, index): From 6ac1bc69d9540968b5c57171ef87329a1adc20bc Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 3 Nov 2025 12:21:31 -0800 Subject: [PATCH 06/10] Make AutoSave getter / setter --- preditor/gui/loggerwindow.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 672b4925..5582e128 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -380,6 +380,22 @@ def apply_options(self): self.update_workbox_stack() + def autoSaveEnabled(self): + """Whether or not AutoSave option is set + + Returns: + bool: hether AutoSave option is checked or not + """ + return self.uiAutoSaveSettingssACT.isChecked() + + def setAutoSaveEnabled(self, state): + """Set AutoSave option to state + + Args: + state (bool): State to set AutoSave option + """ + self.uiAutoSaveSettingssACT.setChecked(state) + def loadPlugins(self): """Load any plugins that modify the LoggerWindow.""" self.plugins = {} @@ -1151,7 +1167,7 @@ def reportExecutionTime(self, seconds): self.uiMenuBar.adjustSize() def recordPrefs(self, manual=False): - if not manual and not self.uiAutoSaveSettingssACT.isChecked(): + if not manual and not self.autoSaveEnabled(): return origPref = self.load_prefs() @@ -1175,7 +1191,7 @@ def recordPrefs(self, manual=False): 'toolbarStates': str(self.saveState().toHex(), 'utf-8'), 'guiFont': self.font().toString(), 'consoleFont': self.console().font().toString(), - 'uiAutoSaveSettingssACT': self.uiAutoSaveSettingssACT.isChecked(), + 'uiAutoSaveSettingssACT': self.autoSaveEnabled(), 'uiAutoPromptACT': self.uiAutoPromptACT.isChecked(), 'uiLinesInNewWorkboxACT': self.uiLinesInNewWorkboxACT.isChecked(), 'uiErrorHyperlinksACT': self.uiErrorHyperlinksACT.isChecked(), @@ -1428,7 +1444,7 @@ def restorePrefs(self, skip_geom=False): self.uiSpellCheckEnabledACT.setChecked(pref.get('spellCheckEnabled', False)) self.uiSpellCheckEnabledACT.setDisabled(False) - self.uiAutoSaveSettingssACT.setChecked(pref.get('uiAutoSaveSettingssACT', True)) + self.setAutoSaveEnabled(pref.get('uiAutoSaveSettingssACT', True)) self.uiAutoPromptACT.setChecked(pref.get('uiAutoPromptACT', False)) self.uiLinesInNewWorkboxACT.setChecked( From 088f73522d8c3830d56c284a1ec8adef69ad7d86 Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Wed, 29 Oct 2025 12:13:16 -0700 Subject: [PATCH 07/10] Removed unused arg and method --- preditor/scintilla/documenteditor.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index d69b21aa..95b9f791 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -85,7 +85,6 @@ def __init__(self, parent, filename='', lineno=0, delayable_engine='default'): # create custom properties self._filename = '' - self.additionalFilenames = [] self._language = '' self._defaultLanguage = "" self._lastSearch = '' @@ -1029,16 +1028,6 @@ def setPermaHighlight(self, value): if not isinstance(value, list): raise TypeError('PermaHighlight must be a list') - def refreshToolTip(self): - # TODO: This will proably be removed once I add a user interface to - # additionalFilenames. - toolTip = [] - if self.additionalFilenames: - toolTip.append('Additional Filenames:') - for filename in self.additionalFilenames: - toolTip.append(filename) - self.setToolTip('\n
'.join(toolTip)) - def reloadFile(self): return self.reloadDialog( 'Are you sure you want to reload %s? You will lose all changes' @@ -1160,10 +1149,6 @@ def refreshTitle(self): def save(self): logger.debug(' Saved Called'.center(60, '-')) ret = self.saveAs(self.filename()) - # If the user has provided additionalFilenames to save, process each of them - # without switching the current filename. - for filename in self.additionalFilenames: - self.saveAs(filename, setFilename=False) return ret def saveAs(self, filename='', setFilename=True, directory=''): @@ -1827,9 +1812,6 @@ def windowTitle(self): if self.isModified(): title += '*' - if self.additionalFilenames: - title = '[{}]'.format(title) - return title def wheelEvent(self, event): From 49189a6b4d4739397e714496a405dd952ad8062e Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 3 Nov 2025 12:16:42 -0800 Subject: [PATCH 08/10] Refactor to support WorkboxTextEdit and overall cleaner structure --- preditor/gui/console.py | 10 +- preditor/gui/drag_tab_bar.py | 60 +-- preditor/gui/group_tab_widget/__init__.py | 24 +- .../group_tab_widget/grouped_tab_widget.py | 33 +- preditor/gui/loggerwindow.py | 17 +- preditor/gui/workbox_mixin.py | 430 ++++++++++++++++-- preditor/gui/workbox_text_edit.py | 36 +- preditor/gui/workboxwidget.py | 121 +++-- preditor/scintilla/documenteditor.py | 183 +------- 9 files changed, 569 insertions(+), 345 deletions(-) diff --git a/preditor/gui/console.py b/preditor/gui/console.py index ad9afcdf..7c42c970 100644 --- a/preditor/gui/console.py +++ b/preditor/gui/console.py @@ -287,7 +287,7 @@ def errorHyperlink(self): cmdTempl = window.textEditorCmdTempl # Bail if not setup properly - if workboxName is None: + if not workboxName: msg = ( "Cannot use traceback hyperlink (Correct the path with Options " "> Set Preferred Text Editor Path).\n" @@ -334,7 +334,7 @@ def errorHyperlink(self): ) subprocess.Popen(command) except (ValueError, OSError): - msg = "The provided text editor command template is not valid:\n {}" + msg = "The provided text editor command is not valid:\n {}" msg = msg.format(cmdTempl) print(msg) elif workboxName is not None: @@ -427,9 +427,11 @@ def getWorkboxLine(self, name, lineNum): workbox = self.window().workbox_for_name(name) if not workbox: return None - if lineNum > workbox.lines(): + + num_lines = workbox.__num_lines__() + if lineNum > num_lines: return None - txt = workbox.text(lineNum).strip() + "\n" + txt = workbox.__text_line__(lineNum=lineNum).strip() + "\n" return txt def executeString( diff --git a/preditor/gui/drag_tab_bar.py b/preditor/gui/drag_tab_bar.py index 6fed673b..5f4278d5 100644 --- a/preditor/gui/drag_tab_bar.py +++ b/preditor/gui/drag_tab_bar.py @@ -107,8 +107,8 @@ def get_color_and_tooltip(self, index): widget = self.parent().widget(index) filename = None - if hasattr(widget, "text"): - filename = widget.__filename__() or widget._filename_pref + if hasattr(widget, "__filename__"): + filename = widget.__filename__() if widget.__changed_by_instance__(): if filename: @@ -145,13 +145,12 @@ def get_color_and_tooltip(self, index): else: state = TabStates.Dirty toolTip = "Workbox has unsaved changes, or it's name has changed." - elif filename: - if Path(filename).is_file(): - state = TabStates.Linked - toolTip = "Linked to file on disk" - else: - state = TabStates.MissingLinked - toolTip = "Linked file is missing" + elif widget.__is_missing_linked_file__(): + state = TabStates.MissingLinked + toolTip = "Linked file is missing" + elif hasattr(widget, "__filename__") and widget.__filename__(): + state = TabStates.Linked + toolTip = "Linked to file on disk" if hasattr(widget, "__workbox_id__"): workbox_id = widget.__workbox_id__() @@ -347,8 +346,8 @@ def tab_menu(self, pos, popup=True): # Show File-related actions depending if filename already set. Don't include # Rename if the workbox is linked to a file. - if hasattr(workbox, 'filename'): - if not workbox.filename(): + if hasattr(workbox, '__filename__'): + if not workbox.__filename__(): act = menu.addAction('Rename') act.triggered.connect(self.rename_tab) @@ -358,7 +357,7 @@ def tab_menu(self, pos, popup=True): act = menu.addAction('Save and Link File') act.triggered.connect(partial(self.save_and_link_file, workbox)) else: - if Path(workbox.filename()).is_file(): + if Path(workbox.__filename__()).is_file(): act = menu.addAction('Explore File') act.triggered.connect(partial(self.explore_file, workbox)) @@ -397,14 +396,21 @@ def link_file(self, workbox): Args: workbox (WorkboxMixin): The workbox contained in the clicked tab """ - filename = workbox.filename() + filename = workbox.__filename__() + workbox.__set_file_monitoring_enabled__(False) + workbox.__set_filename__("") filename, _other = QFileDialog.getOpenFileName(directory=filename) if filename and Path(filename).is_file(): + + # First, save any unsaved text + workbox.__save_prefs__() + + # Now, load file workbox.__load__(filename) - workbox._filename_pref = filename - workbox._filename = filename - name = Path(filename).name + workbox.__set_filename__(filename) + workbox.__set_file_monitoring_enabled__(True) + name = Path(filename).name self.setTabText(self._context_menu_tab, name) self.update() self.window().setWorkboxFontBasedOnConsole(workbox=workbox) @@ -417,21 +423,24 @@ def save_and_link_file(self, workbox): Args: workbox (WorkboxMixin): The workbox contained in the clicked tab """ - filename = workbox.filename() + filename = workbox.__filename__() + if filename and Path(filename).is_file(): + workbox.__set_file_monitoring_enabled__(False) directory = six.text_type(Path(filename).parent) if filename else "" - success = workbox.saveAs(directory=directory) + success = workbox.__save_as__(directory=directory) if not success: return - filename = workbox.filename() - workbox._filename_pref = filename - workbox._filename = filename - workbox.__set_last_workbox_name__(workbox.__workbox_name__()) + # Workbox + filename = workbox.__filename__() + workbox.__set_last_saved_text__(workbox.__text__()) + workbox.__set_file_monitoring_enabled__(True) name = Path(filename).name self.setTabText(self._context_menu_tab, name) self.update() self.window().setWorkboxFontBasedOnConsole(workbox=workbox) + workbox.__set_last_workbox_name__(workbox.__workbox_name__()) def explore_file(self, workbox): """Open a system file explorer at the path of the linked file. @@ -439,7 +448,7 @@ def explore_file(self, workbox): Args: workbox (WorkboxMixin): The workbox contained in the clicked tab """ - path = Path(workbox._filename_pref) + path = Path(workbox.__filename__()) if path.exists(): osystem.explore(str(path)) elif path.parent.exists(): @@ -451,9 +460,8 @@ def unlink_file(self, workbox): Args: workbox (WorkboxMixin): The workbox contained in the clicked tab """ - workbox.updateFilename("") - workbox._filename_pref = "" - + workbox.__set_file_monitoring_enabled__(False) + workbox.__set_filename__("") name = self.parent().default_title self.setTabText(self._context_menu_tab, name) diff --git a/preditor/gui/group_tab_widget/__init__.py b/preditor/gui/group_tab_widget/__init__.py index c4815592..7e596fed 100644 --- a/preditor/gui/group_tab_widget/__init__.py +++ b/preditor/gui/group_tab_widget/__init__.py @@ -97,7 +97,7 @@ def add_new_tab(self, group, title=None, prefs=None): WorkboxMixin: The new text editor. """ if not group: - group = self.get_next_available_tab_name(self.default_title) + group = self.get_next_available_tab_name() elif group is True: group = self.currentIndex() @@ -172,6 +172,20 @@ def default_tab(self, title=None, prefs=None): ) return widget, title + def get_next_available_tab_name(self, name=None): + """Get the next available tab name, providing a default if needed. + + Args: + name (str, optional): The name for which to get the next available + name. + + Returns: + str: The determined next available tab name + """ + if name is None: + name = self.default_title + return super().get_next_available_tab_name(name) + def append_orphan_workboxes_to_prefs(self, prefs, existing_by_group): """If prefs are saved in a different PrEditor instance (in this same core) there may be a workbox which is either: @@ -343,9 +357,9 @@ def restore_prefs(self, prefs): # Support legacy arg for emergency backwards compatibility tempfile = tab.get('tempfile', None) # Get various possible saved filepaths. - filename_pref = tab.get('filename', "") - if filename_pref: - if Path(filename_pref).is_file(): + filename = tab.get('filename', "") + if filename: + if Path(filename).is_file(): loadable = True # See if there are any workbox backups available @@ -361,7 +375,7 @@ def restore_prefs(self, prefs): # tab if it hasn't already been created. prefs = dict( workbox_id=workbox_id, - filename=filename_pref, + filename=filename, backup_file=backup_file, existing_editor_info=existing_by_id.pop(workbox_id, None), orphaned_by_instance=orphaned_by_instance, diff --git a/preditor/gui/group_tab_widget/grouped_tab_widget.py b/preditor/gui/group_tab_widget/grouped_tab_widget.py index efa0faab..b2c8b680 100644 --- a/preditor/gui/group_tab_widget/grouped_tab_widget.py +++ b/preditor/gui/group_tab_widget/grouped_tab_widget.py @@ -79,6 +79,22 @@ def __is_dirty__(self): break return is_dirty + def __is_missing_linked_file__(self): + """Determine if any of the workboxes are linked to file which is missing + on disk. + + Returns: + bool: Whether any of this group's workboxes define a linked file + which is missing on disk. + """ + is_missing_linked_file = False + for workbox_idx in range(self.count()): + workbox = self.widget(workbox_idx) + if workbox.__is_missing_linked_file__(): + is_missing_linked_file = True + break + return is_missing_linked_file + def add_new_editor(self, title=None, prefs=None): title = title or self.default_title @@ -120,6 +136,7 @@ def close_tab(self, index): # Keep track of deleted tabs, make re-openable # Maybe also move workbox dir to a 'removed workboxes' dir + _editor.__set_file_monitoring_enabled__(False) super(GroupedTabWidget, self).close_tab(index) def default_tab(self, title=None, prefs=None): @@ -138,7 +155,6 @@ def default_tab(self, title=None, prefs=None): if editor: editor.__load_workbox_version_text__(VersionTypes.Last) - editor.__set_tab_widget__(self) editor.__set_last_saved_text__(editor.text()) editor.__set_last_workbox_name__(editor.__workbox_name__()) @@ -154,12 +170,27 @@ def default_tab(self, title=None, prefs=None): editor.__set_orphaned_by_instance__(orphaned_by_instance) return editor, title + def get_next_available_tab_name(self, name=None): + """Get the next available tab name, providing a default if needed. + + Args: + name (str, optional): The name for which to get the next available + name. + + Returns: + str: The determined next available tab name + """ + if name is None: + name = self.default_title + return super().get_next_available_tab_name(name) + def showEvent(self, event): # noqa: N802 super(GroupedTabWidget, self).showEvent(event) self.tab_shown(self.currentIndex()) def tab_shown(self, index): editor = self.widget(index) + editor.__set_tab_widget__(self) if editor and editor.isVisible(): editor.__show__() diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 5582e128..3dbe520d 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -372,7 +372,7 @@ def apply_options(self): self.editor_cls_name = editor_cls_name self.uiWorkboxTAB.editor_cls = editor_cls # We need to change the editor, save all prefs - self.recordPrefs(manual=True) + self.recordPrefs(manual=True, disableFileMonitoring=True) # Clear the uiWorkboxTAB self.uiWorkboxTAB.clear() # Restore prefs to populate the tabs @@ -1103,13 +1103,15 @@ def linkedFileChanged(self, filename): self.restorePrefs(skip_geom=True) else: for info in self.uiWorkboxTAB.all_widgets(): - editor, _, _, group_idx, editor_idx = info - if not editor.filename(): + editor, _, _, _, _ = info + if not editor or not editor.__filename__(): continue - if Path(editor.filename()).as_posix() == Path(filename).as_posix(): + if Path(editor.__filename__()) == Path(filename): + editor.__set_file_monitoring_enabled__(False) editor.__save_prefs__(saveLinkedFile=False) editor.__reload_file__() editor.__save_prefs__(saveLinkedFile=False, force=True) + editor.__set_file_monitoring_enabled__(True) def closeEvent(self, event): self.recordPrefs() @@ -1166,10 +1168,15 @@ def reportExecutionTime(self, seconds): self.uiStatusLBL.showSeconds(seconds) self.uiMenuBar.adjustSize() - def recordPrefs(self, manual=False): + def recordPrefs(self, manual=False, disableFileMonitoring=False): if not manual and not self.autoSaveEnabled(): return + if disableFileMonitoring: + for editor_info in self.uiWorkboxTAB.all_widgets(): + editor = editor_info[0] + editor.__set_file_monitoring_enabled__(False) + origPref = self.load_prefs() pref = copy.deepcopy(origPref) geo = self.geometry() diff --git a/preditor/gui/workbox_mixin.py b/preditor/gui/workbox_mixin.py index 1ce4fa8d..de9dd989 100644 --- a/preditor/gui/workbox_mixin.py +++ b/preditor/gui/workbox_mixin.py @@ -1,14 +1,19 @@ from __future__ import absolute_import, print_function +import enum import io +import logging import os +import sys import tempfile import textwrap +import time from pathlib import Path import chardet +import Qt as Qt_py from Qt.QtCore import Qt -from Qt.QtWidgets import QStackedWidget +from Qt.QtWidgets import QMessageBox, QStackedWidget from ..prefs import ( VersionTypes, @@ -19,6 +24,14 @@ get_relative_path, ) +logger = logging.getLogger(__name__) + + +class EolTypes(enum.Enum): + EolWindows = '\r\n' + EolUnix = '\n' + EolMac = '\r' + class WorkboxName(str): """The joined name of a workbox `group/workbox` with access to its parts. @@ -79,8 +92,18 @@ def __init__( self._show_blank = False self._tempdir = None + self._dialogShown = False + self.core_name = core_name + if not workbox_id: + workbox_id = self.__create_workbox_id__(self.core_name) + self.__set_workbox_id__(workbox_id) + + self.__set_filename__(filename) + self.__set_backup_file__(backup_file) + self.__set_tempfile__(tempfile) + self._tab_widget = parent self.__set_last_saved_text__("") @@ -88,10 +111,28 @@ def __init__( # wait until __show__ so that we know the tab exists, and has tabText self._last_workbox_name = None + self._autoReloadOnChange = False + self.__set_orphaned_by_instance__(False) self.__set_changed_by_instance__(False) self._changed_saved = False + def __auto_reload_on_change__(self): + """Whether the option to auto-reload linked files is set + + Returns: + bool: Whether the option to auto-reload linked files is set + """ + return self._autoReloadOnChange + + def __set_auto_reload_on_change__(self, state): + """Set the option to auto-reload linked files to state + + Args: + state (bool): The state to set the auto-reload linked files option + """ + self._autoReloadOnChange = state + def __set_last_saved_text__(self, text): """Store text as last_saved_text on this workbox so checking if if_dirty is quick. @@ -182,7 +223,9 @@ def __set_cursor_position__(self, line, index): raise NotImplementedError("Mixin method not overridden.") def __exec_all__(self): - raise NotImplementedError("Mixin method not overridden.") + txt = self.__unix_end_lines__(self.__text__()).rstrip() + title = self.__workbox_trace_title__() + self.__console__().executeString(txt, filename=title) def __exec_selected__(self, truncate=True): txt, lineNum = self.__selected_text__() @@ -214,7 +257,7 @@ def __file_monitoring_enabled__(self): """Returns True if this workbox supports file monitoring. This allows the editor to update its text if the linked file is changed on disk.""" - raise NotImplementedError("Mixin method not overridden.") + return self.window().fileMonitoringEnabled(self.__filename__()) def __set_file_monitoring_enabled__(self, state): """Enables/Disables open file change monitoring. If enabled, A dialog will pop @@ -226,10 +269,15 @@ def __set_file_monitoring_enabled__(self, state): """ # if file monitoring is enabled and we have a file name then set up the file # monitoring - raise NotImplementedError("Mixin method not overridden.") + self.window().setFileMonitoringEnabled(self.__filename__(), state) def __filename__(self): - raise NotImplementedError("Mixin method not overridden.") + """The workboxes filename (ie linked file), if any + + Returns: + str: The workboxes filename (ie linked file), if any + """ + return self._filename def __set_filename__(self, filename): """Set this workboxes linked filename to the provided filename @@ -238,7 +286,27 @@ def __set_filename__(self, filename): filename (str): The filename to link to """ self._filename = filename - self._filename_pref = filename + + def __tempfile__(self): + """The workboxes defined tempfile, if any. + This property is now obsolete, but retained to more easily facilitate if + a user needs to revert to PrEditor version before the workbox overhaul. + + Returns: + str: The workboxes filename (ie linked file), if any + """ + return self._tempfile + + def __set_tempfile__(self, filename): + """Set this workboxes tempfile to the provided filename + + This property is now obsolete, but retained to more easily facilitate if + a user needs to revert to PrEditor version before the workbox overhaul. + + Args: + filename (str): The filename to link to + """ + self._tempfile = filename def __font__(self): raise NotImplementedError("Mixin method not overridden.") @@ -290,6 +358,7 @@ def __workbox_trace_title__(self, selection=False): def __workbox_name__(self, workbox=None): """Returns the name for this workbox or a given workbox. The name is the group tab text and the workbox tab text joined by a `/`""" + workbox = workbox if workbox else self workboxTAB = self.window().uiWorkboxTAB group_name = None workbox_name = None @@ -312,12 +381,12 @@ def __workbox_name__(self, workbox=None): workbox_name = cur_group_widget.tabText(workbox_idx) break else: - grouped = self.__tab_widget__() + grouped = workbox.__tab_widget__() groupedTabBar = grouped.tabBar() idx = -1 for idx in range(grouped.count()): - if grouped.widget(idx) == self: + if grouped.widget(idx) == workbox: break workbox_name = groupedTabBar.tabText(idx) @@ -349,11 +418,82 @@ def __insert_text__(self, txt): raise NotImplementedError("Mixin method not overridden.") def __load__(self, filename): - raise NotImplementedError("Mixin method not overridden.") + """Load the given filename. If this method is overridden in a subclass, + to do extra functionality, make sure to also call this method, ie + super().__load__(). + + Args: + filename (str): The file to load + """ + if filename and Path(filename).is_file(): + self._encoding, text = self.__open_file__(filename) + self.__set_text__(text) + self.__set_file_monitoring_enabled__(True) + self.__set_filename__(filename) + + # Determine new workbox name so we can store it + cur_workbox_name = self.__workbox_name__() + group_name = cur_workbox_name.group + new_name = Path(filename).name + new_workbox_name = WorkboxName(group_name, new_name) + self.__set_last_workbox_name__(new_workbox_name) + + self.__set_last_saved_text__(self.__text__()) + else: + self.__set_filename__("") def __margins_font__(self): raise NotImplementedError("Mixin method not overridden.") + def __lines__(self): + """A list of all the lines of text contained in this workbox. + + Returns: + list: A list of all the lines of text contained in this workbox. + """ + txt = self.__text__() + eol = self.__detect_eol__(txt) + lines = txt.split(eol.value) + return lines + + def __num_lines__(self): + """The number of lines contained in this workbox. + + Returns: + int: The number of lines contained in this workbox. + """ + num_lines = len(self.__lines__()) + return num_lines + + def __detect_eol__(self, text): + """Determine the eol (end-of-line) type for this file, such as Windows, + Linux or Mac. + + Args: + text (str): The text for which to determine eol characters. + + Returns: + EolTypes: The determined eol type. + """ + newlineN = text.find('\n') + newlineR = text.find('\r') + if newlineN != -1 and newlineR != -1: + if newlineN == newlineR + 1: + # CR LF Windows + return EolTypes.EolWindows + if newlineN != -1 and newlineR != -1: + if newlineN < newlineR: + # First return is a LF + return EolTypes.EolUnix + else: + # first return is a CR + return EolTypes.EolMac + if newlineN != -1: + return EolTypes.EolUnix + if sys.platform == 'win32': + return EolTypes.EolWindows + return EolTypes.EolUnix + def __set_margins_font__(self, font): raise NotImplementedError("Mixin method not overridden.") @@ -364,13 +504,166 @@ def __marker_clear_all__(self): raise NotImplementedError("Mixin method not overridden.") def __reload_file__(self): - raise NotImplementedError("Mixin method not overridden.") + """Reload this workbox's linked file.""" + # Loading the file too quickly misses any changes + time.sleep(0.1) + font = self.__font__() + + choice = self.__show_reload_dialog__() + if choice is True: + self.__load__(self.__filename__()) + + self.__set_last_saved_text__(self.__text__()) + self.__set_last_workbox_name__(self.__workbox_name__()) + self.__set_font__(font) + + def __single_messagebox__(self, title, message): + """Display a messagebox, but only once, in case this is triggered by a + signal which gets received multiple times. + + Args: + title (str): The title for the messagebox + message (str): The descriptive text explaining the situation to the + user, which requires the messagebox. + + Returns: + choice (bool): Whether the user accepted the dialog or not. + """ + choice = False + buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + + if not self._dialogShown: + self._dialogShown = True + result = QMessageBox.question(self.window(), title, message, buttons) + choice = result == QMessageBox.StandardButton.Yes + self._dialogShown = False + return choice + + def __show_reload_dialog__(self): + """Show a messagebox asking if user wants to reload the linked-file, + which has changed externally. + + Returns: + choice (bool): Whether user chose to reload the link file (or + auto-reload setting is True) + """ + choice = None + if self.__auto_reload_on_change__(): + choice = True + else: + name = Path(self.__filename__()).name + title = 'Reload File...' + msg = f'Are you sure you want to reload {name}?' + choice = self.__single_messagebox__(title, msg) + return choice + + def __set_workbox_title__(self, title): + """Set the tab-text on the grouped widget tab for this workbox. + + Args: + title (str): The text to put on the grouped tab's tabText. + """ + _group_idx, editor_idx = self.__group_tab_index__() + self.__tab_widget__().tabBar().setTabText(editor_idx, title) + + def __linked_file_changed__(self): + """If a file was modified or deleted this method + is called when Open File Monitoring is enabled. Returns True if the file + was updated or left open + + Returns: + bool: + """ + filename = self.__filename__() + if not Path(filename).is_file(): + # The file was deleted, ask the user if they still want to keep the file in + # the editor. + + title = 'File Removed...' + msg = f'File: {filename} has been deleted.\nKeep file in editor?' + choice = self.__single_messagebox__(title, msg) + + if choice is False: + logger.debug( + 'The file was deleted, removing document from editor', + ) + group_idx, editor_idx = self.__group_tab_index__() + self.__tab_widget__().close_tab(editor_idx) + return False + elif choice: + self.__set_filename__("") + title = self.__tab_widget__().get_next_available_tab_name() + self.__set_workbox_title__(title) + + # TODO: The file no longer exists, and the document should be marked as + # changed. + + if self.autoReloadOnChange() or not self.isModified(): + choice = True + else: + title = 'Reload File...' + msg = f'File: {filename} has been changed.\nReload from disk?' + choice = self.__single_messagebox__(title, msg) + + if choice is True: + self.__load__(self.__filename__()) def __remove_selected_text__(self): raise NotImplementedError("Mixin method not overridden.") def __save__(self): - raise NotImplementedError("Mixin method not overridden.") + """Save this workbox's linked file. + + Returns: + saved (bool): Whether the file was saved + """ + saved = self.__save_as__(self.__filename__()) + if saved: + self.__set_last_saved_text__(self.__text__()) + self.__set_last_workbox_name__(self.__workbox_name__()) + return saved + + def __save_as__(self, filename='', directory=''): + """Save as provided filename, or self.__filename__(). If this method is + overridden to add functionality, make sure to still call this method. + + Args: + filename (str, optional): The filename to save as + directory (str, optional): A directory to open the dialog at. + + Returns: + saved (bool): Whether the file has been saved + """ + # Disable file watching so workbox doesn't reload and scroll to the top + self.__set_file_monitoring_enabled__(False) + if not filename: + filename = self.__filename__() or directory + filename, extFilter = Qt_py.QtCompat.QFileDialog.getSaveFileName( + self.window(), 'Save File as...', filename + ) + + if filename: + # Save the file to disk + try: + txt = self.__text__() + self.__write_file__(filename, txt, encoding=self._encoding) + self.__set_filename__(filename) + self.__set_last_workbox_name__(self.__workbox_name__()) + self.__set_last_saved_text__(txt) + except PermissionError as error: + logger.debug('An error occurred while saving') + QMessageBox.question( + self.window(), + 'Error saving file...', + 'There was a error saving the file. Error: {}'.format(error), + QMessageBox.StandardButton.Ok, + ) + return False + + # Turn file watching back on. + self.__set_file_monitoring_enabled__(True) + return True + return False def __selected_text__(self, start_of_line=False, selectText=False): """Returns selected text or the current line of text, plus the line @@ -398,7 +691,7 @@ def __tab_width__(self): def __set_tab_width__(self, width): raise NotImplementedError("Mixin method not overridden.") - def __text__(self, line=None, start=None, end=None): + def __text__(self): """Returns the text in this widget, possibly limited in scope. Note: Only pass line, or (start and end) to this method. @@ -413,12 +706,23 @@ def __text__(self, line=None, start=None, end=None): """ raise NotImplementedError("Mixin method not overridden.") + def __text_line__(self, lineNum): + """Return the given line of the workbox's text. + + Args: + lineNum (int): The line number of the line of text to return + + Returns: + str: The found line of text + """ + return self.__lines__()[lineNum] + def __set_text__(self, txt): - """Replace all of the current text with txt. This method should be overridden - by sub-classes, and call super to mark the widget as having been loaded. - If text is being set on the widget, it most likely should be marked as - having been loaded. + """Replace all of the current text with txt. This method can be overridden + by sub-classes to accommodate that widget's text-setting method. Most + likely should also set self._is_loaded=True. """ + self.setText(txt) self._is_loaded = True def __is_dirty__(self): @@ -434,6 +738,19 @@ def __is_dirty__(self): ) return is_dirty + def __is_missing_linked_file__(self): + """Determine if this workbox is linked to a file which is missing on disk. + + Returns: + bool: Whether this workbox is linked to a file which is missing on + disk. + """ + missing = False + filename = self.__filename__() + if filename: + missing = not Path(filename).is_file() + return missing + def __truncate_middle__(self, s, n, sep=' ... '): """Truncates the provided text to a fixed length, putting the sep in the middle. https://www.xormedia.com/string-truncate-middle-with-ellipsis/ @@ -452,20 +769,18 @@ def __unix_end_lines__(cls, txt): """Replaces all windows and then mac line endings with unix line endings.""" return txt.replace('\r\n', '\n').replace('\r', '\n') - def __restore_prefs__(self, prefs): - self._filename_pref = prefs.get('filename') - self._workbox_id = prefs.get('workbox_id') - def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): ret = {} # Hopefully the alphabetical sorting of this dict is preserved in py3 # to make it easy to diff the json pref file if ever required. + + workbox_id = self.__workbox_id__() if current is not None: ret['current'] = current - ret['filename'] = self._filename_pref + ret['filename'] = self.__filename__() ret['name'] = self.__workbox_name__().workbox - ret['workbox_id'] = self._workbox_id + ret['workbox_id'] = workbox_id if self._tempfile: ret['tempfile'] = self._tempfile @@ -476,25 +791,23 @@ def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): return ret fullpath = get_full_path( - self.core_name, self._workbox_id, backup_file=self._backup_file + self.core_name, workbox_id, backup_file=self._backup_file ) time_str = None if self._changed_by_instance: time_str = self.window().latestTimeStrsForBoxesChangedViaInstance.get( - self._workbox_id, None + workbox_id, None ) if self._changed_saved: - self.window().latestTimeStrsForBoxesChangedViaInstance.pop( - self._workbox_id, None - ) + self.window().latestTimeStrsForBoxesChangedViaInstance.pop(workbox_id, None) self._changed_saved = False backup_exists = self._backup_file and Path(fullpath).is_file() if self.__is_dirty__() or not backup_exists or force: full_path = create_stamped_path( - self.core_name, self._workbox_id, time_str=time_str + self.core_name, workbox_id, time_str=time_str ) full_path = str(full_path) @@ -508,14 +821,13 @@ def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): if time_str: self.__set_changed_by_instance__(False) - if self.window().boxesOrphanedViaInstance.pop(self._workbox_id, None): + if self.window().boxesOrphanedViaInstance.pop(workbox_id, None): self.__set_orphaned_by_instance__(False) # If workbox is linked to file on disk, save it - if self._filename_pref and saveLinkedFile: - self._filename = self._filename_pref + if self.__filename__() and saveLinkedFile: self.__save__() - ret['workbox_id'] = self._workbox_id + ret['workbox_id'] = workbox_id self.__set_last_workbox_name__(self.__workbox_name__()) self.__set_last_saved_text__(self.__text__()) @@ -543,6 +855,14 @@ def __workbox_id__(self): """ return self._workbox_id + def __set_workbox_id__(self, workbox_id): + """Set this workbox's workbox_id to the provided workbox_id + + Args: + workbox_id (str): The workbox_id to set on this workbox + """ + self._workbox_id = workbox_id + def __backup_file__(self): """Returns this workbox's backup file @@ -551,6 +871,14 @@ def __backup_file__(self): """ return self._backup_file + def __set_backup_file__(self, filename): + """Set this workbox's backup file to the provided filename + + Args: + filename (str): The filename to set this workbox's backup_file to. + """ + self._backup_file = filename + def __set_changed_by_instance__(self, state): """Set whether this workbox has been determined to have been changed by a secondary PrEditor instance (in the same core). @@ -596,11 +924,13 @@ def __determine_been_changed_by_instance__(self): instance saving it's prefs. It sets the internal property self._changed_by_instance to indicate the result. """ - if not self._workbox_id: - self._workbox_id = self.__create_workbox_id__(self.core_name) + workbox_id = self.__workbox_id__() + if not workbox_id: + workbox_id = self.__create_workbox_id__(self.core_name) + self.__set_workbox_id__(workbox_id) - if self._workbox_id in self.window().latestTimeStrsForBoxesChangedViaInstance: - self.window().latestTimeStrsForBoxesChangedViaInstance.get(self._workbox_id) + if workbox_id in self.window().latestTimeStrsForBoxesChangedViaInstance: + self.window().latestTimeStrsForBoxesChangedViaInstance.get(workbox_id) self._changed_by_instance = True else: self._changed_by_instance = False @@ -620,7 +950,7 @@ def __get_workbox_version_text__(self, filename, versionType): the total count of files for this workbox. """ backup_file = get_full_path( - self.core_name, self._workbox_id, backup_file=self._backup_file + self.core_name, self.__workbox_id__(), backup_file=self._backup_file ) filepath, idx, count = get_backup_version_info( @@ -646,7 +976,7 @@ def __load_workbox_version_text__(self, versionType): index of this file in the stack of files, and the total count of files for this workbox. """ - data = self.__get_workbox_version_text__(self._workbox_id, versionType) + data = self.__get_workbox_version_text__(self.__workbox_id__(), versionType) txt, filepath, idx, count = data if filepath: @@ -654,7 +984,7 @@ def __load_workbox_version_text__(self, versionType): self._backup_file = str(filepath) - self.__set_text__(txt, update_last_saved_text=False) + self.__set_text__(txt) self.__tab_widget__().tabBar().update() filename = Path(filepath).name @@ -690,7 +1020,20 @@ def __open_file__(cls, filename, strict=True): return encoding, text @classmethod - def __write_file__(cls, filename, txt, encoding=None): + def __write_file__(cls, filename, txt=None, encoding=None, toUnixEOL=True): + """Write the provided text to the provided filename + + Args: + filename (str): The filename to write to + txt (str, optional): The text to write to file, or self__text__() + encoding (str, optional): The name of the encoding to use + toUnixEOL (bool, optional): Whether to force line endings to + unix-style. Typically, we do this for regular workboxes, but + not for linked files, so we aren't changing a file on disk's + line-endings. + """ + if toUnixEOL: + txt = cls.__unix_end_lines__(txt) with io.open(filename, 'w', newline='\n', encoding=encoding) as fle: fle.write(txt) @@ -700,14 +1043,15 @@ def __show__(self): self._is_loaded = True count = None - if self._filename_pref and Path(self._filename_pref).is_file(): - self.__load__(self._filename_pref) + filename = self.__filename__() + if filename and Path(filename).is_file(): + self.__load__(filename) return else: core_name = self.window().name versionType = VersionTypes.Last filepath, idx, count = get_backup_version_info( - core_name, self._workbox_id, versionType, "" + core_name, self.__workbox_id__(), versionType, "" ) if count: diff --git a/preditor/gui/workbox_text_edit.py b/preditor/gui/workbox_text_edit.py index 2dcd5d7e..f82a31b9 100644 --- a/preditor/gui/workbox_text_edit.py +++ b/preditor/gui/workbox_text_edit.py @@ -24,20 +24,17 @@ class WorkboxTextEdit(WorkboxMixin, QTextEdit): def __init__( self, - parent=None, console=None, - workbox_id=None, - filename=None, - backup_file=None, - tempfile=None, - delayable_engine='default', core_name=None, + delayable_engine='default', + parent=None, **kwargs, ): - super(WorkboxTextEdit, self).__init__(parent=parent, core_name=core_name) - self._filename = None - self._encoding = None + super(WorkboxTextEdit, self).__init__( + parent=parent, core_name=core_name, **kwargs + ) self.__set_console__(console) + self._encoding = None highlight = CodeHighlighter(self, 'Python') self.uiCodeHighlighter = highlight @@ -47,6 +44,10 @@ def __auto_complete_enabled__(self): def __set_auto_complete_enabled__(self, state): pass + def __clear__(self): + self.clear() + self.__set_last_saved_text__(self.__text__()) + def __copy_indents_as_spaces__(self): """When copying code, should it convert leading tabs to spaces?""" return False @@ -63,11 +64,6 @@ def __cursor_position__(self): sc.setPosition(cursor.selectionStart()) return sc.blockNumber(), sc.positionInBlock() - def __exec_all__(self): - txt = self.__text__().rstrip() - title = self.__workbox_trace_title__() - self.__console__().executeString(txt, filename=title) - def __font__(self): return self.font() @@ -86,12 +82,6 @@ def __indentations_use_tabs__(self): def __set_indentations_use_tabs__(self, state): logger.info("WorkboxTextEdit does not support using spaces for tabs.") - def __load__(self, filename): - self._filename = filename - enc, txt = self.__open_file__(self._filename) - self._encoding = enc - self.__set_text__(txt) - def __margins_font__(self): return QFont() @@ -102,13 +92,9 @@ def __tab_width__(self): # TODO: Implement custom tab widths return 4 - def __text__(self, line=None, start=None, end=None): + def __text__(self): return self.toPlainText() - def __set_text__(self, text, update_last_saved_text=True): - super(WorkboxTextEdit, self).__set_text__(text) - self.setPlainText(text) - def __selected_text__(self, start_of_line=False, selectText=False): cursor = self.textCursor() diff --git a/preditor/gui/workboxwidget.py b/preditor/gui/workboxwidget.py index 41cbaebc..aa267af6 100644 --- a/preditor/gui/workboxwidget.py +++ b/preditor/gui/workboxwidget.py @@ -1,12 +1,13 @@ from __future__ import absolute_import, print_function +import os import re import time from pathlib import Path -from Qt.QtCore import Qt +from Qt.QtCore import QEvent, Qt from Qt.QtGui import QIcon -from Qt.QtWidgets import QAction +from Qt.QtWidgets import QAction, QMessageBox from .. import core, resourcePath from ..gui.workbox_mixin import WorkboxMixin @@ -20,12 +21,9 @@ def __init__( self, parent=None, console=None, - workbox_id=None, - filename=None, - backup_file=None, - tempfile=None, delayable_engine='default', core_name=None, + **kwargs ): self.__set_console__(console) self._searchFlags = 0 @@ -34,18 +32,9 @@ def __init__( # initialize the super class super(WorkboxWidget, self).__init__( - parent, delayable_engine=delayable_engine, core_name=core_name + parent, delayable_engine=delayable_engine, core_name=core_name, **kwargs ) - if workbox_id: - self._workbox_id = workbox_id - else: - self._workbox_id = WorkboxMixin.__create_workbox_id__(self.core_name) - - self._filename_pref = filename - self._backup_file = backup_file - self._tempfile = tempfile - # Store the software name so we can handle custom keyboard shortcuts based on # software self._software = core.objectName() @@ -71,6 +60,12 @@ def __set_auto_complete_enabled__(self, state): ) self.setAutoCompletionSource(state) + def __auto_reload_on_change__(self): + return self.autoReloadOnChange() + + def __set_auto_reload_on_change__(self, state): + self.setAutoReloadOnChange(state) + def __clear__(self): self.clear() self.__set_last_saved_text__(self.__text__()) @@ -92,20 +87,38 @@ def __set_cursor_position__(self, line, index): """Set the cursor to this line number and index""" self.setCursorPosition(line, index) - def __exec_all__(self): - txt = self.__unix_end_lines__(self.text()).rstrip() - title = self.__workbox_trace_title__() - self.__console__().executeString(txt, filename=title) - - def __file_monitoring_enabled__(self): - return self.window().fileMonitoringEnabled(self.__filename__()) - - def __set_file_monitoring_enabled__(self, state): - self.window().setFileMonitoringEnabled(self.__filename__(), state) + def __check_for_save__(self): + if self.isModified(): + result = QMessageBox.question( + self.window(), + 'Save changes to...', + 'Do you want to save your changes?', + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if result == QMessageBox.StandardButton.Yes: + return self.save() + elif result == QMessageBox.StandardButton.Cancel: + return False + return True + + def eventFilter(self, object, event): + if event.type() == QEvent.Type.Close and not self.__check_for_save__(): + event.ignore() + return True + return False + + def execStandalone(self): + if self.__save__(): + os.startfile(str(self.filename())) def __filename__(self): return self.filename() + def __set_filename__(self, filename): + self.set_filename(filename) + def __font__(self): if self.lexer(): return self.lexer().font(0) @@ -131,28 +144,31 @@ def __set_indentations_use_tabs__(self, state): def __insert_text__(self, txt): self.insert(txt) - def __load__(self, filename, update_last_save=True): - self.load(filename) - self.__set_last_saved_text__(self.__text__()) - - # Determine workbox name so we can store it - workbox_name = self.__workbox_name__() - group_name = workbox_name.group - - new_name = Path(filename).name + def __load__(self, filename): + if filename and Path(filename).is_file(): + # This is overriding WorkboxMixin.__load__, make sure to base class + # method. + super().__load__(filename) - new_workbox_name = "{}/{}".format(group_name, new_name) - self.__set_last_workbox_name__(new_workbox_name) + # DocumentEditor specific calls + self.updateFilename(str(filename)) + self.setEolMode(self.detectEndLine(self.text())) def __reload_file__(self): # loading the file too quickly misses any changes time.sleep(0.1) font = self.__font__() - self.reloadChange() + self.__linked_file_changed__() self.__set_last_saved_text__(self.__text__()) self.__set_last_workbox_name__(self.__workbox_name__()) self.__set_font__(font) + def __save__(self): + super().__save__() + filename = self.__filename__() + if filename: + self.updateFilename(filename) + def __margins_font__(self): return self.marginsFont() @@ -177,11 +193,6 @@ def __marker_clear_all__(self): def __remove_selected_text__(self): self.removeSelectedText() - def __save__(self): - self.save() - self.__set_last_saved_text__(self.__text__()) - self.__set_last_workbox_name__(self.__workbox_name__()) - def __selected_text__(self, start_of_line=False, selectText=False): line, s, end, e = self.getSelection() @@ -212,7 +223,7 @@ def __tab_width__(self): def __set_tab_width__(self, width): self.setTabWidth(width) - def __text__(self, line=None, start=None, end=None): + def __text__(self): """Returns the text in this widget, possibly limited in scope. Note: Only pass line, or (start and end) to this method. @@ -225,30 +236,8 @@ def __text__(self, line=None, start=None, end=None): Returns: str: The requested text. """ - if line is not None: - return self.text(line) - elif (start is None) != (end is None): - raise ValueError('You must pass start and end if you pass either.') - elif start is not None: - self.text(start, end) return self.text() - def __set_text__(self, txt, update_last_saved_text=True): - """Replace all of the current text with txt.""" - self.setText(txt) - self._is_loaded = True - if update_last_saved_text: - self.__set_last_saved_text__(self.__text__()) - - @classmethod - def __write_file__(cls, filename, txt, encoding=None): - # Save unix newlines for simplicity. This should only be called for - # files which are not linked, so we don't inadvertently change a file's - # line-endings. For linked files, call saveAs, which bypasses this - # method writes without converting to unix line endings. - txt = cls.__unix_end_lines__(txt) - super(WorkboxWidget, cls).__write_file__(filename, txt, encoding=encoding) - def keyPressEvent(self, event): """Check for certain keyboard shortcuts, and handle them as needed, otherwise pass the keyPress to the superclass. diff --git a/preditor/scintilla/documenteditor.py b/preditor/scintilla/documenteditor.py index 95b9f791..dc116cbb 100644 --- a/preditor/scintilla/documenteditor.py +++ b/preditor/scintilla/documenteditor.py @@ -14,13 +14,12 @@ import re import string import sys -import time from collections import OrderedDict from contextlib import contextmanager from functools import partial import Qt as Qt_py -from Qt.QtCore import Property, QEvent, QPoint, Qt, Signal +from Qt.QtCore import Property, QPoint, Qt, Signal from Qt.QtGui import QColor, QFont, QFontMetrics, QIcon, QKeyEvent, QKeySequence from Qt.QtWidgets import ( QAction, @@ -35,7 +34,6 @@ from ..delayable_engine import DelayableEngine from ..enum import Enum, EnumGroup from ..gui import QtPropertyInit -from ..gui.workbox_mixin import WorkboxMixin from . import QsciScintilla, lang logger = logging.getLogger(__name__) @@ -121,7 +119,6 @@ def __init__(self, parent, filename='', lineno=0, delayable_engine='default'): # dialog shown is used to prevent showing multiple versions of the of the # confirmation dialog. this is caused because multiple signals are emitted and # processed. - self._dialogShown = False # used to store perminately highlighted keywords self._permaHighlight = [] self.setSmartHighlightingRegEx() @@ -222,9 +219,7 @@ def to_int(shortcut): self.uiShowAutoCompleteSCT.activated.connect(lambda: self.showAutoComplete()) # load the file - if filename: - self.load(filename) - else: + if not filename: self.refreshTitle() self.setLanguage('Plain Text') @@ -249,30 +244,14 @@ def setCaretForegroundColor(self, color): self._caretForegroundColor = color super(DocumentEditor, self).setCaretForegroundColor(color) - def checkForSave(self): - if self.isModified(): - result = QMessageBox.question( - self.window(), - 'Save changes to...', - 'Do you want to save your changes?', - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No - | QMessageBox.StandardButton.Cancel, - ) - if result == QMessageBox.StandardButton.Yes: - return self.save() - elif result == QMessageBox.StandardButton.Cancel: - return False - return True - def clear(self): super(DocumentEditor, self).clear() - self._filename = '' + self.set_filename('') def closeEvent(self, event): self.disableTitleUpdate() # unsubcribe the file from the open file monitor - self.__set_file_monitoring_enabled__(False) + self.setFileMonitoringEnabled(False) super(DocumentEditor, self).closeEvent(event) def closeEditor(self): @@ -587,12 +566,6 @@ def disableTitleUpdate(self): def enableTitleUpdate(self): self.modificationChanged.connect(self.refreshTitle) - def eventFilter(self, object, event): - if event.type() == QEvent.Type.Close and not self.checkForSave(): - event.ignore() - return True - return False - def exploreDocument(self): path = self._filename if os.path.isfile(path): @@ -677,20 +650,12 @@ def languageChosen(self, action): def lineMarginWidth(self): return self.marginWidth(self.SymbolMargin) - def load(self, filename): - filename = str(filename) - if filename and os.path.exists(filename): - self._encoding, text = WorkboxMixin.__open_file__(filename) - self.setText(text) - self.updateFilename(filename) - self.__set_file_monitoring_enabled__(True) - self.setEolMode(self.detectEndLine(self.text())) - return True - return False - def filename(self): return self._filename + def set_filename(self, filename): + self._filename = filename + def findNext(self, text, flags): re = (flags & SearchOptions.QRegExp) != 0 cs = (flags & SearchOptions.CaseSensitive) != 0 @@ -1028,76 +993,6 @@ def setPermaHighlight(self, value): if not isinstance(value, list): raise TypeError('PermaHighlight must be a list') - def reloadFile(self): - return self.reloadDialog( - 'Are you sure you want to reload %s? You will lose all changes' - % os.path.basename(self.filename()) - ) - - def reloadChange(self): - """Callback for file monitoring. If a file was modified or deleted this method - is called when Open File Monitoring is enabled. Returns if the file was updated - or left open - - Returns: - bool: - """ - logger.debug( - 'Reload Change called: %0.3f Dialog Shown: %r' - % (self._saveTimer, self._dialogShown), - ) - if time.time() - self._saveTimer < 0.5: - # If we are saving no need to reload the file - logger.debug('timer has not expired') - return False - if not os.path.isfile(self.filename()) and not self._dialogShown: - logger.debug('The file was deleted') - # the file was deleted, ask the user if they still want to keep the file in - # the editor. - self._dialogShown = True - result = QMessageBox.question( - self.window(), - 'File Removed...', - 'File: %s has been deleted.\nKeep file in editor?' % self.filename(), - QMessageBox.StandardButton.Yes, - QMessageBox.StandardButton.No, - ) - self._dialogShown = False - if result == QMessageBox.StandardButton.No: - logger.debug( - 'The file was deleted, removing document from editor', - ) - self.parent().close() - return False - # TODO: The file no longer exists, and the document should be marked as - # changed. - logger.debug( - 'The file was deleted, But the user left it in the editor', - ) - self.__set_file_monitoring_enabled__(False) - return True - logger.debug('Defaulting to reload message') - return self.reloadDialog( - 'File: %s has been changed.\nReload from disk?' % self.filename() - ) - - def reloadDialog(self, message, title='Reload File...'): - if not self._dialogShown: - self._dialogShown = True - if self.autoReloadOnChange() or not self.isModified(): - result = QMessageBox.StandardButton.Yes - else: - result = QMessageBox.question( - self.window(), - title, - message, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - self._dialogShown = False - if result == QMessageBox.StandardButton.Yes: - return self.load(self.filename()) - return False - def replace(self, text, searchtext=None, all=False): # replace the current text with the inputed text if not searchtext: @@ -1146,45 +1041,10 @@ def refreshTitle(self): except RuntimeError: pass - def save(self): - logger.debug(' Saved Called'.center(60, '-')) - ret = self.saveAs(self.filename()) - return ret - - def saveAs(self, filename='', setFilename=True, directory=''): - logger.debug(' Save As Called '.center(60, '-')) - - # Disable file watching so workbox doesn't reload and scroll to the top - self.__set_file_monitoring_enabled__(False) - if not filename: - filename = self.filename() or directory - filename, extFilter = Qt_py.QtCompat.QFileDialog.getSaveFileName( - self.window(), 'Save File as...', filename - ) - - if filename: - self._saveTimer = time.time() - # save the file to disk - try: - txt = self.text() - WorkboxMixin.__write_file__(filename, txt, encoding=self._encoding) - except PermissionError as error: - logger.debug('An error occurred while saving') - QMessageBox.question( - self.window(), - 'Error saving file...', - 'There was a error saving the file. Error: {}'.format(error), - QMessageBox.StandardButton.Ok, - ) - return False - - # update the file - if setFilename: - self.updateFilename(filename) - # Turn file watching back on. - self.__set_file_monitoring_enabled__(True) - return True - return False + def setFileMonitoringEnabled(self, state): + window = self.window() + if hasattr(window, "setFileMonitoringEnabled"): + window.setFileMonitoringEnabled(state) def selectProjectItem(self): window = self.window() @@ -1716,7 +1576,8 @@ def updateFilename(self, filename): self.setLanguage(self._defaultLanguage) # update the filename information - self._filename = os.path.abspath(filename) if filename else "" + filename = os.path.abspath(filename) if filename else "" + self.set_filename(filename) self.setModified(False) try: @@ -2067,21 +1928,3 @@ def setUnmatchedBraceForegroundColor(self, color): '_paperSmartHighlight', QColor(155, 255, 155, 75) ) paperDecorator = QtPropertyInit('_paperDecorator', _defaultPaper) - - def __file_monitoring_enabled__(self): - """Returns True if this workbox supports file monitoring. - This allows the editor to update its text if the linked - file is changed on disk.""" - return False - - def __set_file_monitoring_enabled__(self, state): - """Enables/Disables open file change monitoring. If enabled, A dialog will pop - up when ever the open file is changed externally. If file monitoring is - disabled in the IDE settings it will be ignored. - - Returns: - bool: - """ - # if file monitoring is enabled and we have a file name then set up the file - # monitoring - pass From 34d9a08eba46ef48df4ba9cc5e706b0ed934dc11 Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Mon, 3 Nov 2025 18:32:31 -0800 Subject: [PATCH 09/10] PR Notes - Uniform font / gui font keyboard modifiers - Remove QRegExp (deprecated, QRegularExpression not supported byQt.py) - Identifier classes now subclass py3 enums - update attr check name - Add copy filename context menu item - Better linked file comparisons by with `pathlib.Path` instances (ie remove `as_posix()` - Cleanup doc strings --- preditor/gui/drag_tab_bar.py | 15 ++++++++++++++- preditor/gui/loggerwindow.py | 34 ++++++++++++++++----------------- preditor/gui/ui/loggerwindow.ui | 4 ++-- preditor/prefs.py | 14 +++++++------- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/preditor/gui/drag_tab_bar.py b/preditor/gui/drag_tab_bar.py index 5f4278d5..93d36e0f 100644 --- a/preditor/gui/drag_tab_bar.py +++ b/preditor/gui/drag_tab_bar.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from enum import IntEnum from functools import partial from pathlib import Path @@ -22,7 +23,7 @@ from . import QtPropertyInit -class TabStates: +class TabStates(IntEnum): """Nice names for the Tab states for coloring""" Normal = 0 @@ -366,6 +367,9 @@ def tab_menu(self, pos, popup=True): act = menu.addAction('Save As') act.triggered.connect(partial(self.save_and_link_file, workbox)) + + act = menu.addAction('Copy Filename') + act.triggered.connect(partial(self.copyFilename, workbox)) else: act = menu.addAction('Explore File') act.triggered.connect(partial(self.explore_file, workbox)) @@ -465,6 +469,15 @@ def unlink_file(self, workbox): name = self.parent().default_title self.setTabText(self._context_menu_tab, name) + def copyFilename(self, workbox): + """Copy the given workbox's filename to the clipboard + + Args: + workbox (WorkboxMixin): The workbox for which to provide the filename + """ + filename = workbox.__filename__() + QApplication.clipboard().setText(filename) + def copy_workbox_name(self, workbox, index): """Copy the workbox name to clipboard. diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 3dbe520d..55d7ae54 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -11,6 +11,7 @@ import warnings from builtins import bytes from datetime import datetime, timedelta +from enum import IntEnum from functools import partial from pathlib import Path @@ -61,7 +62,7 @@ PRUNE_PATTERN = re.compile(PRUNE_PATTERN) -class WorkboxPages: +class WorkboxPages(IntEnum): """Nice names for the uiWorkboxSTACK indexes.""" Options = 0 @@ -522,7 +523,6 @@ def workbox_for_id(self, workbox_id, show=False, visible=False): to ensure that it is initialized and its text is loaded. visible (bool, optional): Make the this workbox visible if found. """ - # pred = self.instance() workbox = None for box_info in self.uiWorkboxTAB.all_widgets(): temp_box = box_info[0] @@ -850,24 +850,24 @@ def setGuiFont(self, newSize=None, newFont=None): tabbar_class = current.tabBar().__class__ menubar_class = self.menuBar().__class__ label_class = self.uiStatusLBL.__class__ - children = self.findChildren(tabbar_class, QtCore.QRegExp(".*")) - children.extend(self.findChildren(menubar_class, QtCore.QRegExp(".*"))) - children.extend(self.findChildren(label_class, QtCore.QRegExp(".*"))) - children.extend(self.findChildren(QToolButton, QtCore.QRegExp(".*"))) - children.extend(self.findChildren(QMenu, QtCore.QRegExp(".*"))) - children.extend(self.findChildren(QToolTip, QtCore.QRegExp(".*"))) + children = self.findChildren(tabbar_class, None) + children.extend(self.findChildren(menubar_class, None)) + children.extend(self.findChildren(label_class, None)) + children.extend(self.findChildren(QToolButton, None)) + children.extend(self.findChildren(QMenu, None)) + children.extend(self.findChildren(QToolTip, None)) for child in children: + if not hasattr(child, "setFont"): + continue if newFont is None: newFont = child.font() if newSize is None: newSize = newFont.pointSize() newFont.setPointSize(newSize) child.setFont(newFont) - # child.resize() self.setFont(newFont) QToolTip.setFont(newFont) - # self.resize() def setFontSize(self, newSize): """Update the font size in the console and current workbox. @@ -1046,8 +1046,6 @@ def setFileMonitoringEnabled(self, filename, state): if not filename: return - filename = Path(filename).as_posix() - if state: self.openFileMonitor.addPath(filename) else: @@ -1066,9 +1064,8 @@ def fileMonitoringEnabled(self, filename): if not filename: return False - filename = Path(filename).as_posix() - watched_files = self.openFileMonitor.files() - return filename in watched_files + watched_files = [Path(file) for file in self.openFileMonitor.files()] + return Path(filename) in watched_files def prefsPath(self, name='preditor_pref.json'): """Get the path to this core's prefs, for the given name @@ -1090,10 +1087,9 @@ def linkedFileChanged(self, filename): Args: filename (str): The file which triggered the file changed signal """ - prefs_path = Path(self.prefsPath()).as_posix() # Either handle prefs or workbox - if filename == prefs_path: + if Path(filename) == Path(self.prefsPath()): # First, save workbox prefs. Don't save preditor.prefs because that # would just overwrite whatever changes we are responding to. self.getBoxesChangedByInstance() @@ -1172,6 +1168,10 @@ def recordPrefs(self, manual=False, disableFileMonitoring=False): if not manual and not self.autoSaveEnabled(): return + # When applying a change to editor class, we may essentially auto-save + # prefs, in order to reload on the next class. In doing so, we may be + # changing workbox filename(s), if any, so let's remove them from file + # monitoring. They will be re-added during restorePrefs. if disableFileMonitoring: for editor_info in self.uiWorkboxTAB.all_widgets(): editor = editor_info[0] diff --git a/preditor/gui/ui/loggerwindow.ui b/preditor/gui/ui/loggerwindow.ui index f7634514..950d3600 100644 --- a/preditor/gui/ui/loggerwindow.ui +++ b/preditor/gui/ui/loggerwindow.ui @@ -1063,7 +1063,7 @@ at the indicated line in the specified text editor. Increase Gui Font Size
- ..or Alt+Scroll Up + ..or Ctrl+Alt+Scroll Up Ctrl+Alt++ @@ -1085,7 +1085,7 @@ at the indicated line in the specified text editor. Decrease Gui Font Size - ..or Alt+Scroll Down + ..or Ctrl+Alt+Scroll Down Ctrl+Alt+- diff --git a/preditor/prefs.py b/preditor/prefs.py index 20c82da6..d428b052 100644 --- a/preditor/prefs.py +++ b/preditor/prefs.py @@ -203,7 +203,7 @@ def get_file_group(core_name, workbox_id): workbox_id (str): The current workbox_id Returns: - TYPE: Description + files (list): The list of files found for the given workbox_id """ directory = Path(get_prefs_dir(core_name=core_name, sub_dir='workboxes')) workbox_dir = directory / workbox_id @@ -252,7 +252,7 @@ def get_backup_version_info(core_name, workbox_id, versionType, backup_file=None Args: core_name (str): The current core_name workbox_id (str): The current workbox_id - versionType (TYPE): The VersionType (ie First, Previous, Next, Last) + versionType (VersionType): The VersionType (ie First, Previous, Next, Last) backup_file (None, optional): The currently loaded backup file. Returns: @@ -312,9 +312,9 @@ def update_pref_args(core_name, pref_dict, old_name, update_data): Args: core_name (str): The current core_name - pref_dict (TYPE): The pref to update - old_name (TYPE): Original pref name, which may be updated - update_data (TYPE): Dict to define ways to update the values, which + pref_dict (dict): The pref to update + old_name (str): Original pref name, which may be updated + update_data (str): Dict to define ways to update the values, which currently only supports str.replace. """ workbox_dir = Path(get_prefs_dir(core_name=core_name, create=True)) @@ -352,8 +352,8 @@ def update_prefs_args(core_name, prefs_dict, prefs_updates): Args: core_name (str): The current core_name - prefs_dict (TYPE): The PrEditor prefs to update - prefs_updates (TYPE): The update definition dict + prefs_dict (dict): The PrEditor prefs to update + prefs_updates (dict): The update definition dict Returns: prefs_dict (dict): The updated dict From 4ac7f8e930559f3c2738ab8ae3aa184d81e9e31a Mon Sep 17 00:00:00 2001 From: Mark Mancewicz Date: Tue, 4 Nov 2025 12:19:19 -0800 Subject: [PATCH 10/10] Addressing extra notes - Don't set a workboxes tab_widget, always get it dynamically - Handle Reload messageboxes better --- .../group_tab_widget/grouped_tab_widget.py | 1 - preditor/gui/loggerwindow.py | 13 +- preditor/gui/workbox_mixin.py | 142 ++++++++++-------- preditor/gui/workboxwidget.py | 14 +- 4 files changed, 92 insertions(+), 78 deletions(-) diff --git a/preditor/gui/group_tab_widget/grouped_tab_widget.py b/preditor/gui/group_tab_widget/grouped_tab_widget.py index b2c8b680..705e5486 100644 --- a/preditor/gui/group_tab_widget/grouped_tab_widget.py +++ b/preditor/gui/group_tab_widget/grouped_tab_widget.py @@ -190,7 +190,6 @@ def showEvent(self, event): # noqa: N802 def tab_shown(self, index): editor = self.widget(index) - editor.__set_tab_widget__(self) if editor and editor.isVisible(): editor.__show__() diff --git a/preditor/gui/loggerwindow.py b/preditor/gui/loggerwindow.py index 55d7ae54..c17de4f3 100644 --- a/preditor/gui/loggerwindow.py +++ b/preditor/gui/loggerwindow.py @@ -385,7 +385,7 @@ def autoSaveEnabled(self): """Whether or not AutoSave option is set Returns: - bool: hether AutoSave option is checked or not + bool: Whether AutoSave option is checked or not """ return self.uiAutoSaveSettingssACT.isChecked() @@ -1104,10 +1104,13 @@ def linkedFileChanged(self, filename): continue if Path(editor.__filename__()) == Path(filename): editor.__set_file_monitoring_enabled__(False) - editor.__save_prefs__(saveLinkedFile=False) - editor.__reload_file__() - editor.__save_prefs__(saveLinkedFile=False, force=True) - editor.__set_file_monitoring_enabled__(True) + choice = editor.__maybe_reload_file__() + if choice: + editor.__save_prefs__(saveLinkedFile=False, force=True) + + filename = editor.__filename__() + if filename and Path(filename).is_file(): + editor.__set_file_monitoring_enabled__(True) def closeEvent(self, event): self.recordPrefs() diff --git a/preditor/gui/workbox_mixin.py b/preditor/gui/workbox_mixin.py index de9dd989..d7fc145e 100644 --- a/preditor/gui/workbox_mixin.py +++ b/preditor/gui/workbox_mixin.py @@ -23,6 +23,7 @@ get_prefs_dir, get_relative_path, ) +from .group_tab_widget.one_tab_widget import OneTabWidget logger = logging.getLogger(__name__) @@ -92,7 +93,9 @@ def __init__( self._show_blank = False self._tempdir = None - self._dialogShown = False + # As event-driven dialogs are shown, add the tuple of (title, message) + # to this list, to prevent multiple dialogs showing for same reason. + self.shownDialogs = [] self.core_name = core_name @@ -141,7 +144,10 @@ def __set_last_saved_text__(self, text): text (str): The text to define as last_saved_text """ self._last_saved_text = text - self.__tab_widget__().tabBar().update() + + tab_widget = self.__tab_widget__() + if tab_widget is not None: + tab_widget.tabBar().update() def __last_saved_text__(self): """Returns the last_saved_text on this workbox @@ -176,7 +182,14 @@ def __tab_widget__(self): Returns: GroupedTabWidget: The tab widget which contains this workbox """ - return self._tab_widget + tab_widget = None + parent = self.parent() + while parent is not None: + if issubclass(parent.__class__, OneTabWidget): + tab_widget = parent + break + parent = parent.parent() + return tab_widget def __set_tab_widget__(self, tab_widget): """Set this workbox's _tab_widget to the provided tab_widget""" @@ -363,8 +376,11 @@ def __workbox_name__(self, workbox=None): group_name = None workbox_name = None + grouped_tab_widget = workbox.__tab_widget__() + if grouped_tab_widget is None: + return WorkboxName("", "") + if workbox: - grouped_tab_widget = workbox.__tab_widget__() for group_idx in range(workboxTAB.count()): # If a previous iteration determine workbox_name, bust out if workbox_name: @@ -381,20 +397,19 @@ def __workbox_name__(self, workbox=None): workbox_name = cur_group_widget.tabText(workbox_idx) break else: - grouped = workbox.__tab_widget__() - groupedTabBar = grouped.tabBar() + groupedTabBar = grouped_tab_widget.tabBar() idx = -1 - for idx in range(grouped.count()): - if grouped.widget(idx) == workbox: + for idx in range(grouped_tab_widget.count()): + if grouped_tab_widget.widget(idx) == workbox: break workbox_name = groupedTabBar.tabText(idx) - group = grouped.tab_widget() - groupTabBar = group.tabBar() + group_tab_widget = grouped_tab_widget.tab_widget() + groupTabBar = group_tab_widget.tabBar() idx = -1 - for idx in range(group.count()): - if group.widget(idx) == grouped: + for idx in range(group_tab_widget.count()): + if group_tab_widget.widget(idx) == grouped_tab_widget: break group_name = groupTabBar.tabText(idx) @@ -503,19 +518,36 @@ def __marker_add__(self, line): def __marker_clear_all__(self): raise NotImplementedError("Mixin method not overridden.") - def __reload_file__(self): + def __set_workbox_title__(self, title): + """Set the tab-text on the grouped widget tab for this workbox. + + Args: + title (str): The text to put on the grouped tab's tabText. + """ + _group_idx, editor_idx = self.__group_tab_index__() + + tab_widget = self.__tab_widget__() + if tab_widget is not None: + tab_widget.tabBar().setTabText(editor_idx, title) + + def __maybe_reload_file__(self): """Reload this workbox's linked file.""" # Loading the file too quickly misses any changes time.sleep(0.1) font = self.__font__() - choice = self.__show_reload_dialog__() + choice = self.__linked_file_changed__() if choice is True: + # First save unsaved changes, so user can get it from a previous + # version is desired. + self.__save_prefs__(saveLinkedFile=False, resetLastInfos=False) + + # Load the file self.__load__(self.__filename__()) - self.__set_last_saved_text__(self.__text__()) - self.__set_last_workbox_name__(self.__workbox_name__()) + # Reset the font self.__set_font__(font) + return choice def __single_messagebox__(self, title, message): """Display a messagebox, but only once, in case this is triggered by a @@ -529,42 +561,17 @@ def __single_messagebox__(self, title, message): Returns: choice (bool): Whether the user accepted the dialog or not. """ - choice = False - buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - if not self._dialogShown: - self._dialogShown = True - result = QMessageBox.question(self.window(), title, message, buttons) - choice = result == QMessageBox.StandardButton.Yes - self._dialogShown = False - return choice + tup = (title, message) + if tup in self.shownDialogs: + return None + self.shownDialogs.append(tup) - def __show_reload_dialog__(self): - """Show a messagebox asking if user wants to reload the linked-file, - which has changed externally. - - Returns: - choice (bool): Whether user chose to reload the link file (or - auto-reload setting is True) - """ - choice = None - if self.__auto_reload_on_change__(): - choice = True - else: - name = Path(self.__filename__()).name - title = 'Reload File...' - msg = f'Are you sure you want to reload {name}?' - choice = self.__single_messagebox__(title, msg) - return choice - - def __set_workbox_title__(self, title): - """Set the tab-text on the grouped widget tab for this workbox. + buttons = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + result = QMessageBox.question(self.window(), title, message, buttons) + self.shownDialogs.remove(tup) - Args: - title (str): The text to put on the grouped tab's tabText. - """ - _group_idx, editor_idx = self.__group_tab_index__() - self.__tab_widget__().tabBar().setTabText(editor_idx, title) + return result == QMessageBox.StandardButton.Yes def __linked_file_changed__(self): """If a file was modified or deleted this method @@ -588,25 +595,28 @@ def __linked_file_changed__(self): 'The file was deleted, removing document from editor', ) group_idx, editor_idx = self.__group_tab_index__() - self.__tab_widget__().close_tab(editor_idx) + + tab_widget = self.__tab_widget__() + if tab_widget is not None: + tab_widget.close_tab(editor_idx) + return False elif choice: self.__set_filename__("") - title = self.__tab_widget__().get_next_available_tab_name() - self.__set_workbox_title__(title) - # TODO: The file no longer exists, and the document should be marked as - # changed. + tab_widget = self.__tab_widget__() + if tab_widget is not None: + title = tab_widget.get_next_available_tab_name() + self.__set_workbox_title__(title) - if self.autoReloadOnChange() or not self.isModified(): + if self.__auto_reload_on_change__() or not self.__is_dirty__(): choice = True else: title = 'Reload File...' msg = f'File: {filename} has been changed.\nReload from disk?' choice = self.__single_messagebox__(title, msg) - if choice is True: - self.__load__(self.__filename__()) + return choice def __remove_selected_text__(self): raise NotImplementedError("Mixin method not overridden.") @@ -769,7 +779,13 @@ def __unix_end_lines__(cls, txt): """Replaces all windows and then mac line endings with unix line endings.""" return txt.replace('\r\n', '\n').replace('\r', '\n') - def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): + def __save_prefs__( + self, + current=None, + force=False, + saveLinkedFile=True, + resetLastInfos=True, + ): ret = {} # Hopefully the alphabetical sorting of this dict is preserved in py3 @@ -829,8 +845,9 @@ def __save_prefs__(self, current=None, saveLinkedFile=True, force=False): self.__save__() ret['workbox_id'] = workbox_id - self.__set_last_workbox_name__(self.__workbox_name__()) - self.__set_last_saved_text__(self.__text__()) + if resetLastInfos: + self.__set_last_workbox_name__(self.__workbox_name__()) + self.__set_last_saved_text__(self.__text__()) return ret @@ -985,7 +1002,10 @@ def __load_workbox_version_text__(self, versionType): self._backup_file = str(filepath) self.__set_text__(txt) - self.__tab_widget__().tabBar().update() + + tab_widget = self.__tab_widget__() + if tab_widget is not None: + tab_widget.tabBar().update() filename = Path(filepath).name return filename, idx, count diff --git a/preditor/gui/workboxwidget.py b/preditor/gui/workboxwidget.py index aa267af6..19547e9f 100644 --- a/preditor/gui/workboxwidget.py +++ b/preditor/gui/workboxwidget.py @@ -2,7 +2,6 @@ import os import re -import time from pathlib import Path from Qt.QtCore import QEvent, Qt @@ -154,15 +153,6 @@ def __load__(self, filename): self.updateFilename(str(filename)) self.setEolMode(self.detectEndLine(self.text())) - def __reload_file__(self): - # loading the file too quickly misses any changes - time.sleep(0.1) - font = self.__font__() - self.__linked_file_changed__() - self.__set_last_saved_text__(self.__text__()) - self.__set_last_workbox_name__(self.__workbox_name__()) - self.__set_font__(font) - def __save__(self): super().__save__() filename = self.__filename__() @@ -251,7 +241,9 @@ def keyPressEvent(self, event): truncation, but no modifiers are registered when Enter is pressed (unlike when Return is pressed), so this combination is not detectable. """ - self.__tab_widget__().tabBar().update() + tab_widget = self.__tab_widget__() + if tab_widget is not None: + tab_widget.tabBar().update() if self.process_shortcut(event): return