diff --git a/dots/.config/quickshell/ii/modules/common/Directories.qml b/dots/.config/quickshell/ii/modules/common/Directories.qml index 8e70506d3..56f647684 100644 --- a/dots/.config/quickshell/ii/modules/common/Directories.qml +++ b/dots/.config/quickshell/ii/modules/common/Directories.qml @@ -32,7 +32,9 @@ Singleton { property string shellConfig: FileUtils.trimFileProtocol(`${Directories.config}/illogical-impulse`) property string shellConfigName: "config.json" property string shellConfigPath: `${Directories.shellConfig}/${Directories.shellConfigName}` - property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`) + property string todoPath: FileUtils.trimFileProtocol(`${Directories.state}/user/todo.json`) + property string notesPath: FileUtils.trimFileProtocol(`${Directories.state}/user/notes.txt`) + property string conflictCachePath: FileUtils.trimFileProtocol(`${Directories.cache}/conflict-killer`) property string notificationsPath: FileUtils.trimFileProtocol(`${Directories.cache}/notifications/notifications.json`) property string generatedMaterialThemePath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/colors.json`) property string generatedWallpaperCategoryPath: FileUtils.trimFileProtocol(`${Directories.state}/user/generated/wallpaper/category.txt`) diff --git a/dots/.config/quickshell/ii/modules/common/Persistent.qml b/dots/.config/quickshell/ii/modules/common/Persistent.qml index 212fc34b9..478cadd10 100644 --- a/dots/.config/quickshell/ii/modules/common/Persistent.qml +++ b/dots/.config/quickshell/ii/modules/common/Persistent.qml @@ -131,6 +131,14 @@ Singleton { property real height: 600 property int tabIndex: 0 } + property JsonObject notes: JsonObject { + property bool pinned: false + property bool clickthrough: true + property real x: 1400 + property real y: 42 + property real width: 460 + property real height: 330 + } } property JsonObject timer: JsonObject { diff --git a/dots/.config/quickshell/ii/modules/common/widgets/StyledTextArea.qml b/dots/.config/quickshell/ii/modules/common/widgets/StyledTextArea.qml index b1f4579d2..13702e1f9 100644 --- a/dots/.config/quickshell/ii/modules/common/widgets/StyledTextArea.qml +++ b/dots/.config/quickshell/ii/modules/common/widgets/StyledTextArea.qml @@ -10,6 +10,7 @@ TextArea { selectedTextColor: Appearance.m3colors.m3onSecondaryContainer selectionColor: Appearance.colors.colSecondaryContainer placeholderTextColor: Appearance.m3colors.m3outline + color: Appearance.colors.colOnLayer0 font { family: Appearance.font.family.main pixelSize: Appearance?.font.pixelSize.small ?? 15 diff --git a/dots/.config/quickshell/ii/modules/ii/overlay/Overlay.qml b/dots/.config/quickshell/ii/modules/ii/overlay/Overlay.qml index 71ba510c6..65901ea13 100644 --- a/dots/.config/quickshell/ii/modules/ii/overlay/Overlay.qml +++ b/dots/.config/quickshell/ii/modules/ii/overlay/Overlay.qml @@ -25,7 +25,8 @@ Scope { exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "quickshell:overlay" WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: GlobalStates.overlayOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + // Use OnDemand for pinned widgets to allow focus switching with mouse clicks + WlrLayershell.keyboardFocus: GlobalStates.overlayOpen ? WlrKeyboardFocus.Exclusive : (OverlayContext.clickableWidgets.length > 0 ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None) visible: true color: "transparent" diff --git a/dots/.config/quickshell/ii/modules/ii/overlay/OverlayContext.qml b/dots/.config/quickshell/ii/modules/ii/overlay/OverlayContext.qml index 15e1ef91b..bb68cefc3 100644 --- a/dots/.config/quickshell/ii/modules/ii/overlay/OverlayContext.qml +++ b/dots/.config/quickshell/ii/modules/ii/overlay/OverlayContext.qml @@ -11,6 +11,7 @@ Singleton { { identifier: "floatingImage", materialSymbol: "imagesmode" }, { identifier: "recorder", materialSymbol: "screen_record" }, { identifier: "resources", materialSymbol: "browse_activity" }, + { identifier: "notes", materialSymbol: "note_stack" }, { identifier: "volumeMixer", materialSymbol: "volume_up" }, ] diff --git a/dots/.config/quickshell/ii/modules/ii/overlay/OverlayWidgetDelegateChooser.qml b/dots/.config/quickshell/ii/modules/ii/overlay/OverlayWidgetDelegateChooser.qml index 6e4bc6522..e004be7dd 100644 --- a/dots/.config/quickshell/ii/modules/ii/overlay/OverlayWidgetDelegateChooser.qml +++ b/dots/.config/quickshell/ii/modules/ii/overlay/OverlayWidgetDelegateChooser.qml @@ -8,10 +8,11 @@ import Quickshell import Quickshell.Bluetooth import qs.modules.ii.overlay.crosshair import qs.modules.ii.overlay.volumeMixer +import qs.modules.ii.overlay.floatingImage import qs.modules.ii.overlay.fpsLimiter import qs.modules.ii.overlay.recorder import qs.modules.ii.overlay.resources -import qs.modules.ii.overlay.floatingImage +import qs.modules.ii.overlay.notes DelegateChooser { id: root @@ -22,5 +23,6 @@ DelegateChooser { DelegateChoice { roleValue: "fpsLimiter"; FpsLimiter {} } DelegateChoice { roleValue: "recorder"; Recorder {} } DelegateChoice { roleValue: "resources"; Resources {} } + DelegateChoice { roleValue: "notes"; Notes {} } DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} } } diff --git a/dots/.config/quickshell/ii/modules/ii/overlay/notes/Notes.qml b/dots/.config/quickshell/ii/modules/ii/overlay/notes/Notes.qml new file mode 100644 index 000000000..64aa11d05 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/ii/overlay/notes/Notes.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.services +import qs.modules.common +import qs.modules.ii.overlay + +StyledOverlayWidget { + id: root + title: Translation.tr("Notes") + showCenterButton: true + + contentItem: NotesContent { + radius: root.contentRadius + isClickthrough: root.clickthrough + } +} diff --git a/dots/.config/quickshell/ii/modules/ii/overlay/notes/NotesContent.qml b/dots/.config/quickshell/ii/modules/ii/overlay/notes/NotesContent.qml new file mode 100644 index 000000000..dfcc4e052 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/ii/overlay/notes/NotesContent.qml @@ -0,0 +1,290 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.ii.overlay + +OverlayBackground { + id: root + + property alias content: textInput.text + property bool pendingReload: false + property var copyListEntries: [] + property string lastParsedCopylistText: "" + property var parsedCopylistLines: [] + property bool isClickthrough: false + property real maxCopyButtonSize: 20 + + Component.onCompleted: { + noteFile.reload(); + updateCopyListEntries(); + } + + function saveContent() { + if (!textInput) + return; + noteFile.setText(root.content); + } + + function focusAtEnd() { + if (!textInput) + return; + textInput.forceActiveFocus(); + const endPos = root.content.length; + applySelection(endPos, endPos); + } + + function applySelection(cursorPos, anchorPos) { + if (!textInput) + return; + const textLength = root.content.length; + const cursor = Math.max(0, Math.min(cursorPos, textLength)); + const anchor = Math.max(0, Math.min(anchorPos, textLength)); + textInput.select(anchor, cursor); + if (cursor === anchor) + textInput.deselect(); + } + + function scheduleCopylistUpdate(immediate = false) { + if (!textInput) + return; + if (immediate) { + copyListDebounce?.stop(); + updateCopyListEntries(); + } else { + copyListDebounce.restart(); + } + } + + function updateCopyListEntries() { + if (!textInput) + return; + const textValue = root.content; + if (!textValue || textValue.length === 0) { + lastParsedCopylistText = ""; + parsedCopylistLines = []; + root.copyListEntries = []; + return; + } + + if (textValue !== lastParsedCopylistText) { + const lineRegex = /(.*?)(\r?\n|$)/g; + let match = null; + const parsed = []; + while ((match = lineRegex.exec(textValue)) !== null) { + const lineText = match[1]; + const newlineText = match[2]; + const lineStart = match.index; + const lineEnd = lineStart + lineText.length; + const bulletMatch = lineText.match(/^\s*-\s+(.*\S)\s*$/); + if (bulletMatch) { + parsed.push({ + content: bulletMatch[1].trim(), + start: lineStart, + end: lineEnd + }); + } + if (newlineText === "") + break; + } + lastParsedCopylistText = textValue; + parsedCopylistLines = parsed; + if (parsed.length === 0) { + root.copyListEntries = []; + return; + } + } + + updateCopylistPositions(); + } + + function updateCopylistPositions() { + if (!textInput || parsedCopylistLines.length === 0) + return; + const rawSelectionStart = textInput.selectionStart; + const rawSelectionEnd = textInput.selectionEnd; + const selectionStart = rawSelectionStart === -1 ? textInput.cursorPosition : rawSelectionStart; + const selectionEnd = rawSelectionEnd === -1 ? textInput.cursorPosition : rawSelectionEnd; + const rangeStart = Math.min(selectionStart, selectionEnd); + const rangeEnd = Math.max(selectionStart, selectionEnd); + + const entries = parsedCopylistLines.map(line => { + // Don't show copy button if line is (partially) selected + const caretIntersects = rangeEnd > line.start && rangeStart <= line.end; + if (caretIntersects) + return null; + const startRect = textInput.positionToRectangle(line.start); + let endRect = textInput.positionToRectangle(line.end); + if (!isFinite(startRect.y)) + return null; + if (!isFinite(endRect.y)) + endRect = startRect; + const lineBottom = endRect.y + endRect.height; + const rectHeight = Math.max(lineBottom - startRect.y, textInput.font.pixelSize + 8); + return { + content: line.content, + y: startRect.y, + height: rectHeight + }; + }).filter(entry => entry !== null); + + root.copyListEntries = entries; + } + + ColumnLayout { + id: contentItem + anchors.fill: parent + spacing: -16 + + ScrollView { + id: editorScrollView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.vertical.policy: ScrollBar.AsNeeded + onWidthChanged: root.scheduleCopylistUpdate(true) + + StyledTextArea { // This has to be a direct child of ScrollView for proper scrolling + id: textInput + anchors { + left: parent.left + right: parent.right + } + wrapMode: TextEdit.Wrap + placeholderText: Translation.tr("Write something here...\nUse '-' to create copyable bullet points, like this:\n\nSheep fricker\n- 4x Slab\n- 1x Boat\n- 4x Redstone Dust\n- 1x Sticky Piston\n- 1x End Rod\n- 4x Redstone Repeater\n- 1x Redstone Torch\n- 1x Sheep") + selectByMouse: true + persistentSelection: true + textFormat: TextEdit.PlainText + background: null + padding: 16 + rightPadding: root.maxCopyButtonSize + padding + + onTextChanged: { + if (textInput.activeFocus) { + saveDebounce.restart(); + } + root.scheduleCopylistUpdate(true); + } + + onHeightChanged: root.scheduleCopylistUpdate(true) + onContentHeightChanged: root.scheduleCopylistUpdate(true) + onCursorPositionChanged: root.scheduleCopylistUpdate() + onSelectionStartChanged: root.scheduleCopylistUpdate() + onSelectionEndChanged: root.scheduleCopylistUpdate() + } + + Item { + anchors.fill: textInput + visible: root.copyListEntries.length > 0 + clip: true + + Repeater { + model: ScriptModel { + values: root.copyListEntries + } + delegate: RippleButton { + id: copyButton + required property var modelData + readonly property real lineHeight: Math.min(Math.max(modelData.height, Appearance.font.pixelSize.normal + 6), root.maxCopyButtonSize) + readonly property real iconSizeLocal: Appearance.font.pixelSize.normal + readonly property real hitPadding: 6 + property bool justCopied: false + + implicitHeight: lineHeight + implicitWidth: lineHeight + buttonRadius: height / 2 + y: modelData.y + anchors.right: parent.right + anchors.rightMargin: 16 + z: 5 + + Timer { + id: resetState + interval: 700 + onTriggered: { + copyButton.justCopied = false; + } + } + + onClicked: { + Quickshell.clipboardText = copyButton.modelData.content; + justCopied = true; + resetState.start(); + } + + contentItem: Item { + anchors.centerIn: parent + MaterialSymbol { + id: iconItem + anchors.centerIn: parent + text: copyButton.justCopied ? "check" : "content_copy" + iconSize: copyButton.iconSizeLocal + color: Appearance.colors.colOnLayer1 + } + } + } + } + } + } + + StyledText { + id: statusLabel + Layout.fillWidth: true + Layout.margins: 16 + horizontalAlignment: Text.AlignRight + text: saveDebounce.running ? Translation.tr("Saving...") : Translation.tr("Saved ") + color: Appearance.colors.colSubtext + } + } + + Timer { + id: saveDebounce + interval: 500 + repeat: false + onTriggered: saveContent() + } + + Timer { + id: copyListDebounce + interval: 100 + repeat: false + onTriggered: updateCopylistPositions() + } + + FileView { + id: noteFile + path: Qt.resolvedUrl(Directories.notesPath) + onLoaded: { + root.content = noteFile.text(); + if (root.content !== root.content) { + const previousCursor = textInput.cursorPosition; + const previousAnchor = textInput.selectionStart; + root.content = root.content; + applySelection(previousCursor, previousAnchor); + } + if (pendingReload) { + pendingReload = false; + Qt.callLater(root.focusAtEnd); + } + Qt.callLater(root.updateCopyListEntries); + } + onLoadFailed: error => { + if (error === FileViewError.FileNotFound) { + root.content = ""; + noteFile.setText(root.content); + if (pendingReload) { + pendingReload = false; + Qt.callLater(root.focusAtEnd); + } + Qt.callLater(root.updateCopyListEntries); + } else { + console.log("[Overlay Notes] Error loading file: " + error); + } + } + } +}