From b07bc283d91b8a38fc87eac428fae42338ea17c2 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 29 May 2026 00:45:05 -0700 Subject: [PATCH 1/2] Implement mini-editor --- faststack/app.py | 43 +- faststack/qml/CompactEditorWindow.qml | 786 ++++++++++++++++++++++++++ faststack/qml/ImageEditorDialog.qml | 21 +- faststack/qml/Main.qml | 21 +- faststack/ui/provider.py | 12 + 5 files changed, 872 insertions(+), 11 deletions(-) create mode 100644 faststack/qml/CompactEditorWindow.qml diff --git a/faststack/app.py b/faststack/app.py index f4bd146..ae2ea91 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1071,6 +1071,16 @@ def handle_key_from_histogram(self, key: int, modifiers: int, text: str): ) self.eventFilter(self.main_window, event) + @Slot(int, int, str) + def handle_key_from_compact_editor(self, key: int, modifiers: int, text: str): + """Forward navigation keys from the compact editor to main window.""" + from PySide6.QtGui import QKeyEvent + + event = QKeyEvent( + QKeyEvent.Type.KeyPress, key, Qt.KeyboardModifier(modifiers), text + ) + self.keybinder.handle_key_press(event) + def eventFilter(self, watched, event) -> bool: # Don't handle key events when a dialog is open if self._dialog_open: @@ -1097,9 +1107,12 @@ def eventFilter(self, watched, event) -> bool: self.cancel_crop_mode() return True # Consume event, crop mode cancelled - # When editing, let QML handle Enter/Esc and related keys. - # Otherwise keybinder can swallow them before QML sees them. - if getattr(self.ui_state, "isEditorOpen", False): + # When editing in full (expanded) mode, let QML handle Enter/Esc + # and related keys. In compact mode, the editor is a separate + # window so the main window keybinder should still run. + if getattr(self.ui_state, "isEditorOpen", False) and getattr( + self.ui_state, "isEditorExpanded", False + ): return False # When cropping, let QML handle Enter/Return for crop execution @@ -2144,6 +2157,11 @@ def _is_current_live_edit_session_dirty(self) -> bool: return False return self._current_live_session_has_meaningful_edits() + @Slot(result=bool) + def has_unsaved_edits(self) -> bool: + """QML-callable: True when the editor has unsaved meaningful edits.""" + return self._is_current_live_edit_session_dirty() + def _mark_current_live_edit_session_submitted(self, revision: int) -> None: """Record that a save request has been queued for the current session revision.""" state = self._live_edit_session_state @@ -2963,7 +2981,7 @@ def save_edited_image(self): if not self._submit_save_request_async(request, saving_status="Saving..."): return - if self.ui_state.isEditorOpen: + if self.ui_state.isEditorOpen and self.ui_state.isEditorExpanded: self.ui_state.isEditorOpen = False @Slot(object) @@ -3082,7 +3100,10 @@ def _on_save_finished(self, save_result: dict): # 1. Editor Cleanup (only if revision is unchanged) if still_on_identical_revision: if editor_was_open: - if self.ui_state.isEditorOpen: + if ( + self.ui_state.isEditorOpen + and self.ui_state.isEditorExpanded + ): self.ui_state.isEditorOpen = False # Closing triggers _on_editor_open_changed -> image_editor.clear() # but we call it explicitly here just in case they closed it manually. @@ -8233,6 +8254,18 @@ def rotate_image_ccw(self): if self.ui_state.isHistogramVisible: self.update_histogram() + def toggle_editor(self): + """Toggle compact image editor. Called from keybinder E key.""" + if self.ui_state.isCropping: + self.ui_state.statusMessage = "Apply or cancel the crop before editing" + return + if self.ui_state.isEditorOpen: + self.ui_state.isEditorOpen = False + else: + self.ui_state.isEditorExpanded = False + self.ui_state.isEditorOpen = True + self.load_image_for_editing() + @Slot() def toggle_histogram(self): """Toggle histogram window visibility.""" diff --git a/faststack/qml/CompactEditorWindow.qml b/faststack/qml/CompactEditorWindow.qml new file mode 100644 index 0000000..00454ac --- /dev/null +++ b/faststack/qml/CompactEditorWindow.qml @@ -0,0 +1,786 @@ +pragma ComponentBehavior: Bound + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 +import QtCore + +Window { + id: compactEditor + width: 320 + height: Screen.desktopAvailableHeight * 0.85 + minimumWidth: 280 + maximumWidth: 400 + minimumHeight: 500 + title: "Image Editor" + flags: Qt.Tool | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + + property var uiStateRef: null + property var controllerRef: null + + visible: compactEditor.uiStateRef + ? (compactEditor.uiStateRef.isEditorOpen && !compactEditor.uiStateRef.isEditorExpanded) + : false + + Settings { + id: compactSettings + category: "compactEditor" + property bool overlaidHistogram: true + property real savedX: -1 + property real savedY: -1 + } + + Component.onCompleted: { + compactEditor.uiStateRef = uiState + compactEditor.controllerRef = controller + if (compactSettings.savedX >= 0 && compactSettings.savedY >= 0) { + compactEditor.x = compactSettings.savedX + compactEditor.y = compactSettings.savedY + } else { + positionAtRightGutter() + } + } + + function positionAtRightGutter() { + var mainWin = Application.windows[0] + if (mainWin) { + compactEditor.x = mainWin.x + mainWin.width - compactEditor.width - 10 + compactEditor.y = mainWin.y + 40 + } + } + + onXChanged: if (visible) compactSettings.savedX = x + onYChanged: if (visible) compactSettings.savedY = y + + // --- Color Palette (matches full editor) --- + readonly property color backgroundColor: "#1e1e1e" + readonly property color textColor: "white" + readonly property color accentColor: "#6366f1" + readonly property color accentColorHover: "#818cf8" + readonly property color controlBg: "#10ffffff" + readonly property color controlBorder: "#30ffffff" + readonly property color separatorColor: "#20ffffff" + readonly property color mutedText: "#6b6764" + + color: compactEditor.backgroundColor + Material.theme: Material.Dark + Material.accent: compactEditor.accentColor + + property int updatePulse: 0 + property int lastLoadedIndex: -1 + + Timer { + id: deferredLoadTimer + interval: 200 + repeat: false + onTriggered: { + if (!compactEditor.visible || !compactEditor.controllerRef || !compactEditor.uiStateRef) return + var idx = compactEditor.uiStateRef.currentIndex + if (idx === compactEditor.lastLoadedIndex) return + compactEditor.lastLoadedIndex = idx + compactEditor.controllerRef.load_image_for_editing() + compactEditor.controllerRef.update_histogram() + compactEditor.updatePulse++ + } + } + + function ensureEditorLoaded() { + if (!compactEditor.controllerRef || !compactEditor.uiStateRef) return + var idx = compactEditor.uiStateRef.currentIndex + if (idx !== compactEditor.lastLoadedIndex) { + deferredLoadTimer.stop() + compactEditor.lastLoadedIndex = idx + compactEditor.controllerRef.load_image_for_editing() + compactEditor.controllerRef.update_histogram() + compactEditor.updatePulse++ + } + } + + Connections { + target: compactEditor.uiStateRef + function onCurrentIndexChanged() { + if (!compactEditor.visible) return + deferredLoadTimer.restart() + } + } + + onVisibleChanged: { + if (visible && compactEditor.controllerRef) { + ensureEditorLoaded() + if (compactSettings.savedX < 0) positionAtRightGutter() + } + } + + onUpdatePulseChanged: { + if (visible && compactEditor.controllerRef) { + compactEditor.controllerRef.update_histogram() + } + } + + property int slidersPressedCount: 0 + onSlidersPressedCountChanged: { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.setAnySliderPressed(slidersPressedCount > 0) + } + + function getBackendValue(key) { + var _dependency = updatePulse; + if (compactEditor.uiStateRef && key in compactEditor.uiStateRef) return compactEditor.uiStateRef[key]; + return 0.0; + } + + onClosing: (close) => { + if (compactEditor.uiStateRef && compactEditor.controllerRef) { + if (compactEditor.controllerRef.has_unsaved_edits()) { + close.accepted = false + discardDialog.open() + return + } + compactEditor.uiStateRef.isEditorOpen = false + } + } + + // Forward navigation keys to main window + FocusScope { + id: keyScope + anchors.fill: parent + focus: compactEditor.visible + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + if (compactEditor.uiStateRef && compactEditor.controllerRef) { + if (compactEditor.controllerRef.has_unsaved_edits()) { + discardDialog.open() + } else { + compactEditor.uiStateRef.isEditorOpen = false + } + } + event.accepted = true + } else if (event.key === Qt.Key_E && !(event.modifiers & Qt.ControlModifier)) { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + event.accepted = true + } else if (event.key === Qt.Key_S && !(event.modifiers & Qt.ControlModifier)) { + compactEditor.ensureEditorLoaded() + if (compactEditor.controllerRef) compactEditor.controllerRef.save_edited_image() + event.accepted = true + } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + if (compactEditor.controllerRef) + compactEditor.controllerRef.handle_key_from_compact_editor(event.key, event.modifiers, event.text) + event.accepted = true + } + } + } + + // Discard confirmation dialog + Dialog { + id: discardDialog + title: "Discard Edits?" + modal: true + anchors.centerIn: parent + width: 260 + standardButtons: Dialog.Yes | Dialog.No + + Label { + text: "You have unsaved edits.\nDiscard and close?" + wrapMode: Text.WordWrap + } + + onAccepted: { + if (compactEditor.controllerRef) compactEditor.controllerRef.reset_edit_parameters() + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + } + } + + ScrollView { + id: editorScroll + anchors.fill: parent + anchors.margins: 8 + rightPadding: editorScroll.ScrollBar.vertical.visible ? editorScroll.ScrollBar.vertical.width + 4 : 0 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: ScrollBar.AsNeeded + contentWidth: editorScroll.availableWidth + + ColumnLayout { + width: editorScroll.availableWidth + spacing: 6 + + // --- Header --- + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Item { Layout.fillWidth: true } + + // Expand button + Button { + id: expandBtn + implicitWidth: 26; implicitHeight: 26 + Layout.minimumWidth: 26; Layout.preferredWidth: 26; Layout.maximumWidth: 26 + Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 + padding: 0 + flat: true + onClicked: { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorExpanded = true + } + contentItem: Text { + text: "⤢" + font.pixelSize: 16 + color: compactEditor.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: expandBtn.hovered ? "#30ffffff" : "transparent" + } + } + + // Close/discard button + Button { + id: discardBtn + implicitWidth: 26; implicitHeight: 26 + Layout.minimumWidth: 26; Layout.preferredWidth: 26; Layout.maximumWidth: 26 + Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 + padding: 0 + flat: true + onClicked: { + if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { + discardDialog.open() + } else { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + } + } + contentItem: Text { + text: "✕" + font.pixelSize: 13 + color: discardBtn.hovered ? "#ff6060" : compactEditor.mutedText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: discardBtn.hovered ? "#40ff4040" : "transparent" + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: compactEditor.separatorColor } + + // --- Histogram Section --- + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "HISTOGRAM" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + color: "#9a9795" + } + + Item { Layout.fillWidth: true } + + // Tiny toggle for histogram mode + Row { + spacing: 0 + + Rectangle { + width: 52; height: 16 + radius: 2 + color: compactSettings.overlaidHistogram ? "#2c2c2c" : "transparent" + border.color: "#3a3a3a"; border.width: 1 + + Text { + anchors.centerIn: parent + text: "Overlay" + font.pixelSize: 8 + color: compactSettings.overlaidHistogram ? "#e8e6e3" : "#6b6764" + } + + MouseArea { + anchors.fill: parent + onClicked: compactSettings.overlaidHistogram = true + } + } + + Rectangle { + width: 48; height: 16 + radius: 2 + color: !compactSettings.overlaidHistogram ? "#2c2c2c" : "transparent" + border.color: "#3a3a3a"; border.width: 1 + + Text { + anchors.centerIn: parent + text: "R G B" + font.pixelSize: 8 + color: !compactSettings.overlaidHistogram ? "#e8e6e3" : "#6b6764" + } + + MouseArea { + anchors.fill: parent + onClicked: compactSettings.overlaidHistogram = false + } + } + } + } + + // Overlaid histogram + Item { + Layout.fillWidth: true + Layout.preferredHeight: compactSettings.overlaidHistogram ? 100 : 0 + visible: compactSettings.overlaidHistogram + + OverlaidHistogram { + anchors.fill: parent + rData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r"] || []) : [] + gData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g"] || []) : [] + bData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b"] || []) : [] + rClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r_clip"] || 0) : 0 + gClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g_clip"] || 0) : 0 + bClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b_clip"] || 0) : 0 + rPreClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r_preclip"] || 0) : 0 + gPreClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g_preclip"] || 0) : 0 + bPreClip: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b_preclip"] || 0) : 0 + gridLineColor: compactEditor.controlBorder + } + } + + // Stacked channel histograms (vertical stack for narrow panel) + ColumnLayout { + Layout.fillWidth: true + visible: !compactSettings.overlaidHistogram + spacing: 2 + + SingleChannelHistogram { + Layout.fillWidth: true; Layout.preferredHeight: 50 + channelName: "Red"; channelColor: "#e15050" + gridLineColor: compactEditor.controlBorder + dangerColor: "#40ff0000"; textColor: compactEditor.textColor; minimal: true + histogramData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r"] || []) : [] + clipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r_clip"] || 0) : 0 + preClipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["r_preclip"] || 0) : 0 + } + SingleChannelHistogram { + Layout.fillWidth: true; Layout.preferredHeight: 50 + channelName: "Green"; channelColor: "#50e150" + gridLineColor: compactEditor.controlBorder + dangerColor: "#40ff0000"; textColor: compactEditor.textColor; minimal: true + histogramData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g"] || []) : [] + clipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g_clip"] || 0) : 0 + preClipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["g_preclip"] || 0) : 0 + } + SingleChannelHistogram { + Layout.fillWidth: true; Layout.preferredHeight: 50 + channelName: "Blue"; channelColor: "#5050e1" + gridLineColor: compactEditor.controlBorder + dangerColor: "#40ff0000"; textColor: compactEditor.textColor; minimal: true + histogramData: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b"] || []) : [] + clipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b_clip"] || 0) : 0 + preClipCount: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData["b_preclip"] || 0) : 0 + } + } + + // Clip stats (only in channel mode; overlay mode has built-in stats) + RowLayout { + Layout.fillWidth: true + spacing: 4 + visible: !compactSettings.overlaidHistogram + + Repeater { + model: [ + { label: "R", preKey: "r_preclip", clipKey: "r_clip", dimColor: "#804040", hotColor: "#ff6060" }, + { label: "G", preKey: "g_preclip", clipKey: "g_clip", dimColor: "#407040", hotColor: "#60ff60" }, + { label: "B", preKey: "b_preclip", clipKey: "b_clip", dimColor: "#404080", hotColor: "#8080ff" }, + ] + + delegate: Column { + required property var modelData + Layout.fillWidth: true + spacing: 0 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + " pre:" + (compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData[modelData.preKey] || 0) : 0) + font.pixelSize: 8; font.family: "IBM Plex Mono" + color: modelData.dimColor + } + Text { + property int clipVal: compactEditor.uiStateRef && compactEditor.uiStateRef.histogramData ? (compactEditor.uiStateRef.histogramData[modelData.clipKey] || 0) : 0 + anchors.horizontalCenter: parent.horizontalCenter + text: "clip:" + clipVal + font.pixelSize: 8; font.family: "IBM Plex Mono" + font.bold: clipVal > 0 + color: clipVal > 0 ? modelData.hotColor : modelData.dimColor + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: compactEditor.separatorColor; Layout.topMargin: 2 } + + // --- LIGHT Section --- + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 4 + + Text { + text: "LIGHT" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + color: "#9a9795" + } + + Item { Layout.fillWidth: true } + + Button { + id: autoLevelsBtn + implicitWidth: 36; implicitHeight: 16 + Layout.minimumWidth: 36; Layout.preferredWidth: 36; Layout.maximumWidth: 36 + Layout.minimumHeight: 16; Layout.preferredHeight: 16; Layout.maximumHeight: 16 + padding: 0 + flat: true + onClicked: { + compactEditor.ensureEditorLoaded() + if (compactEditor.controllerRef) compactEditor.controllerRef.auto_levels() + compactEditor.updatePulse++ + } + contentItem: Text { + text: "Auto" + font.pixelSize: 8 + color: autoLevelsBtn.hovered ? "#e8e6e3" : "#9a9795" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 2 + color: autoLevelsBtn.hovered ? "#30ffffff" : "#18ffffff" + border.color: "#3a3a3a"; border.width: 1 + } + } + } + + ListModel { + id: lightModel + ListElement { name: "Exposure"; key: "exposure"; min: -100; max: 100 } + ListElement { name: "Whites"; key: "whites"; min: -100; max: 100 } + ListElement { name: "Shadows"; key: "shadows"; min: -100; max: 100 } + ListElement { name: "Blacks"; key: "blacks"; min: -100; max: 100 } + } + Repeater { model: lightModel; delegate: compactSlider } + + Rectangle { Layout.fillWidth: true; height: 1; color: compactEditor.separatorColor; Layout.topMargin: 4 } + + // --- COLOR Section --- + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 4 + + Text { + text: "COLOR" + font.pixelSize: 9 + font.weight: Font.DemiBold + font.letterSpacing: 1.0 + color: "#9a9795" + } + + Item { Layout.fillWidth: true } + + Button { + id: autoWbBtn + implicitWidth: 36; implicitHeight: 16 + Layout.minimumWidth: 36; Layout.preferredWidth: 36; Layout.maximumWidth: 36 + Layout.minimumHeight: 16; Layout.preferredHeight: 16; Layout.maximumHeight: 16 + padding: 0 + flat: true + onClicked: { + compactEditor.ensureEditorLoaded() + if (compactEditor.controllerRef) compactEditor.controllerRef.auto_white_balance() + compactEditor.updatePulse++ + } + contentItem: Text { + text: "Auto" + font.pixelSize: 8 + color: autoWbBtn.hovered ? "#e8e6e3" : "#9a9795" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 2 + color: autoWbBtn.hovered ? "#30ffffff" : "#18ffffff" + border.color: "#3a3a3a"; border.width: 1 + } + } + } + + ListModel { + id: colorModel + ListElement { name: "Temp (B/Y)"; key: "white_balance_by"; min: -100; max: 100 } + ListElement { name: "Tint (G/M)"; key: "white_balance_mg"; min: -100; max: 100 } + ListElement { name: "Vibrance"; key: "vibrance"; min: -100; max: 100 } + } + Repeater { model: colorModel; delegate: compactSlider } + + // --- Footer Buttons --- + Item { Layout.fillHeight: true; Layout.minimumHeight: 10 } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Button { + text: "Reset" + flat: true + Layout.preferredWidth: 60 + Layout.preferredHeight: 28 + font.pixelSize: 11 + Material.foreground: compactEditor.mutedText + onClicked: { + compactEditor.ensureEditorLoaded() + if (compactEditor.controllerRef) compactEditor.controllerRef.reset_edit_parameters() + compactEditor.updatePulse++ + } + background: Rectangle { + color: "transparent"; radius: 4 + } + } + + Item { Layout.fillWidth: true } + + Button { + id: closeBtn + text: "Close" + Layout.preferredWidth: 60 + Layout.preferredHeight: 28 + font.pixelSize: 11 + onClicked: { + if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { + discardDialog.open() + } else { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + } + } + contentItem: Text { + text: closeBtn.text + font: closeBtn.font + color: compactEditor.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: closeBtn.down ? "#20ffffff" : "transparent" + radius: 4 + border.color: closeBtn.hovered ? "#60ffffff" : compactEditor.controlBorder + border.width: 1 + } + } + + Button { + id: saveBtn + text: compactEditor.uiStateRef && compactEditor.uiStateRef.isSaving ? "Saving..." : "Save" + Layout.preferredWidth: 80 + Layout.preferredHeight: 28 + font.pixelSize: 11 + enabled: compactEditor.uiStateRef ? !compactEditor.uiStateRef.isSaving : true + onClicked: { + compactEditor.ensureEditorLoaded() + if (compactEditor.controllerRef) compactEditor.controllerRef.save_edited_image() + } + contentItem: Text { + text: saveBtn.text + font: saveBtn.font + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + opacity: saveBtn.enabled ? 1.0 : 0.5 + } + background: Rectangle { + color: saveBtn.enabled + ? (saveBtn.down ? Qt.darker(compactEditor.accentColor, 1.1) : compactEditor.accentColor) + : Qt.darker(compactEditor.accentColor, 1.5) + radius: 4 + } + } + } + } + } + + // --- Compact Slider Component --- + Component { + id: compactSlider + RowLayout { + id: sliderRow + required property string name + required property string key + required property real min + required property real max + + Layout.fillWidth: true + spacing: 6 + + Text { + text: sliderRow.name + color: compactEditor.textColor + font.pixelSize: 11 + font.weight: Font.Medium + Layout.preferredWidth: 70 + Layout.alignment: Qt.AlignVCenter + elide: Text.ElideRight + } + + Slider { + id: slider + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + from: sliderRow.min + to: sliderRow.max + stepSize: 1 + + property real backendValue: compactEditor.getBackendValue(sliderRow.key) * sliderRow.max + + Binding { + target: slider + property: "value" + value: slider.backendValue + when: !slider.pressed && !slider.isResetting + } + + property real _pendingValue: 0 + property real _lastSentValue: 0 + Timer { + id: sendTimer + interval: 16 + repeat: true + onTriggered: { + if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) { + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, slider._pendingValue / sliderRow.max) + slider._lastSentValue = slider._pendingValue + } + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.DragThreshold + onDoubleTapped: { + if (!slider.isResetting) slider.triggerReset() + } + } + + property bool isResetting: false + Timer { + id: resetTimer + interval: 100 + repeat: false + onTriggered: slider.isResetting = false + } + + function triggerReset() { + compactEditor.ensureEditorLoaded() + slider.isResetting = true + sendTimer.stop() + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, 0.0) + slider.value = 0.0 + _pendingValue = 0.0 + slider._lastSentValue = 0.0 + compactEditor.updatePulse++ + resetTimer.restart() + } + + onPressedChanged: { + if (pressed) { + compactEditor.ensureEditorLoaded() + compactEditor.slidersPressedCount++ + if (!slider.isResetting) { + _pendingValue = value + slider._lastSentValue = value + if (!sendTimer.running) sendTimer.start() + } + } else { + compactEditor.slidersPressedCount-- + sendTimer.stop() + if (slider.isResetting) { + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, 0.0) + } else { + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, value / sliderRow.max) + } + if (compactEditor.controllerRef) compactEditor.controllerRef.update_histogram() + } + } + + onMoved: { + if (slider.isResetting) return + _pendingValue = value + if (!sendTimer.running) sendTimer.start() + } + + Behavior on value { + enabled: !slider.pressed && !slider.isResetting + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } + + background: Item { + x: slider.leftPadding + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: slider.availableWidth + height: 4 + + Rectangle { + anchors.fill: parent; radius: 2 + color: "#2e2e2e"; border.color: "#383838"; border.width: 1 + } + + Rectangle { + property real range: slider.to - slider.from + property real anchorVal: Math.max(slider.from, Math.min(slider.to, 0)) + property real anchorPos: (anchorVal - slider.from) / range + x: Math.min(slider.visualPosition, anchorPos) * parent.width + width: Math.abs(slider.visualPosition - anchorPos) * parent.width + height: parent.height; radius: 2 + color: compactEditor.accentColor; opacity: 0.6 + Behavior on width { NumberAnimation { duration: 100 } } + Behavior on x { NumberAnimation { duration: 100 } } + } + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 10; height: 10; radius: 5 + color: "#e8e6e3" + border.color: slider.pressed ? compactEditor.accentColor : "#5a5755" + border.width: 1 + scale: handleHover.hovered || slider.pressed ? 1.3 : 1.0 + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutBack } } + + HoverHandler { id: handleHover } + } + } + + Text { + id: valueReadout + property int displayValue: Math.round(slider.value) + text: displayValue === 0 ? "0" : (displayValue > 0 ? "+" + displayValue : "−" + Math.abs(displayValue)) + Layout.preferredWidth: 32 + Layout.alignment: Qt.AlignVCenter + horizontalAlignment: Text.AlignRight + font.family: "IBM Plex Mono" + font.pixelSize: 10 + color: displayValue === 0 ? compactEditor.mutedText : "#e8e6e3" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!slider.isResetting) slider.triggerReset() + } + } + } + } + } +} diff --git a/faststack/qml/ImageEditorDialog.qml b/faststack/qml/ImageEditorDialog.qml index e783034..cfa8e2d 100644 --- a/faststack/qml/ImageEditorDialog.qml +++ b/faststack/qml/ImageEditorDialog.qml @@ -14,7 +14,7 @@ Window { property var uiStateRef: null property var controllerRef: null title: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.editorFilename ? "Image Editor - " + imageEditorDialog.uiStateRef.editorFilename + " (" + imageEditorDialog.uiStateRef.editorBitDepth + "-bit)" : "Image Editor" - visible: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.isEditorOpen : false + visible: imageEditorDialog.uiStateRef ? (imageEditorDialog.uiStateRef.isEditorOpen && imageEditorDialog.uiStateRef.isEditorExpanded) : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint Settings { id: histSettings @@ -491,7 +491,7 @@ Window { spacing: 10 // Reset (Tertiary) - Button { + Button { id: resetButton text: "Reset" flat: true @@ -508,6 +508,23 @@ Window { } } + // Compact (collapse to compact editor) + Button { + id: compactButton + text: "Compact" + flat: true + Layout.preferredWidth: 80 + Material.foreground: "#6b6764" + onClicked: { + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorExpanded = false + } + background: Rectangle { + color: "transparent" + radius: 4 + border.color: "transparent" + } + } + Item { Layout.fillWidth: true } // Spacer // Close (Secondary) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 00b3e7f..49cd8a5 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -798,9 +798,12 @@ ApplicationWindow { actionsMenu.close() return } - root.uiStateRef.isEditorOpen = !root.uiStateRef.isEditorOpen - if (root.uiStateRef.isEditorOpen && root.controllerRef) { - root.controllerRef.load_image_for_editing() + if (root.uiStateRef.isEditorOpen) { + root.uiStateRef.isEditorOpen = false + } else { + root.uiStateRef.isEditorExpanded = false + root.uiStateRef.isEditorOpen = true + if (root.controllerRef) root.controllerRef.load_image_for_editing() } } actionsMenu.close() @@ -1130,7 +1133,7 @@ ApplicationWindow { Shortcut { sequence: "E" - context: Qt.ApplicationShortcut + context: Qt.WindowShortcut enabled: root.uiStateRef ? !root.uiStateRef.isDialogOpen : true onActivated: { if (!root.uiStateRef) return @@ -1143,6 +1146,7 @@ ApplicationWindow { if (root.uiStateRef.isEditorOpen) { root.uiStateRef.isEditorOpen = false } else { + root.uiStateRef.isEditorExpanded = false root.uiStateRef.isEditorOpen = true if (root.controllerRef) { root.controllerRef.load_image_for_editing() @@ -1846,6 +1850,15 @@ ApplicationWindow { gridLineColor: root.isDarkTheme ? "#454545" : "#dcdcdc" } + CompactEditorWindow { + id: compactEditorWindow + onVisibleChanged: { + if (!visible) { + mainViewLoader.forceActiveFocus() + } + } + } + ImageEditorDialog { id: imageEditorDialog backgroundColor: root.currentBackgroundColor diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 3245473..b6de78d 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -263,6 +263,7 @@ class UIState(QObject): autoLevelStrengthAutoChanged = Signal(bool) # Image Editor Signals is_editor_open_changed = Signal(bool) + is_editor_expanded_changed = Signal(bool) editorImageChanged = ( Signal() ) # New signal for when the image loaded in editor changes @@ -344,6 +345,7 @@ def __init__(self, app_controller, clock_func=None): self._status_message = "" # New private variable for status message # Image Editor State self._is_editor_open = False + self._is_editor_expanded = False self._is_cropping = False self._is_crop_rotating = False self._is_histogram_visible = False @@ -993,6 +995,16 @@ def isEditorOpen(self, new_value: bool): self._is_editor_open = new_value self.is_editor_open_changed.emit(new_value) + @Property(bool, notify=is_editor_expanded_changed) + def isEditorExpanded(self) -> bool: + return self._is_editor_expanded + + @isEditorExpanded.setter + def isEditorExpanded(self, new_value: bool): + if self._is_editor_expanded != new_value: + self._is_editor_expanded = new_value + self.is_editor_expanded_changed.emit(new_value) + @Property(str, notify=editorImageChanged) def editorFilename(self) -> str: """Returns the filename of the image currently being edited (may be .tif for developed RAW).""" From ebd6ef9ad6940b97493d5d3ec9d58f59e5185868 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 29 May 2026 01:08:33 -0700 Subject: [PATCH 2/2] Fix minor bugs --- faststack/app.py | 14 ++++++-------- faststack/qml/CompactEditorWindow.qml | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index ae2ea91..ec2c84c 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1073,13 +1073,13 @@ def handle_key_from_histogram(self, key: int, modifiers: int, text: str): @Slot(int, int, str) def handle_key_from_compact_editor(self, key: int, modifiers: int, text: str): - """Forward navigation keys from the compact editor to main window.""" + """Forward navigation keys from the compact editor through eventFilter.""" from PySide6.QtGui import QKeyEvent event = QKeyEvent( QKeyEvent.Type.KeyPress, key, Qt.KeyboardModifier(modifiers), text ) - self.keybinder.handle_key_press(event) + self.eventFilter(self.main_window, event) def eventFilter(self, watched, event) -> bool: # Don't handle key events when a dialog is open @@ -2968,8 +2968,9 @@ def prepare_for_app_close(self) -> bool: @Slot() def save_edited_image(self): """Save the current live editor session in the background.""" + close_after = self.ui_state.isEditorOpen and self.ui_state.isEditorExpanded request = self._prepare_current_session_save_request( - editor_was_open=self.ui_state.isEditorOpen, + editor_was_open=close_after, success_message="Image saved", ) if request is None: @@ -2981,7 +2982,7 @@ def save_edited_image(self): if not self._submit_save_request_async(request, saving_status="Saving..."): return - if self.ui_state.isEditorOpen and self.ui_state.isEditorExpanded: + if close_after: self.ui_state.isEditorOpen = False @Slot(object) @@ -3100,10 +3101,7 @@ def _on_save_finished(self, save_result: dict): # 1. Editor Cleanup (only if revision is unchanged) if still_on_identical_revision: if editor_was_open: - if ( - self.ui_state.isEditorOpen - and self.ui_state.isEditorExpanded - ): + if self.ui_state.isEditorOpen: self.ui_state.isEditorOpen = False # Closing triggers _on_editor_open_changed -> image_editor.clear() # but we call it explicitly here just in case they closed it manually. diff --git a/faststack/qml/CompactEditorWindow.qml b/faststack/qml/CompactEditorWindow.qml index 00454ac..50f2b09 100644 --- a/faststack/qml/CompactEditorWindow.qml +++ b/faststack/qml/CompactEditorWindow.qml @@ -70,6 +70,14 @@ Window { property int updatePulse: 0 property int lastLoadedIndex: -1 + property string closeTooltip: "Close editor" + + function refreshCloseTooltip() { + if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) + compactEditor.closeTooltip = "Discard unsaved edits and close" + else + compactEditor.closeTooltip = "Close editor" + } Timer { id: deferredLoadTimer @@ -245,6 +253,10 @@ Window { Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 padding: 0 flat: true + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: compactEditor.closeTooltip + onHoveredChanged: if (hovered) compactEditor.refreshCloseTooltip() onClicked: { if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { discardDialog.open() @@ -558,6 +570,10 @@ Window { Layout.preferredWidth: 60 Layout.preferredHeight: 28 font.pixelSize: 11 + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: compactEditor.closeTooltip + onHoveredChanged: if (hovered) compactEditor.refreshCloseTooltip() onClicked: { if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { discardDialog.open()