diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 7174ec6c..a8d29d3f 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -520,13 +520,39 @@ Singleton { property real notificationSummaryFontSize: Spec.SPEC.notificationSummaryFontSize.def property real notificationBodyFontSize: Spec.SPEC.notificationBodyFontSize.def property bool notepadShowLineNumbers: false + property bool notepadAutoSave: false + property string notepadSlideoutSide: "right" + property string notepadDefaultMode: "slideout" property real notepadTransparencyOverride: -1 property real notepadLastCustomTransparency: 0.7 + property bool notepadUseCompositorGap: false + property int notepadEdgeGap: 0 + + // Compositor layout gap when enabled and available, else the manual value. + readonly property int notepadEffectiveEdgeGap: { + if (notepadUseCompositorGap) { + var g = -1; + if (CompositorService.isNiri) + g = niriLayoutGapsOverride; + else if (CompositorService.isHyprland) + g = hyprlandLayoutGapsOverride; + else if (CompositorService.isMango) + g = mangoLayoutGapsOverride; + if (g >= 0) + return g; + } + return Math.max(0, notepadEdgeGap); + } onNotepadUseMonospaceChanged: saveSettings() onNotepadFontFamilyChanged: saveSettings() onNotepadFontSizeChanged: saveSettings() onNotepadShowLineNumbersChanged: saveSettings() + onNotepadAutoSaveChanged: saveSettings() + onNotepadSlideoutSideChanged: saveSettings() + onNotepadDefaultModeChanged: saveSettings() + onNotepadUseCompositorGapChanged: saveSettings() + onNotepadEdgeGapChanged: saveSettings() // onCenteringModeChanged: saveSettings() onNotepadTransparencyOverrideChanged: { if (notepadTransparencyOverride > 0) { diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index d9e88a8d..cf6a0ce4 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -264,8 +264,13 @@ var SPEC = { notificationSummaryFontSize: { def: 0 }, notificationBodyFontSize: { def: 0 }, notepadShowLineNumbers: { def: false }, + notepadAutoSave: { def: false }, + notepadSlideoutSide: { def: "right" }, + notepadDefaultMode: { def: "slideout" }, notepadTransparencyOverride: { def: -1 }, notepadLastCustomTransparency: { def: 0.7 }, + notepadUseCompositorGap: { def: false }, + notepadEdgeGap: { def: 0 }, soundsEnabled: { def: true }, useSystemSoundTheme: { def: false }, diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index fc38510c..861a5798 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -1093,11 +1093,17 @@ Item { slideoutWidth: 480 expandable: true expandedWidthValue: 960 + edgeGap: SettingsData.notepadEffectiveEdgeGap + slideEdge: SettingsData.notepadSlideoutSide content: Component { Notepad { slideout: notepadSlideout onHideRequested: notepadSlideout.hide() + onPopoutRequested: { + notepadSlideout.hide(); + PopoutService.openNotepadPopout(); + } } } @@ -1114,6 +1120,24 @@ Item { Component.onCompleted: PopoutService.notepadSlideouts = instances } + LazyLoader { + id: notepadPopoutLoader + active: false + + Component.onCompleted: { + PopoutService.notepadPopoutLoader = notepadPopoutLoader; + } + + onActiveChanged: { + if (active && item) { + PopoutService.notepadPopout = item; + PopoutService._onNotepadPopoutLoaded(); + } + } + + NotepadPopoutWindow {} + } + LazyLoader { id: powerMenuModalLoader diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index c94e0115..a02a9d71 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -373,6 +373,10 @@ Item { } function open(): string { + if (SettingsData.notepadDefaultMode === "popout") { + PopoutService.openNotepadPopout(); + return "NOTEPAD_OPEN_SUCCESS"; + } var instance = getActiveNotepadInstance(); if (instance) { instance.show(); @@ -391,6 +395,10 @@ Item { } function toggle(): string { + if (SettingsData.notepadDefaultMode === "popout") { + PopoutService.toggleNotepadPopout(); + return "NOTEPAD_TOGGLE_SUCCESS"; + } var instance = getActiveNotepadInstance(); if (instance) { instance.toggle(); diff --git a/quickshell/Modules/DankBar/Widgets/NotepadButton.qml b/quickshell/Modules/DankBar/Widgets/NotepadButton.qml index 1871d898..d0de0753 100644 --- a/quickshell/Modules/DankBar/Widgets/NotepadButton.qml +++ b/quickshell/Modules/DankBar/Widgets/NotepadButton.qml @@ -32,9 +32,20 @@ BasePill { } readonly property var notepadInstance: resolveNotepadInstance() - readonly property bool isActive: notepadInstance?.isVisible ?? false + readonly property bool popoutDefault: SettingsData.notepadDefaultMode === "popout" + readonly property bool isActive: popoutDefault ? (PopoutService.notepadPopout?.visible ?? false) : (notepadInstance?.isVisible ?? false) property bool isAutoHideBar: false + function showActiveSurface() { + if (root.popoutDefault) { + PopoutService.openNotepadPopout(); + return; + } + const instance = prepareNotepadInstance(root.notepadInstance); + if (instance && typeof instance.show === "function") + instance.show(); + } + function prepareNotepadInstance(instance) { if (instance) instance.triggerUsesOverlayLayer = root.barUsesOverlayLayer; @@ -75,20 +86,14 @@ BasePill { function openTabByIndex(tabIndex) { if (tabIndex < 0) return; - const instance = prepareNotepadInstance(root.notepadInstance); - if (instance && typeof instance.show === "function") { - instance.show(); - } + showActiveSurface(); Qt.callLater(() => { NotepadStorageService.switchToTab(tabIndex); }); } function openNewNote() { - const instance = prepareNotepadInstance(root.notepadInstance); - if (instance && typeof instance.show === "function") { - instance.show(); - } + showActiveSurface(); Qt.callLater(() => { NotepadStorageService.createNewTab(); }); @@ -147,6 +152,10 @@ BasePill { openContextMenu(); return; } + if (root.popoutDefault) { + PopoutService.toggleNotepadPopout(); + return; + } const inst = prepareNotepadInstance(root.notepadInstance); if (inst) { inst.toggle(); diff --git a/quickshell/Modules/Notepad/Notepad.qml b/quickshell/Modules/Notepad/Notepad.qml index 6a024781..46fd11ed 100644 --- a/quickshell/Modules/Notepad/Notepad.qml +++ b/quickshell/Modules/Notepad/Notepad.qml @@ -1,5 +1,6 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts import Quickshell import Quickshell.Io import qs.Common @@ -21,11 +22,24 @@ Item { property var currentTab: NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null property bool showSettingsMenu: false property string pendingSaveContent: "" + readonly property bool conflictBannerVisible: currentTab !== null && NotepadStorageService.conflictTabId === currentTab.id property var slideout: null + property bool inPopout: false + property bool surfaceVisible: slideout ? slideout.isVisible : true signal hideRequested + signal popoutRequested + signal dockRequested signal previewRequested(string content) + function externalSync() { + textEditor.syncFromDisk(); + } + + function flushAutoSave() { + textEditor.autoSaveToSession(); + } + Ref { service: NotepadStorageService } @@ -36,6 +50,37 @@ Item { function onAboutToHide() { textEditor.autoSaveToSession(); } + function onRevealed() { + textEditor.syncFromDisk(); + } + } + + function showConflictBanner(diskContent) { + if (!currentTab) + return; + NotepadStorageService.flagConflict(currentTab.id, diskContent); + } + + function resolveConflictKeepEdits() { + if (!root.conflictBannerVisible) + return; + NotepadStorageService.clearConflict(); + if (currentTab && currentTab.filePath && !currentTab.isTemporary) { + root.saveToFile("file://" + currentTab.filePath); + } + } + + function resolveConflictReload() { + if (!root.conflictBannerVisible) + return; + const diskContent = NotepadStorageService.conflictDiskContent; + NotepadStorageService.clearConflict(); + textEditor.reloadFromDisk(diskContent); + } + + function dismissConflictBanner() { + if (root.conflictBannerVisible) + NotepadStorageService.clearConflict(); } function hasUnsavedChanges() { @@ -51,10 +96,14 @@ Item { } function performCreateNewTab() { + textEditor.commitLiveBuffer(); NotepadStorageService.createNewTab(); + textEditor.applyingShared = true; textEditor.text = ""; textEditor.lastSavedContent = ""; + textEditor.loadedTabId = -1; textEditor.contentLoaded = true; + textEditor.applyingShared = false; textEditor.textArea.forceActiveFocus(); } @@ -86,7 +135,6 @@ Item { NotepadStorageService.switchToTab(tabIndex); Qt.callLater(() => { - textEditor.loadCurrentTabContent(); if (currentTab) { root.currentFileName = currentTab.fileName || ""; root.currentFileUrl = currentTab.fileUrl || ""; @@ -100,6 +148,7 @@ Item { var content = textEditor.text; var filePath = fileUrl.toString().replace(/^file:\/\//, ''); + textEditor.externalWatchPaused = true; saveFileView.path = ""; pendingSaveContent = content; saveFileView.path = filePath; @@ -109,6 +158,53 @@ Item { }); } + function saveExternalWithFreshnessCheck() { + if (!currentTab || currentTab.isTemporary || !currentTab.filePath) + return; + const filePath = currentTab.filePath; + loadFileView.path = ""; + loadFileView.path = filePath; + + if (!loadFileView.waitForJob()) { + saveToFile("file://" + filePath); + return; + } + Qt.callLater(() => { + if (!currentTab || currentTab.isTemporary || currentTab.filePath !== filePath) + return; + const diskContent = loadFileView.text(); + if (diskContent !== undefined && diskContent !== null && diskContent !== textEditor.text && diskContent !== textEditor.lastSavedContent) { + root.showConflictBanner(diskContent); + return; + } + saveToFile("file://" + filePath); + }); + } + + function autoSaveExternal() { + if (!SettingsData.notepadAutoSave) + return; + if (!currentTab || currentTab.isTemporary || !currentTab.filePath) + return; + if (!textEditor.hasUnsavedChanges()) + return; + const filePath = currentTab.filePath; + loadFileView.path = ""; + loadFileView.path = filePath; + if (!loadFileView.waitForJob()) + return; + Qt.callLater(() => { + if (!currentTab || currentTab.isTemporary || currentTab.filePath !== filePath) + return; + const diskContent = loadFileView.text(); + if (diskContent === undefined || diskContent === null) + return; + if (diskContent !== textEditor.lastSavedContent) + return; + saveToFile("file://" + filePath); + }); + } + function loadFromFile(fileUrl) { if (hasUnsavedTemporaryContent()) { root.pendingFileUrl = fileUrl; @@ -146,14 +242,151 @@ Item { root.currentFileName = fileName; root.currentFileUrl = fileUrl; - textEditor.saveCurrentTabContent(); + textEditor.loadedTabId = currentTab.id; + NotepadStorageService.clearSessionBuffer(currentTab.id); + if (root.conflictBannerVisible) + NotepadStorageService.clearConflict(); } }); } } + Item { + id: conflictBanner + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: root.conflictBannerVisible ? bannerRect.implicitHeight : 0 + visible: height > 0 + clip: true + z: 5 + + Behavior on height { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + StyledRect { + id: bannerRect + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: bannerLayout.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.warning, 0.12) + border.color: Theme.withAlpha(Theme.warning, 0.5) + border.width: 1 + + ColumnLayout { + id: bannerLayout + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingM + + DankIcon { + Layout.alignment: Qt.AlignVCenter + name: "sync_problem" + size: Theme.iconSize - 2 + color: Theme.warning + } + + StyledText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: I18n.tr("File changed on disk") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + wrapMode: Text.NoWrap + elide: Text.ElideRight + } + + DankActionButton { + Layout.alignment: Qt.AlignVCenter + iconName: "close" + iconSize: Theme.iconSizeSmall + iconColor: Theme.surfaceText + buttonSize: 28 + onClicked: root.dismissConflictBanner() + } + } + + RowLayout { + id: bannerActions + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + spacing: Theme.spacingS + + StyledRect { + readonly property real actionWidth: Math.min(keepText.implicitWidth + Theme.spacingM * 2, Math.max(104, (bannerActions.width - bannerActions.spacing) / 2)) + Layout.preferredWidth: actionWidth + Layout.preferredHeight: 32 + radius: Theme.cornerRadius + color: "transparent" + border.color: Theme.outlineMedium + border.width: 1 + + StateLayer { + anchors.fill: parent + cornerRadius: parent.radius + stateColor: Theme.surfaceText + onClicked: root.resolveConflictKeepEdits() + } + + StyledText { + id: keepText + anchors.centerIn: parent + width: parent.width - Theme.spacingM + text: I18n.tr("Keep My Edits") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + StyledRect { + readonly property real actionWidth: Math.min(reloadText.implicitWidth + Theme.spacingM * 2, Math.max(116, (bannerActions.width - bannerActions.spacing) / 2)) + Layout.preferredWidth: actionWidth + Layout.preferredHeight: 32 + radius: Theme.cornerRadius + color: Theme.primary + + StateLayer { + anchors.fill: parent + cornerRadius: parent.radius + stateColor: Theme.background + onClicked: root.resolveConflictReload() + } + + StyledText { + id: reloadText + anchors.centerIn: parent + width: parent.width - Theme.spacingM + text: I18n.tr("Reload From Disk") + font.pixelSize: Theme.fontSizeSmall + color: Theme.background + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + } + } + } + Column { - anchors.fill: parent + anchors.top: conflictBanner.bottom + anchors.topMargin: root.conflictBannerVisible ? Theme.spacingM : 0 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom spacing: Theme.spacingM NotepadTabs { @@ -178,11 +411,12 @@ Item { id: textEditor width: parent.width height: parent.height - tabBar.height - Theme.spacingM * 2 + inPopout: root.inPopout + surfaceVisible: root.surfaceVisible onSaveRequested: { if (currentTab && !currentTab.isTemporary && currentTab.filePath) { - var fileUrl = "file://" + currentTab.filePath; - saveToFile(fileUrl); + root.saveExternalWithFreshnessCheck(); } else { root.fileDialogOpen = true; saveBrowserLoader.active = true; @@ -214,12 +448,28 @@ Item { onEscapePressed: { textEditor.autoSaveToSession(); - root.hideRequested(); + if (showSettingsMenu) { + showSettingsMenu = false; + return; + } + if (!root.inPopout) { + root.hideRequested(); + } } onSettingsRequested: { showSettingsMenu = !showSettingsMenu; } + + onPopoutRequested: root.popoutRequested() + + onDockRequested: root.dockRequested() + + onConflictDetected: diskContent => { + root.showConflictBanner(diskContent); + } + + onAutoSaveRequested: root.autoSaveExternal() } } @@ -242,17 +492,24 @@ Item { printErrors: true onSaved: { - if (currentTab && saveFileView.path && pendingSaveContent) { + if (currentTab && saveFileView.path) { NotepadStorageService.updateTabMetadata(NotepadStorageService.currentTabIndex, { hasUnsavedChanges: false, lastSavedContent: pendingSaveContent }); root.lastSavedFileContent = pendingSaveContent; - pendingSaveContent = ""; + textEditor.lastSavedContent = pendingSaveContent; + textEditor.ignoreNextExternalChange = true; + textEditor.commitLiveBuffer(); + if (root.conflictBannerVisible) + NotepadStorageService.clearConflict(); } + textEditor.externalWatchPaused = false; + pendingSaveContent = ""; } onSaveFailed: error => { + textEditor.externalWatchPaused = false; pendingSaveContent = ""; } } @@ -298,6 +555,7 @@ Item { root.currentFileName = fileName; root.currentFileUrl = fileUrl; + textEditor.externalWatchPaused = true; if (currentTab) { NotepadStorageService.saveTabAs(NotepadStorageService.currentTabIndex, cleanPath); @@ -343,7 +601,7 @@ Item { browserTitle: I18n.tr("Open Notepad File") browserIcon: "folder_open" browserType: "notepad_load" - fileExtensions: ["*.txt", "*.md", "*.*"] + fileExtensions: ["*"] allowStacking: true onFileSelected: path => { @@ -376,6 +634,7 @@ Item { modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 180 shouldBeVisible: false allowStacking: true + useOverlayLayer: true onBackgroundClicked: { close(); diff --git a/quickshell/Modules/Notepad/NotepadPopoutWindow.qml b/quickshell/Modules/Notepad/NotepadPopoutWindow.qml new file mode 100644 index 00000000..5f6659b7 --- /dev/null +++ b/quickshell/Modules/Notepad/NotepadPopoutWindow.qml @@ -0,0 +1,134 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Notepad + +FloatingWindow { + id: win + + property alias shouldBeVisible: win.visible + + function show() { + visible = true; + } + + function hide() { + visible = false; + } + + function toggle() { + visible = !visible; + } + + title: I18n.tr("Notepad") + minimumSize: Qt.size(360, 320) + implicitWidth: 640 + implicitHeight: 760 + color: Theme.surfaceContainer + visible: false + + onVisibleChanged: { + if (visible) { + Qt.callLater(notepad.externalSync); + } else { + notepad.flushAutoSave(); + } + } + + Item { + anchors.fill: parent + + Item { + id: titleBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 44 + z: 10 + + MouseArea { + anchors.fill: parent + onPressed: windowControls.tryStartMove() + onDoubleClicked: windowControls.tryToggleMaximize() + } + + Rectangle { + anchors.fill: parent + color: Theme.surfaceContainerHigh + opacity: 0.5 + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "edit_note" + size: Theme.iconSize - 2 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Notepad") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankActionButton { + visible: windowControls.canMaximize + circular: false + iconName: win.maximized ? "fullscreen_exit" : "fullscreen" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: windowControls.tryToggleMaximize() + } + + DankActionButton { + circular: false + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: win.hide() + } + } + } + + Notepad { + id: notepad + anchors.top: titleBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Theme.spacingM + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: Theme.spacingM + inPopout: true + surfaceVisible: win.visible + onHideRequested: win.hide() + onDockRequested: { + win.hide(); + PopoutService.openNotepadSlideout(); + } + } + } + + FloatingWindowControls { + id: windowControls + targetWindow: win + } +} diff --git a/quickshell/Modules/Notepad/NotepadSettings.qml b/quickshell/Modules/Notepad/NotepadSettings.qml index 7f76e81d..ff7eb327 100644 --- a/quickshell/Modules/Notepad/NotepadSettings.qml +++ b/quickshell/Modules/Notepad/NotepadSettings.qml @@ -10,6 +10,7 @@ Item { property var cachedFontFamilies: [] property var cachedMonoFamilies: [] property bool fontsEnumerated: false + property bool shortcutsExpanded: false signal settingsRequested signal findRequested @@ -62,11 +63,23 @@ Item { } } - MouseArea { + Rectangle { anchors.fill: parent visible: root.isVisible - onClicked: root.settingsRequested() z: 50 + color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.85) + + WheelHandler { + // Hold scroll so the editor beneath doesn't move while settings are open. + onWheel: event => { + event.accepted = true; + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.settingsRequested() + } } Rectangle { @@ -74,8 +87,8 @@ Item { visible: root.isVisible anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter - width: 360 - height: settingsColumn.implicitHeight + Theme.spacingXL * 2 + width: Math.min(360, root.width - Theme.spacingL * 2) + height: Math.min(settingsColumn.implicitHeight + Theme.spacingXL * 2, root.height - Theme.spacingL * 2) radius: Theme.cornerRadius color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, Theme.notepadTransparency) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) @@ -93,274 +106,458 @@ Item { z: parent.z - 1 } - Column { - id: settingsColumn - width: parent.width - Theme.spacingXL * 2 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: Theme.spacingXL - spacing: Theme.spacingS + DankFlickable { + id: settingsFlickable + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: settingsColumn.implicitHeight + Theme.spacingXL * 2 - Rectangle { - width: parent.width - height: 36 - color: "transparent" + Column { + id: settingsColumn + x: Theme.spacingXL + y: Theme.spacingXL + width: settingsFlickable.width - Theme.spacingXL * 2 + spacing: Theme.spacingS - StyledText { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - text: I18n.tr("Notepad Font Settings") - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - color: Theme.surfaceText - } - } - - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - } - - DankToggle { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - text: I18n.tr("Use Monospace Font") - description: I18n.tr("Toggle fonts") - checked: SettingsData.notepadUseMonospace - onToggled: checked => { - SettingsData.notepadUseMonospace = checked; - } - } - - DankToggle { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - text: I18n.tr("Show Line Numbers") - description: I18n.tr("Display line numbers in editor") - checked: SettingsData.notepadShowLineNumbers - onToggled: checked => { - SettingsData.notepadShowLineNumbers = checked; - } - } - - StyledRect { - width: parent.width - height: 60 - radius: Theme.cornerRadius - color: "transparent" - - StateLayer { - anchors.fill: parent - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - stateColor: Theme.primary - cornerRadius: parent.radius - onClicked: root.findRequested() - } - - Row { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - anchors.right: parent.right - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: "search" - size: Theme.iconSize - 2 - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - StyledText { - text: I18n.tr("Find in Text") - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - color: Theme.surfaceText - } - - StyledText { - text: I18n.tr("Open search bar to find text") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - } - } - } - - Rectangle { - width: parent.width - height: visible ? (fontDropdown.height + Theme.spacingS) : 0 - color: "transparent" - visible: !SettingsData.notepadUseMonospace - - DankDropdown { - id: fontDropdown - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - text: I18n.tr("Font Family") - options: cachedFontFamilies - currentValue: { - if (!SettingsData.notepadFontFamily || SettingsData.notepadFontFamily === "") - return I18n.tr("Default (Global)"); - else - return SettingsData.notepadFontFamily; - } - enableFuzzySearch: true - onValueChanged: value => { - if (value && (value.startsWith("Default") || value === "Default (Global)")) { - SettingsData.notepadFontFamily = ""; - } else { - SettingsData.notepadFontFamily = value; - } - } - } - } - - Rectangle { - width: parent.width - height: fontSizeRow.height + Theme.spacingS - color: "transparent" - - Row { - id: fontSizeRow + Rectangle { width: parent.width - spacing: Theme.spacingS + height: 36 + color: "transparent" - Column { - width: parent.width - fontSizeControls.width - Theme.spacingM - spacing: Theme.spacingXS + StyledText { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Notepad Settings") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + } - StyledText { - text: I18n.tr("Font Size") - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - color: Theme.surfaceText - } + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } - StyledText { - text: SettingsData.notepadFontSize + "px" - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - width: parent.width - } + DankToggle { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Use Monospace Font") + description: I18n.tr("Toggle fonts") + checked: SettingsData.notepadUseMonospace + onToggled: checked => { + SettingsData.notepadUseMonospace = checked; + } + } + + DankToggle { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Show Line Numbers") + description: I18n.tr("Display line numbers in editor") + checked: SettingsData.notepadShowLineNumbers + onToggled: checked => { + SettingsData.notepadShowLineNumbers = checked; + } + } + + DankToggle { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Auto-save to disk") + description: I18n.tr("Automatically save changes to opened files as you type") + checked: SettingsData.notepadAutoSave + onToggled: checked => { + SettingsData.notepadAutoSave = checked; + } + } + + StyledRect { + width: parent.width + height: 60 + radius: Theme.cornerRadius + color: "transparent" + + StateLayer { + anchors.fill: parent + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + stateColor: Theme.primary + cornerRadius: parent.radius + onClicked: root.findRequested() } Row { - id: fontSizeControls - spacing: Theme.spacingS + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM - DankActionButton { - buttonSize: 32 - iconName: "remove" - iconSize: Theme.iconSizeSmall - enabled: SettingsData.notepadFontSize > 8 - backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5) - iconColor: Theme.surfaceText - onClicked: { - var newSize = Math.max(8, SettingsData.notepadFontSize - 1); - SettingsData.notepadFontSize = newSize; - } + DankIcon { + name: "search" + size: Theme.iconSize - 2 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter } - Rectangle { - width: 60 - height: 32 - radius: Theme.cornerRadius - color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) - border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - border.width: 1 + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS StyledText { - anchors.centerIn: parent - text: SettingsData.notepadFontSize + "px" + text: I18n.tr("Find in Text") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Open search bar to find text") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + } + + Rectangle { + width: parent.width + height: visible ? (fontDropdown.height + Theme.spacingS) : 0 + color: "transparent" + visible: !SettingsData.notepadUseMonospace + + DankDropdown { + id: fontDropdown + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Font Family") + options: cachedFontFamilies + currentValue: { + if (!SettingsData.notepadFontFamily || SettingsData.notepadFontFamily === "") + return I18n.tr("Default (Global)"); + else + return SettingsData.notepadFontFamily; + } + enableFuzzySearch: true + onValueChanged: value => { + if (value && (value.startsWith("Default") || value === "Default (Global)")) { + SettingsData.notepadFontFamily = ""; + } else { + SettingsData.notepadFontFamily = value; + } + } + } + } + + Rectangle { + width: parent.width + height: fontSizeRow.height + Theme.spacingS + color: "transparent" + + Row { + id: fontSizeRow + width: parent.width + spacing: Theme.spacingS + + Column { + width: parent.width - fontSizeControls.width - Theme.spacingM + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Font Size") font.pixelSize: Theme.fontSizeSmall font.weight: Font.Medium color: Theme.surfaceText } + + StyledText { + text: SettingsData.notepadFontSize + "px" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + } } - DankActionButton { - buttonSize: 32 - iconName: "add" - iconSize: Theme.iconSizeSmall - enabled: SettingsData.notepadFontSize < 48 - backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5) - iconColor: Theme.surfaceText - onClicked: { - var newSize = Math.min(48, SettingsData.notepadFontSize + 1); - SettingsData.notepadFontSize = newSize; + Row { + id: fontSizeControls + spacing: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + + DankActionButton { + buttonSize: 32 + iconName: "remove" + iconSize: Theme.iconSizeSmall + enabled: SettingsData.notepadFontSize > 8 + backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5) + iconColor: Theme.surfaceText + onClicked: { + var newSize = Math.max(8, SettingsData.notepadFontSize - 1); + SettingsData.notepadFontSize = newSize; + } + } + + Rectangle { + width: 60 + height: 32 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: 1 + + StyledText { + anchors.centerIn: parent + text: SettingsData.notepadFontSize + "px" + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + } + + DankActionButton { + buttonSize: 32 + iconName: "add" + iconSize: Theme.iconSizeSmall + enabled: SettingsData.notepadFontSize < 48 + backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5) + iconColor: Theme.surfaceText + onClicked: { + var newSize = Math.min(48, SettingsData.notepadFontSize + 1); + SettingsData.notepadFontSize = newSize; + } } } } } - } - Rectangle { - width: parent.width - height: transparencySliderColumn.height + Theme.spacingS - color: "transparent" - - Column { - id: transparencySliderColumn + Rectangle { width: parent.width - spacing: Theme.spacingS + height: transparencySliderColumn.height + Theme.spacingS + color: "transparent" - DankToggle { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - text: I18n.tr("Custom Transparency") - description: I18n.tr("Override global transparency for Notepad") - checked: SettingsData.notepadTransparencyOverride >= 0 - onToggled: checked => { - if (checked) { - SettingsData.notepadTransparencyOverride = SettingsData.notepadLastCustomTransparency; - } else { - SettingsData.notepadTransparencyOverride = -1; + Column { + id: transparencySliderColumn + width: parent.width + spacing: Theme.spacingS + + DankToggle { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Surface Opacity") + description: I18n.tr("Override global transparency for Notepad") + checked: SettingsData.notepadTransparencyOverride >= 0 + onToggled: checked => { + if (checked) { + SettingsData.notepadTransparencyOverride = SettingsData.notepadLastCustomTransparency; + } else { + SettingsData.notepadTransparencyOverride = -1; + } } } - } - DankSlider { - anchors.left: parent.left - anchors.leftMargin: -Theme.spacingM - width: parent.width + Theme.spacingM - height: 24 - visible: SettingsData.notepadTransparencyOverride >= 0 - value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100) - minimum: 0 - maximum: 100 - unit: "" - showValue: true - wheelEnabled: false - onSliderValueChanged: newValue => { - if (SettingsData.notepadTransparencyOverride >= 0) { - SettingsData.notepadTransparencyOverride = newValue / 100; + DankSlider { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + height: 24 + visible: SettingsData.notepadTransparencyOverride >= 0 + value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100) + minimum: 0 + maximum: 100 + unit: "" + showValue: true + wheelEnabled: false + onSliderValueChanged: newValue => { + if (SettingsData.notepadTransparencyOverride >= 0) { + SettingsData.notepadTransparencyOverride = newValue / 100; + } } } } } - } - StyledText { - width: parent.width - text: SettingsData.notepadUseMonospace ? I18n.tr("Using global monospace font from Settings → Personalization") : I18n.tr("Global fonts can be configured in Settings → Personalization") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceTextMedium - wrapMode: Text.WordWrap - opacity: 0.8 + Rectangle { + width: parent.width + height: gapColumn.height + Theme.spacingS + color: "transparent" + + Column { + id: gapColumn + width: parent.width + spacing: Theme.spacingS + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Default Mode") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + + DankButtonGroup { + model: [I18n.tr("Slideout"), I18n.tr("Popout")] + size: "small" + currentIndex: SettingsData.notepadDefaultMode === "popout" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.notepadDefaultMode = index === 1 ? "popout" : "slideout"; + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + visible: SettingsData.notepadDefaultMode !== "popout" + + StyledText { + text: I18n.tr("Open From") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + + DankButtonGroup { + model: [I18n.tr("Right"), I18n.tr("Left")] + size: "small" + currentIndex: SettingsData.notepadSlideoutSide === "left" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.notepadSlideoutSide = index === 1 ? "left" : "right"; + } + } + } + + DankToggle { + anchors.left: parent.left + anchors.leftMargin: -Theme.spacingM + width: parent.width + Theme.spacingM + text: I18n.tr("Auto Compositor Gaps") + description: I18n.tr("Inset the Notepad from screen edges using the compositor's configured gaps") + checked: SettingsData.notepadUseCompositorGap + onToggled: checked => { + SettingsData.notepadUseCompositorGap = checked; + } + } + + StyledText { + visible: !SettingsData.notepadUseCompositorGap + text: I18n.tr("Manual Gaps") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + } + + DankSlider { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingXS + width: parent.width - Theme.spacingXS * 2 + height: 24 + visible: !SettingsData.notepadUseCompositorGap + value: SettingsData.notepadEdgeGap + minimum: 0 + maximum: 64 + unit: "px" + showValue: true + wheelEnabled: false + onSliderValueChanged: newValue => { + SettingsData.notepadEdgeGap = newValue; + } + } + } + } + + StyledText { + width: parent.width + text: SettingsData.notepadUseMonospace ? I18n.tr("Using global monospace font from Settings → Personalization") : I18n.tr("Global fonts can be configured in Settings → Personalization") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + wrapMode: Text.WordWrap + opacity: 0.8 + } + + StyledRect { + width: parent.width + implicitHeight: shortcutsHeader.height + (root.shortcutsExpanded ? shortcutsColumn.implicitHeight + Theme.spacingM : 0) + radius: Theme.cornerRadius + color: root.shortcutsExpanded ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : "transparent" + border.color: root.shortcutsExpanded ? Theme.primary : Theme.outlineMedium + border.width: root.shortcutsExpanded ? 2 : 1 + + StateLayer { + anchors.fill: parent + stateColor: Theme.primary + cornerRadius: parent.radius + onClicked: root.shortcutsExpanded = !root.shortcutsExpanded + } + + Row { + id: shortcutsHeader + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingS + height: 36 + spacing: Theme.spacingS + + DankIcon { + name: root.shortcutsExpanded ? "expand_less" : "expand_more" + size: Theme.iconSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Keyboard Shortcuts") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + Column { + id: shortcutsColumn + visible: root.shortcutsExpanded + width: parent.width - Theme.spacingL * 2 + anchors.top: shortcutsHeader.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: 2 + + StyledText { + width: parent.width + text: I18n.tr("Ctrl+S: Save • Ctrl+O: Open • Ctrl+N: New • Ctrl+F: Find") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + width: parent.width + text: I18n.tr("Ctrl+A: Select All • Ctrl+P: Preview • Enter/Shift+Enter: Find Next/Previous • Esc: Close") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + } } } } diff --git a/quickshell/Modules/Notepad/NotepadTextEditor.qml b/quickshell/Modules/Notepad/NotepadTextEditor.qml index 01602bc7..56a14806 100644 --- a/quickshell/Modules/Notepad/NotepadTextEditor.qml +++ b/quickshell/Modules/Notepad/NotepadTextEditor.qml @@ -32,6 +32,23 @@ Column { property string pluginHighlightedHtml: "" property string lastPluginContent: "" property int loadRequestId: 0 + property bool ignoreNextExternalChange: false + property bool watcherReloadPending: false + property bool externalWatchPaused: false + property bool inPopout: false + property bool surfaceVisible: true + // Tab ids are Date.now() timestamps (~1.78e12) which overflow a 32-bit `int`, + // corrupting the value (e.g. -946062153) and breaking buffer keying. `var` + // holds the full JS-safe integer. + property var loadedTabId: -1 + property bool applyingShared: false + property bool showPathInfo: false + + function currentFilePath() { + if (!currentTab) + return ""; + return currentTab.isTemporary ? (NotepadStorageService.baseDir + "/" + currentTab.filePath) : currentTab.filePath; + } signal saveRequested signal openRequested @@ -40,6 +57,10 @@ Column { signal escapePressed signal contentChanged signal settingsRequested + signal popoutRequested + signal dockRequested + signal conflictDetected(string diskContent) + signal autoSaveRequested function hasUnsavedChanges() { if (!currentTab || !contentLoaded) { @@ -52,6 +73,12 @@ Column { return textArea.text !== lastSavedContent; } + function commitLiveBuffer() { + if (loadedTabId < 0 || !contentLoaded) + return; + NotepadStorageService.setSessionBuffer(loadedTabId, textArea.text, lastSavedContent); + } + function loadCurrentTabContent() { if (!currentTab) return; @@ -62,8 +89,25 @@ Column { const activeTab = NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null; if (requestId !== loadRequestId || !activeTab || activeTab.id !== requestedTabId) return; + + const buffer = NotepadStorageService.getSessionBuffer(requestedTabId); + if (buffer !== undefined) { + applyingShared = true; + lastSavedContent = buffer.baseline; + textArea.text = buffer.content; + applyingShared = false; + loadedTabId = requestedTabId; + contentLoaded = true; + syncContentToPlugin(); + applyDiskContent(content); + return; + } + + applyingShared = true; lastSavedContent = content; textArea.text = content; + applyingShared = false; + loadedTabId = requestedTabId; contentLoaded = true; syncContentToPlugin(); }); @@ -72,14 +116,56 @@ Column { function saveCurrentTabContent() { if (!currentTab || !contentLoaded) return; + if (!currentTab.isTemporary) + return; NotepadStorageService.saveTabContent(NotepadStorageService.currentTabIndex, textArea.text); lastSavedContent = textArea.text; + NotepadStorageService.clearSessionBuffer(loadedTabId); } function autoSaveToSession() { + commitLiveBuffer(); if (!currentTab || !contentLoaded) return; - saveCurrentTabContent(); + if (currentTab.isTemporary) { + saveCurrentTabContent(); + } else if (SettingsData.notepadAutoSave) { + root.autoSaveRequested(); + } + } + + function syncFromDisk() { + if (!currentTab) + return; + loadCurrentTabContent(); + } + + function applyDiskContent(diskContent) { + if (diskContent === undefined || diskContent === null) + return; + if (diskContent === textArea.text) { + lastSavedContent = diskContent; + return; + } + if (diskContent === lastSavedContent) { + return; + } + if (textArea.text === lastSavedContent) { + reloadFromDisk(diskContent); + } else if (surfaceVisible) { + conflictDetected(diskContent); + } + } + + function reloadFromDisk(diskContent) { + applyingShared = true; + contentLoaded = false; + textArea.text = diskContent; + lastSavedContent = diskContent; + contentLoaded = true; + applyingShared = false; + NotepadStorageService.clearSessionBuffer(loadedTabId); + syncContentToPlugin(); } function setTextDocumentLineHeight() { @@ -202,7 +288,8 @@ Column { if (!currentTab) return; const filePath = currentTab?.filePath || ""; - const ext = filePath.split('.').pop().toLowerCase(); + const baseName = filePath.split('/').pop(); + const ext = baseName.includes('.') ? baseName.split('.').pop().toLowerCase() : ""; const content = textArea.text; if (content === lastPluginContent && SettingsData.getBuiltInPluginSetting("dankNotepadModule", "previewActive", false) === inlinePreviewVisible) { @@ -550,6 +637,7 @@ Column { Connections { target: NotepadStorageService function onCurrentTabIndexChanged() { + root.commitLiveBuffer(); loadCurrentTabContent(); Qt.callLater(() => { textArea.forceActiveFocus(); @@ -570,7 +658,9 @@ Column { } onTextChanged: { - if (contentLoaded && text !== lastSavedContent) { + // Debounced flush to the shared buffer (+ optional disk + // autosave) for every loaded tab, not just scratch notes. + if (contentLoaded && !applyingShared) { autoSaveTimer.restart(); } root.contentChanged(); @@ -744,6 +834,7 @@ Column { spacing: Theme.spacingS Item { + id: buttonBarItem width: parent.width height: 32 @@ -820,17 +911,98 @@ Column { } } - DankActionButton { + Row { + id: rightButtonRow anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - iconName: "more_horiz" - iconSize: Theme.iconSize - 2 - iconColor: Theme.surfaceText - onClicked: root.settingsRequested() + spacing: Theme.spacingS + + DankActionButton { + visible: !root.inPopout + iconName: "open_in_new" + iconSize: Theme.iconSize - 2 + iconColor: Theme.surfaceText + onClicked: root.popoutRequested() + } + + DankActionButton { + visible: root.inPopout + iconName: "dock_to_right" + iconSize: Theme.iconSize - 2 + iconColor: Theme.surfaceText + onClicked: root.dockRequested() + } + + DankActionButton { + iconName: "more_horiz" + iconSize: Theme.iconSize - 2 + iconColor: Theme.surfaceText + onClicked: root.settingsRequested() + } + } + + StyledRect { + id: pathInfoPopup + visible: root.showPathInfo + anchors.right: parent.right + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingS + width: Math.min(root.width, 360) + height: pathInfoRow.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + border.color: Theme.outlineMedium + border.width: 1 + z: 10 + + Row { + id: pathInfoRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: currentTab && currentTab.isTemporary ? "draft" : "description" + size: Theme.iconSize - 4 + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + width: pathInfoRow.width - (Theme.iconSize - 4) - copyPathButton.width - Theme.spacingS * 2 + text: root.currentFilePath() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + elide: Text.ElideMiddle + anchors.verticalCenter: parent.verticalCenter + } + + DankActionButton { + id: copyPathButton + iconName: "content_copy" + iconSize: Theme.iconSize - 6 + iconColor: Theme.surfaceTextMedium + anchors.verticalCenter: parent.verticalCenter + onClicked: { + const proc = clipboardCopyProcComp.createObject(root, { + content: root.currentFilePath(), + running: true + }); + proc.exited.connect(() => { + ToastService.showInfo(I18n.tr("Path copied to clipboard")); + proc.destroy(); + }); + } + } + } } } Row { + id: statusRow width: parent.width spacing: Theme.spacingL @@ -853,35 +1025,46 @@ Column { opacity: 1.0 } - StyledText { - text: { - if (autoSaveTimer.running) { - return I18n.tr("Auto-saving..."); - } + Row { + visible: textArea.text.length > 0 + spacing: Theme.spacingXS - if (hasUnsavedChanges()) { - if (currentTab && currentTab.isTemporary) { - return I18n.tr("Unsaved note..."); - } else { - return I18n.tr("Unsaved changes"); + StyledText { + anchors.verticalCenter: parent.verticalCenter + readonly property bool savingToDisk: autoSaveTimer.running && currentTab && (currentTab.isTemporary || SettingsData.notepadAutoSave) + text: { + if (savingToDisk) { + return I18n.tr("Saving..."); } - } else { - return I18n.tr("Saved"); - } - } - font.pixelSize: Theme.fontSizeSmall - color: { - if (autoSaveTimer.running) { - return Theme.primary; - } - if (hasUnsavedChanges()) { - return Theme.warning; - } else { - return Theme.success; + if (currentTab && currentTab.isTemporary) { + return I18n.tr("Auto saved"); + } + + return hasUnsavedChanges() ? I18n.tr("Unsaved changes") : I18n.tr("Saved"); + } + font.pixelSize: Theme.fontSizeSmall + color: { + if (savingToDisk) { + return Theme.primary; + } + + if (currentTab && currentTab.isTemporary) { + return Theme.success; + } + + return hasUnsavedChanges() ? Theme.warning : Theme.success; } } - opacity: textArea.text.length > 0 ? 1.0 : 0.0 + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "info" + iconSize: Theme.iconSizeSmall + iconColor: root.showPathInfo ? Theme.primary : Theme.surfaceTextMedium + buttonSize: 20 + onClicked: root.showPathInfo = !root.showPathInfo + } } } } @@ -902,6 +1085,38 @@ Column { onTriggered: syncContentToPlugin() } + FileView { + id: externalWatch + path: (!root.externalWatchPaused && currentTab && !currentTab.isTemporary && currentTab.filePath) ? currentTab.filePath : "" + blockLoading: true + preload: true + watchChanges: true + + onFileChanged: { + root.watcherReloadPending = true; + reload(); + } + + onLoaded: { + if (root.ignoreNextExternalChange) { + root.ignoreNextExternalChange = false; + root.lastSavedContent = externalWatch.text(); + root.watcherReloadPending = false; + return; + } + if (!root.watcherReloadPending) + return; + root.watcherReloadPending = false; + if (!root.contentLoaded || !root.currentTab || root.currentTab.isTemporary) + return; + if (!root.surfaceVisible) + return; + root.applyDiskContent(externalWatch.text()); + } + + onLoadFailed: error => {} + } + Connections { target: SettingsData function onBuiltInPluginSettingsChanged() { @@ -910,4 +1125,24 @@ Column { } } } + + Connections { + target: NotepadStorageService + function onSessionBufferRevisionChanged() { + if (applyingShared || !contentLoaded || loadedTabId < 0) + return; + if (textArea.activeFocus) + return; + var buffer = NotepadStorageService.getSessionBuffer(loadedTabId); + if (buffer === undefined || buffer.content === textArea.text) + return; + if (textArea.text === lastSavedContent) { + applyingShared = true; + lastSavedContent = buffer.baseline; + textArea.text = buffer.content; + applyingShared = false; + syncContentToPlugin(); + } + } + } } diff --git a/quickshell/Services/NotepadStorageService.qml b/quickshell/Services/NotepadStorageService.qml index fc14699e..ebbfb7ac 100644 --- a/quickshell/Services/NotepadStorageService.qml +++ b/quickshell/Services/NotepadStorageService.qml @@ -23,6 +23,49 @@ Singleton { property var tabsBeingCreated: ({}) property bool metadataLoaded: false + // Shared live edit state across slideout and popout surfaces. + property var sessionBuffers: ({}) + property int sessionBufferRevision: 0 + + function setSessionBuffer(tabId, content, baseline) { + if (tabId === undefined || tabId === null || tabId < 0) + return + var next = Object.assign({}, sessionBuffers) + if (content !== baseline) { + next[tabId] = { content: content, baseline: baseline } + } else { + delete next[tabId] + } + sessionBuffers = next + sessionBufferRevision++ + } + + function getSessionBuffer(tabId) { + return sessionBuffers[tabId] + } + + function clearSessionBuffer(tabId) { + if (sessionBuffers[tabId] === undefined) + return + var next = Object.assign({}, sessionBuffers) + delete next[tabId] + sessionBuffers = next + sessionBufferRevision++ + } + + property var conflictTabId: -1 + property string conflictDiskContent: "" + + function flagConflict(tabId, diskContent) { + conflictDiskContent = diskContent + conflictTabId = tabId + } + + function clearConflict() { + conflictTabId = -1 + conflictDiskContent = "" + } + Component.onCompleted: { ensureDirectories() } @@ -209,6 +252,10 @@ Singleton { if (tabIndex < 0 || tabIndex >= tabs.length) return var newTabs = tabs.slice() + var closedTabId = newTabs[tabIndex] ? newTabs[tabIndex].id : -1 + clearSessionBuffer(closedTabId) + if (conflictTabId === closedTabId) + clearConflict() if (newTabs.length <= 1) { var id = Date.now() diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index f3ee6e10..9cc9a048 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -789,21 +789,65 @@ Singleton { networkInfoModal?.close(); } - function openNotepad() { + function openNotepadSlideout() { if (notepadSlideouts.length > 0) { notepadSlideouts[0]?.show(); } } + function openNotepad() { + if (SettingsData.notepadDefaultMode === "popout") { + openNotepadPopout(); + return; + } + openNotepadSlideout(); + } + function closeNotepad() { + if (SettingsData.notepadDefaultMode === "popout") { + notepadPopout?.hide(); + return; + } if (notepadSlideouts.length > 0) { notepadSlideouts[0]?.hide(); } } function toggleNotepad() { + if (SettingsData.notepadDefaultMode === "popout") { + toggleNotepadPopout(); + return; + } if (notepadSlideouts.length > 0) { notepadSlideouts[0]?.toggle(); } } + + property var notepadPopout: null + property var notepadPopoutLoader: null + property bool _notepadPopoutWantsOpen: false + + function openNotepadPopout() { + if (notepadPopout) { + notepadPopout.show(); + } else if (notepadPopoutLoader) { + _notepadPopoutWantsOpen = true; + notepadPopoutLoader.active = true; + } + } + + function _onNotepadPopoutLoaded() { + if (_notepadPopoutWantsOpen && notepadPopout) { + _notepadPopoutWantsOpen = false; + notepadPopout.show(); + } + } + + function toggleNotepadPopout() { + if (notepadPopout) { + notepadPopout.toggle(); + } else { + openNotepadPopout(); + } + } } diff --git a/quickshell/Widgets/DankButtonGroup.qml b/quickshell/Widgets/DankButtonGroup.qml index 02e145ed..23bbfb23 100644 --- a/quickshell/Widgets/DankButtonGroup.qml +++ b/quickshell/Widgets/DankButtonGroup.qml @@ -13,11 +13,12 @@ Row { property var initialSelection: [] property var currentSelection: initialSelection property bool checkEnabled: true - property int buttonHeight: 40 - property int minButtonWidth: 64 - property int buttonPadding: Theme.spacingL - property int checkIconSize: Theme.iconSizeSmall - property int textSize: Theme.fontSizeMedium + property string size: "medium" + property int buttonHeight: size === "small" ? 32 : 40 + property int minButtonWidth: size === "small" ? 56 : 64 + property int buttonPadding: size === "small" ? Theme.spacingM : Theme.spacingL + property int checkIconSize: size === "small" ? Theme.iconSizeSmall - 2 : Theme.iconSizeSmall + property int textSize: size === "small" ? Theme.fontSizeSmall : Theme.fontSizeMedium property bool userInteracted: false signal selectionChanged(int index, bool selected) diff --git a/quickshell/Widgets/DankSlideout.qml b/quickshell/Widgets/DankSlideout.qml index 6d940f52..5f5a954d 100644 --- a/quickshell/Widgets/DankSlideout.qml +++ b/quickshell/Widgets/DankSlideout.qml @@ -20,17 +20,22 @@ PanelWindow { property bool expandable: false property bool expandedWidth: false property real expandedWidthValue: 960 + property real edgeGap: 0 + property string slideEdge: "right" + readonly property bool slideFromLeft: slideEdge === "left" property Component content: null property string title: "" property alias container: contentContainer property real customTransparency: -1 property bool mappedVisible: false signal aboutToHide + signal revealed function show() { mappedVisible = true; Qt.callLater(() => { isVisible = true; + revealed(); }); } @@ -52,9 +57,9 @@ PanelWindow { anchors.top: true anchors.bottom: true - anchors.right: true + anchors.right: !root.slideFromLeft + anchors.left: root.slideFromLeft - // Expandable: fixed max surface width; strip width is slideContainer only (keeps blur/mask aligned). implicitWidth: expandable ? expandedWidthValue : slideoutWidth implicitHeight: modelData ? modelData.height : 800 @@ -69,14 +74,15 @@ PanelWindow { readonly property real dpr: CompositorService.getScreenScale(root.screen) readonly property real alignedWidth: Theme.px(expandable && expandedWidth ? expandedWidthValue : slideoutWidth, dpr) readonly property real alignedHeight: Theme.px(modelData ? modelData.height : 800, dpr) + readonly property real alignedEdgeGap: Theme.px(edgeGap, dpr) readonly property real slideoutSlideSnapX: Theme.snap(slideContainer.slideOffset, dpr) mask: Region { item: Rectangle { - x: root.width - slideContainer.width - y: 0 + x: root.slideFromLeft ? root.alignedEdgeGap : (root.width - slideContainer.width - root.alignedEdgeGap) + y: root.alignedEdgeGap width: slideContainer.width - height: root.height + height: root.height - root.alignedEdgeGap * 2 } } @@ -84,16 +90,21 @@ PanelWindow { id: slideContainer anchors.top: parent.top anchors.bottom: parent.bottom - anchors.right: parent.right + anchors.right: root.slideFromLeft ? undefined : parent.right + anchors.left: root.slideFromLeft ? parent.left : undefined + anchors.topMargin: root.alignedEdgeGap + anchors.bottomMargin: root.alignedEdgeGap + anchors.rightMargin: root.alignedEdgeGap + anchors.leftMargin: root.alignedEdgeGap width: root.alignedWidth - height: root.alignedHeight + height: root.alignedHeight - root.alignedEdgeGap * 2 - property real slideOffset: root.alignedWidth + property real slideOffset: root.slideFromLeft ? -root.alignedWidth : root.alignedWidth Connections { target: root function onIsVisibleChanged() { - slideContainer.slideOffset = root.isVisible ? 0 : slideContainer.width; + slideContainer.slideOffset = root.isVisible ? 0 : (root.slideFromLeft ? -slideContainer.width : slideContainer.width); } } @@ -111,7 +122,6 @@ PanelWindow { } } - // Expandable only; mask/blur bind to slideContainer geometry so they track this animation. Behavior on width { enabled: root.expandable NumberAnimation { @@ -217,7 +227,6 @@ PanelWindow { } } - // Blur region from slideContainer (not layered contentRect); position uses x + slideoutSlideSnapX, not mapToItem(root). WindowBlur { targetWindow: root blurX: root.slideoutBlurActive ? slideContainer.x + root.slideoutSlideSnapX : 0