diff --git a/bookmarks/README.md b/bookmarks/README.md new file mode 100644 index 0000000..63c4b36 --- /dev/null +++ b/bookmarks/README.md @@ -0,0 +1,121 @@ +# Bookmarks + +A bookmarks/shortcuts plugin for Noctalia. You can add folders and commands to a list of bookmarks. +Reorder bookmarks, categorize them with folders, set glyphs and labels, and simply left click to +execute. All of it is user defined, stored in a JSON file you can backup. + +## Plugin + +| Field | Value | +| ------- | ---------------------------------- | +| ID | `dunarand/bookmarks` | +| Entries | Bar widget: `bar`; panel: `panel`; | + +Bar widget `bar` is not required. Instead, you can assign a keybind to directly open the `panel`, +which is shown in the Usage section. + +## Requirements + +- Noctalia v5.0.0 or higher +- `nohup` (Optional): For "Run in background" wrapper toggle + +## Usage + +You can add the widget to your bar or assign the following command to your compositor keybinds. For +example, in Hyprland v0.55+ + +``` +hl.bind( + "SUPER + SHIFT + B", + hl.dsp.exec_cmd("noctalia msg panel-toggle dunarand/bookmarks:panel") +) +``` + +You can interact with the bookmarks list via keybinds: + +| Keybind | Purpose | +| ------------------------ | ---------------------------------------------------------------- | +| `CTRL + N` or Down Arrow | Select the next (below) item | +| `CTRL + P` or Up Arrow | Select the previous (above) item | +| `CTRL + S` | Search bookmarks | +| `Enter` / `Return` | Execute the selected bookmark's command / Navigate into a folder | + +- Bookmarks and folders are listed in the main panel. + + ![](./assets/preview-1.png) + +- "?" button shows tooltips: bookmark command and its description. + + ![](./assets/preview-2.png) + +- You can create or edit bookmarks by assigning them a glyph, a label, a command, and an + optional description. + + ![](./assets/preview-3.png) + + - "Run in background" toggle wraps the command you defined in the following way: + + `nohup >/dev/null 2>&1 &` + + For example, instead of typing the whole command + + `nohup xdg-open "$HOME" >/dev/null 2>&1 &` + + each time, you can instead define the command as `xdg-open "$HOME"` and toggle "Run in + background" switch. + + - "Run in terminal" switch executes the command with the default terminal. These switches are + mutually exclusive so you can only choose one. Toggling on a switch will result the other to + turn off. + +- You can create folders and nest other bookmarks within folders. + + ![](./assets/preview-4.png) + + Folders cannot nest other folders. This is by design and it'll not change unless I find a + genuine usecase. You can edit folders by clicking on the "pen" icon next to its name. + +- You can press the "eye" icon to enter edit mode where you can edit, delete, and reorder bookmarks. + + ![](./assets/preview-5.png) + +- Each bookmark can be edited anytime. + + ![](./assets/preview-6.png) + +The saved bookmarks are written to `$NOCTALIA_STATE_HOME/plugins/data/dunarand/bookmarks/data.json`. +By default, `$NOCTALIA_STATE_HOME` should point to `~/.local/state/noctalia`. You can point to a +different location for saving and backing up your bookmarks. This setting is configurable via +**plugin settings** under Settings -> Plugins. Only JSON format is accepted. + +## Settings + +The bar widget has the following settings: + +| Setting | Type | Default | Description | +| --------- | -------- | ----------- | -------------------------------- | +| `glyph` | `glyph` | `bookmark` | Glyph displayed on the bar | +| `tooltip` | `string` | `Bookmarks` | Tooltip displayed on the bar | +| `text` | `string` | `Bookmarks` | Widget text displayed on the bar | + +The plugin itself has the following settings: + +| Setting | Type | Default | Description | +| ------------------ | ------ | ------- | ------------------------------------------------------------------------------------- | +| `data_path` | `file` | | data.json file to store the saved bookmarks. Leave empty to use the default location. | +| `show_info_button` | `bool` | `true` | Shows the "?" tooltip button on the bookmark entries. | + +## IPC + +1. Open the bookmarks panel: + + ```sh + noctalia msg panel-toggle dunarand/bookmarks:panel + ``` + +2. Open the bookmarks panel in search mode (immediately puts you into search mode) so that you can + use your bookmarks panel as a launcher: + + ```sh + noctalia msg panel-toggle dunarand/bookmarks:panel search + ``` diff --git a/bookmarks/assets/preview-1.png b/bookmarks/assets/preview-1.png new file mode 100644 index 0000000..637dda8 Binary files /dev/null and b/bookmarks/assets/preview-1.png differ diff --git a/bookmarks/assets/preview-2.png b/bookmarks/assets/preview-2.png new file mode 100644 index 0000000..8d71934 Binary files /dev/null and b/bookmarks/assets/preview-2.png differ diff --git a/bookmarks/assets/preview-3.png b/bookmarks/assets/preview-3.png new file mode 100644 index 0000000..88f5cb6 Binary files /dev/null and b/bookmarks/assets/preview-3.png differ diff --git a/bookmarks/assets/preview-4.png b/bookmarks/assets/preview-4.png new file mode 100644 index 0000000..f646068 Binary files /dev/null and b/bookmarks/assets/preview-4.png differ diff --git a/bookmarks/assets/preview-5.png b/bookmarks/assets/preview-5.png new file mode 100644 index 0000000..9436722 Binary files /dev/null and b/bookmarks/assets/preview-5.png differ diff --git a/bookmarks/assets/preview-6.png b/bookmarks/assets/preview-6.png new file mode 100644 index 0000000..b4647ef Binary files /dev/null and b/bookmarks/assets/preview-6.png differ diff --git a/bookmarks/bar.luau b/bookmarks/bar.luau new file mode 100644 index 0000000..4338271 --- /dev/null +++ b/bookmarks/bar.luau @@ -0,0 +1,30 @@ +local PANEL_ID = "dunarand/bookmarks:panel" + +local open = false + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph")) + if open then + barWidget.setGlyphColor("primary") + barWidget.setColor("primary") + else + barWidget.setGlyphColor("on_surface") + barWidget.setColor("on_surface") + end + + barWidget.setTooltip(noctalia.getConfig("tooltip")) + barWidget.setText(noctalia.getConfig("text")) +end + +noctalia.state.watch("bookmarks_open", function(value) + open = value == true + render() +end) + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end diff --git a/bookmarks/panel.luau b/bookmarks/panel.luau new file mode 100644 index 0000000..9fddaef --- /dev/null +++ b/bookmarks/panel.luau @@ -0,0 +1,1387 @@ +-- Prefer the user-configured data_path setting; fall back to the plugin's +-- own persistent data directory when unset. +local function resolveDataPath() + local configured = noctalia.getConfig("data_path") + if type(configured) == "string" and noctalia.string.trim(configured) ~= "" then + return noctalia.expandPath(noctalia.string.trim(configured)), nil + end + + local dataDir, dataDirErr = noctalia.pluginDataDir() + if dataDir == nil then + return nil, dataDirErr + end + return dataDir .. "/data.json", nil +end + +local path, pathErr = resolveDataPath() +if path == nil then + noctalia.log("bookmarks: could not resolve data path: " .. tostring(pathErr)) +end + +local view = "list" -- "list" | "new" +local formRev = 0 + +local bookmarks = {} +local listError = nil + +local currentFolder = nil -- nil = root, else index into `bookmarks` of the open folder +local editMode = false -- when true, rows show reorder/edit/delete controls + +local searchQuery = "" +local searchRev = 0 -- bumped to reseed the search input on open +local focusSearchOnRender = false -- one-shot: grabs the search input on the next render only + +local selectedIndex = nil -- 1-based position within the currently visible list, or nil + +local draftKind = "bookmark" -- "bookmark" | "folder", set when the form opens +local draftGlyph = "bookmark" +local draftLabel = "" +local draftCmd = "" +local draftDescription = "" +local draftRunInBackground = false +local draftRunInTerminal = false +local draftError = nil +local editingIndex = nil -- nil = creating new, number = editing the entry at that index in the active list +local editingRoot = false -- true when editingIndex refers to `bookmarks` (root) instead of activeList() + +-- Coerces native booleans, string coercions, and UI event objects into a +-- strict boolean, as required by ui.toggle's `checked` prop. +local function parseBool(val) + if type(val) == "table" then + return val.checked == true or val.value == true + end + return val == true or val == "true" or val == 1 +end + +-- Returns the array we're currently viewing/editing: root, or the open +-- folder's items. Falls back to root if currentFolder points at something +-- that's gone (e.g. deleted from under us). +local function activeList() + if currentFolder == nil then + return bookmarks + end + local folder = bookmarks[currentFolder] + if folder == nil or folder.type ~= "folder" then + currentFolder = nil + return bookmarks + end + folder.items = folder.items or {} + return folder.items +end + +local function entryType(entry) + return entry.type == "folder" and "folder" or "bookmark" +end + +-- Fuzzy-searches bookmark labels only (not commands, not folder names) +-- across root and every folder's contents, using Noctalia's native +-- matcher. Returns results sorted best-match-first, each as +-- { entry, path }, where path is the containing folder's label (nil for +-- root entries). Search results are only ever run, never edited in place, +-- so no index back into `bookmarks` is kept. +local function searchBookmarks(query) + local trimmed = noctalia.string.trim(query) + if trimmed == "" then + return {} + end + + local results = {} + for _, entry in ipairs(bookmarks) do + if entryType(entry) == "folder" then + for _, subEntry in ipairs(entry.items or {}) do + if entryType(subEntry) ~= "folder" then + local score = noctalia.fuzzyScore(trimmed, subEntry.label or "") + if score ~= nil then + table.insert( + results, + { entry = subEntry, path = entry.label or "", score = score } + ) + end + end + end + else + local score = noctalia.fuzzyScore(trimmed, entry.label or "") + if score ~= nil then + table.insert(results, { entry = entry, path = nil, score = score }) + end + end + end + + table.sort(results, function(a, b) + return a.score > b.score + end) + return results +end + +local function loadBookmarks() + if path == nil then + bookmarks = {} + return + end + if not noctalia.fileExists(path) then + bookmarks = {} + listError = nil + return + end + local raw, readErr = noctalia.readFile(path) + if raw == nil then + bookmarks = {} + listError = noctalia.tr("err_read_failed", { error = tostring(readErr) }) + return + end + local decoded, decodeErr = noctalia.json.decode(raw) + if type(decoded) ~= "table" then + bookmarks = {} + listError = noctalia.tr("err_corrupt", { error = tostring(decodeErr) }) + return + end + bookmarks = decoded + listError = nil +end + +local function saveBookmarks() + if path == nil then + return false, noctalia.tr("err_no_data_dir") + end + local encoded, encodeErr = noctalia.json.encode(bookmarks) + if encoded == nil then + return false, tostring(encodeErr) + end + local ok, writeErr = noctalia.writeFile(path, encoded) + if not ok then + return false, tostring(writeErr) + end + return true, nil +end + +local function snapshotArray(list) + local copy = {} + for i, v in ipairs(list) do + copy[i] = v + end + return copy +end + +-- Replaces `list`'s contents in place with `snapshot`'s, used to roll back +-- a move when saveBookmarks() fails partway through. +local function restoreArray(list, snapshot) + for i = #list, 1, -1 do + list[i] = nil + end + for i, v in ipairs(snapshot) do + list[i] = v + end +end + +-- Moves the entry currently at `fromIndex` so it sits immediately before +-- `beforeIndex` (both 1-based, referring to positions in `list` before the +-- move). `beforeIndex == #list + 1` means "move to the end". No-ops for a +-- drop that wouldn't change order, and rolls back on write failure. +local function moveEntryTo(list, fromIndex, beforeIndex) + if fromIndex < 1 or fromIndex > #list then + return + end + -- Dropping onto the gap immediately above or below your own row is a + -- no-op, not a "move to the other side of yourself". + if beforeIndex == fromIndex or beforeIndex == fromIndex + 1 then + return + end + + local snapshot = snapshotArray(list) + + local moved = table.remove(list, fromIndex) + local insertAt = beforeIndex + if beforeIndex > fromIndex then + insertAt = beforeIndex - 1 + end + table.insert(list, insertAt, moved) + + local ok, err = saveBookmarks() + if not ok then + restoreArray(list, snapshot) + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("err_reorder_failed", { error = err }) + ) + end +end + +-- Moves a single bookmark (never a folder - nesting stays one level deep) +-- from `fromList` at `fromIndex` onto the end of `toList`. Used for both +-- directions of filing: root -> into a folder, and folder -> back to root. +-- Rolls back both lists on write failure, same pattern as moveEntryTo. +local function moveEntryAcross(fromList, fromIndex, toList) + local entry = fromList[fromIndex] + if entry == nil or entryType(entry) ~= "bookmark" then + return false + end + + local fromSnapshot = snapshotArray(fromList) + local toSnapshot = snapshotArray(toList) + + table.remove(fromList, fromIndex) + table.insert(toList, entry) + + local ok, err = saveBookmarks() + if not ok then + restoreArray(fromList, fromSnapshot) + restoreArray(toList, toSnapshot) + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("err_reorder_failed", { error = err }) + ) + return false + end + return true, entry +end + +-- Runs a bookmark's command. Two distinct failure paths get a notification: +-- the shell couldn't even be spawned, or it ran and exited non-zero. +-- Success stays silent, same as file-search's xdg-open. +local function runBookmark(entry) + local cmd = entry.cmd + if type(cmd) ~= "string" or cmd == "" then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("err_no_command")) + return + end + local label = entry.label or cmd + + if parseBool(entry.runInTerminal) then + noctalia.runInTerminal(cmd) + return + end + + local finalCmd = cmd + if parseBool(entry.runInBackground) then + finalCmd = "nohup " .. cmd .. " >/dev/null 2>&1 &" + end + + local ok = noctalia.runAsync(finalCmd, function(result) + if parseBool(entry.runInBackground) then + return + end + + if result.exitCode ~= 0 then + local detail = noctalia.string.trim(result.stderr or "") + local msg = + noctalia.tr("err_exit_code", { label = label, code = tostring(result.exitCode) }) + if detail ~= "" then + msg = msg .. ": " .. detail + end + noctalia.notifyError(noctalia.tr("title"), msg) + end + -- exitCode == 0: succeeded, no notification + end) + + if not ok then + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("err_launch_failed", { label = label }) + ) + end +end + +-- Runs a bookmark and closes the panel, the standard "user picked a +-- bookmark" action shared by every activation path (rows, search results, +-- keyboard Enter). +local function runAndClose(entry) + runBookmark(entry) + onClose() + panel.close() +end + +-- Thin insertion-point drop zone rendered between rows (and before the +-- first / after the last). `place` is "before"/"after" relative to +-- `anchorIndex`, encoded into `value` for _onReorderEntry to decode. +local function insertionGap(anchorIndex, place) + return ui.dropZone({ + key = "gap-" .. place .. "-" .. anchorIndex, + accepts = { "bookmark-entry" }, + value = place .. "|" .. anchorIndex, + onDrop = "_onReorderEntry", + height = 6, + expandOnDrag = true, + hitSlop = 20, + }) +end + +-- Wraps a folder row so the whole row (root view only) doubles as a drop +-- target: dropping a bookmark onto it files that bookmark into the folder, +-- appended at the end. `value` is "into|", read by +-- _onReorderEntry alongside the "before|after" insertion-gap values already +-- in use for same-list reordering. `hitSlop` matters here: the insertion +-- gaps immediately above/below this row extend their own hit area well +-- past their thin visible strip (see insertionGap), so without a +-- competing hitSlop of its own this zone would rarely win a drop even +-- when the pointer is squarely over the folder row - "closest wins" needs +-- both zones actually in the running. +local function folderDropTarget(folderIndex, child) + return ui.dropZone({ + key = "into-" .. folderIndex, + accepts = { "bookmark-entry" }, + value = "into|" .. folderIndex, + onDrop = "_onReorderEntry", + direction = "column", + radius = 8, + border = "primary/0.45", + borderWidth = 1.5, + expandOnDrag = true, + hitSlop = 20, + }, { child }) +end + +-- Persistent drop bar shown at the top of a folder's contents while in edit +-- mode: dropping a bookmark here pulls it back out to the root list, +-- appended at the end. Always present (not just an insertion gap) so an +-- otherwise-empty folder still offers a way to release an item back out. +local function moveToRootDropBar() + return ui.dropZone({ + key = "move-to-root", + accepts = { "bookmark-entry" }, + value = "out|root", + onDrop = "_onReorderEntry", + direction = "row", + align = "center", + gap = 8, + height = 34, + radius = 8, + fill = "primary/0.08", + border = "primary/0.35", + borderWidth = 1.5, + expandOnDrag = true, + hitSlop = 12, + }, { + ui.glyph({ name = "corner-left-up", size = 14, color = "primary" }), + ui.label({ + text = noctalia.tr("drop_out_of_folder"), + fontSize = 12, + color = "primary", + flexGrow = 1, + }), + }) +end + +-- Returns the ordered list of rows currently visible for keyboard +-- navigation, in the same order they're rendered. Each entry is +-- { kind = "search", result } or { kind = "entry", entry, index }, so +-- Enter can dispatch the right action regardless of view. +local function visibleRows() + local trimmed = noctalia.string.trim(searchQuery) + if currentFolder == nil and trimmed ~= "" then + local rows = {} + for _, result in ipairs(searchBookmarks(searchQuery)) do + table.insert(rows, { kind = "search", result = result }) + end + return rows + end + + local list = activeList() + local rows = {} + for index, entry in ipairs(list) do + table.insert(rows, { kind = "entry", entry = entry, index = index }) + end + return rows +end + +-- Runs whatever the given visible-row entry represents: opens a folder, +-- or runs and closes on a bookmark (search result or normal row alike). +local function activateRow(row) + if row == nil then + return + end + if row.kind == "search" then + runAndClose(row.result.entry) + return + end + if entryType(row.entry) == "folder" then + _onOpenFolder(row.index) + else + runAndClose(row.entry) + end +end + +local function entryRow(entry, index) + local isFolder = entryType(entry) == "folder" + + local trailing = {} + if isFolder then + table.insert( + trailing, + ui.button({ + glyph = "chevron-right", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("open_tooltip"), + onClick = function() + _onOpenFolder(index) + end, + }) + ) + elseif not editMode and parseBool(noctalia.getConfig("show_info_button")) then + local description = noctalia.string.trim(entry.description or "") + local cmd = entry.cmd or "" + local infoTooltip = noctalia.tr("info_cmd_line", { cmd = cmd }) + if description ~= "" then + infoTooltip = infoTooltip + .. "\n" + .. noctalia.tr("info_description_line", { description = description }) + end + table.insert( + trailing, + ui.button({ + glyph = "help-circle", + variant = "ghost", + controlSize = "sm", + tooltip = infoTooltip, + onClick = function() end, + }) + ) + end + if not isFolder and editMode then + trailing = { + ui.button({ + glyph = "pencil", + variant = "secondary", + controlSize = "sm", + tooltip = noctalia.tr("edit_tooltip"), + onClick = function() + _onEditEntry(index) + end, + }), + ui.button({ + glyph = "trash", + variant = "destructive", + controlSize = "sm", + tooltip = noctalia.tr("delete_tooltip"), + onClick = function() + _onDeleteEntry(index) + end, + }), + } + end + + local isSelected = selectedIndex == index + local activateEntry = function() + if isFolder then + _onOpenFolder(index) + else + runAndClose(entry) + end + end + + -- In edit mode a folder's glyph sits in a filled chip rather than bare, + -- the same visual language as the move-to-root drop bar (filled + -- primary/border), so "this accepts a dropped bookmark" reads at a + -- glance instead of only revealing itself once a drag is already + -- underway and the host's own highlight kicks in. + -- Note: ui.box does not render child content (its documented props are + -- fill/radius/border/size only), so the chip wrapper uses ui.row - + -- which supports both children and the same fill/radius styling - with + -- align/justify = "center" to center the glyph inside it. + local folderGlyph = ui.glyph({ + name = entry.glyph or (isFolder and "folder" or "bookmark"), + size = 16, + color = (isFolder and editMode) and "primary" or "on_surface", + }) + if isFolder and editMode then + folderGlyph = ui.row({ + fill = "primary/0.12", + radius = 6, + width = 24, + height = 24, + align = "center", + justify = "center", + }, { folderGlyph }) + end + + -- Flat, single-level row: grip, glyph, label, and trailing controls are + -- all direct children of "bm-", the same node that sits as a + -- sibling of the insertion gaps in the `rows` array built by listView. + -- This matters for previewAncestor/liftFromLayout below - they walk up + -- from the dragSource by ancestor count, so the drag source needs to be + -- exactly one level under the node that's actually a gap's neighbor in + -- the list. An earlier version wrapped glyph+label in their own nested + -- sub-row; liftFromLayout then only collapsed that inner wrapper while + -- the outer list-item row (the real gap sibling) stayed in layout as a + -- leftover sliver next to the real gap - reading as two highlightable + -- spaces where the dragged row used to be instead of one. + local rowChildren = {} + if editMode then + table.insert( + rowChildren, + ui.dragSource({ + key = "grip-" .. index, + dragType = "bookmark-entry", + payload = tostring(index), + previewAncestor = 1, + liftFromLayout = true, + tooltip = noctalia.tr("drag_tooltip"), + width = 16, + height = 16, + }, { + ui.glyph({ name = "grip-vertical", size = 14, color = "on_surface_variant" }), + }) + ) + end + table.insert(rowChildren, folderGlyph) + table.insert( + rowChildren, + ui.label({ + text = entry.label or "", + fontSize = 13, + color = isSelected and "primary" or "on_surface", + fontWeight = isSelected and "bold" or "regular", + flexGrow = 1, + maxLines = 1, + }) + ) + for _, node in ipairs(trailing) do + table.insert(rowChildren, node) + end + + local row = ui.row({ + key = "bm-" .. index, + gap = 8, + align = "center", + height = 30, + onClick = activateEntry, + onHover = function(state) + if state == "true" then + selectedIndex = index + render() + end + end, + }, rowChildren) + + -- Only root-level folders accept drops (nesting stays one level deep, + -- and folders never contain other folders), and only while edit mode + -- exposes drag handles at all. + if isFolder and editMode and currentFolder == nil then + return folderDropTarget(index, row) + end + return row +end + +-- One row in the flat search results list: runs the bookmark on click, +-- same as a normal row, and shows its containing folder as a small path +-- label when the match came from inside one. +local function searchResultRow(result, rowIndex) + local entry = result.entry + local isSelected = selectedIndex == rowIndex + local runEntry = function() + runAndClose(entry) + end + + local children = { + ui.button({ + glyph = entry.glyph or "bookmark", + variant = "ghost", + glyphSize = 16, + width = 24, + height = 24, + tooltip = entry.cmd, + onClick = runEntry, + }), + ui.label({ + text = entry.label or "", + fontSize = 13, + color = isSelected and "primary" or "on_surface", + fontWeight = isSelected and "bold" or "regular", + flexGrow = 1, + maxLines = 1, + }), + } + if result.path ~= nil then + table.insert( + children, + ui.label({ + text = result.path, + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }) + ) + end + + return ui.row({ + key = "search-" .. rowIndex, + gap = 8, + align = "center", + height = 30, + onClick = runEntry, + onHover = function(state) + if state == "true" then + selectedIndex = rowIndex + render() + end + end, + }, children) +end + +local function listView() + local list = activeList() + local children = {} + + local searching = currentFolder == nil and noctalia.string.trim(searchQuery) ~= "" + + if currentFolder ~= nil then + local folder = bookmarks[currentFolder] + table.insert( + children, + ui.row({ gap = 8, align = "center" }, { + ui.button({ + glyph = "chevron-left", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("back_button"), + onClick = "_onBackToRoot", + }), + ui.row({ + gap = 6, + align = "center", + flexGrow = 1, + onClick = "_onEditFolderHeader", + }, { + ui.glyph({ + name = "pencil", + size = 13, + color = "on_surface_variant", + }), + ui.label({ + text = (folder and folder.label) or "", + fontSize = 13, + fontWeight = "bold", + color = "on_surface", + flexGrow = 1, + maxLines = 1, + }), + }), + }) + ) + end + + if currentFolder == nil then + local wantsFocus = focusSearchOnRender + focusSearchOnRender = false + table.insert( + children, + ui.input({ + key = "search-input-" .. searchRev, + value = searchQuery, + placeholder = noctalia.tr("search_placeholder"), + controlSize = "sm", + focus = wantsFocus, + onChange = "_onSearchChanged", + onSubmit = "_onSearchSubmit", + }) + ) + end + + if searching then + local results = searchBookmarks(searchQuery) + if #results == 0 then + table.insert( + children, + ui.label({ + text = noctalia.tr("no_search_results"), + color = "on_surface_variant", + fontSize = 12, + }) + ) + else + local rows = {} + for rowIndex, result in ipairs(results) do + table.insert(rows, searchResultRow(result, rowIndex)) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows)) + end + return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) + end + + local topButtons = { + ui.button({ + key = "new-bookmark", + text = noctalia.tr("new_bookmark"), + glyph = "plus", + variant = "primary", + flexGrow = 1, + enabled = path ~= nil, + onClick = "_onNewBookmark", + }), + } + -- Folders can only be created at root: one level of nesting only. The + -- button is omitted entirely inside a folder, rather than just disabled, + -- since nesting isn't a supported concept here at all. + if currentFolder == nil then + table.insert( + topButtons, + ui.button({ + key = "new-folder", + text = noctalia.tr("new_folder"), + glyph = "folder-plus", + variant = "secondary", + flexGrow = 1, + enabled = path ~= nil, + onClick = "_onNewFolder", + }) + ) + end + + table.insert( + topButtons, + ui.button({ + key = "toggle-edit-mode", + glyph = editMode and "eye" or "eye-off", + variant = "ghost", + controlSize = "sm", + width = 32, + tooltip = noctalia.tr(editMode and "hide_controls_tooltip" or "show_controls_tooltip"), + onClick = "_onToggleEditMode", + }) + ) + + table.insert(children, ui.row({ gap = 8 }, topButtons)) + + -- Inside a folder, edit mode always offers a way to drop a bookmark back + -- out to root - including when the folder is currently empty, so it + -- stays a valid drag target rather than disappearing along with the + -- "no bookmarks" message. + if currentFolder ~= nil and editMode then + table.insert(children, moveToRootDropBar()) + end + + if listError ~= nil then + table.insert(children, ui.label({ text = listError, color = "error", fontSize = 12 })) + elseif #list == 0 then + table.insert( + children, + ui.label({ + text = noctalia.tr("no_bookmarks"), + color = "on_surface_variant", + fontSize = 12, + }) + ) + else + local rows = {} + local count = #list + for index, entry in ipairs(list) do + if editMode then + table.insert(rows, insertionGap(index, "before")) + end + table.insert(rows, entryRow(entry, index)) + end + if editMode and count > 0 then + table.insert(rows, insertionGap(count, "after")) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows)) + end + + if currentFolder ~= nil then + table.insert( + children, + ui.button({ + key = "delete-folder", + text = noctalia.tr("delete_folder_button"), + glyph = "trash", + variant = "destructive", + onClick = "_onDeleteFolder", + }) + ) + end + + return ui.column({ gap = 10, padding = 14, flexGrow = 1 }, children) +end + +local function formView() + local isFolder = draftKind == "folder" + local title + if editingIndex ~= nil then + title = isFolder and noctalia.tr("edit_folder") or noctalia.tr("edit_bookmark") + else + title = isFolder and noctalia.tr("new_folder") or noctalia.tr("new_bookmark") + end + + local children = { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ + text = title, + fontSize = 15, + fontWeight = "bold", + color = "on_surface", + flexGrow = 1, + }), + ui.button({ + glyph = "close", + variant = "ghost", + tooltip = noctalia.tr("cancel_button"), + onClick = "_onCancelBookmark", + }), + }), + + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ + name = draftGlyph ~= "" and draftGlyph or (isFolder and "folder" or "bookmark"), + size = 18, + color = "primary", + }), + ui.input({ + key = "glyph-input-" .. formRev, + value = draftGlyph, + placeholder = noctalia.tr("glyph_field"), + controlSize = "sm", + flexGrow = 1, + onChange = "_onGlyphChange", + }), + }), + + ui.input({ + key = "label-input-" .. formRev, + value = draftLabel, + placeholder = isFolder and noctalia.tr("folder_name_field") + or noctalia.tr("label_field"), + controlSize = "sm", + focus = true, + onChange = "_onLabelChange", + }), + } + + if not isFolder then + table.insert( + children, + ui.input({ + key = "cmd-input-" .. formRev, + value = draftCmd, + placeholder = noctalia.tr("cmd_field"), + controlSize = "sm", + onChange = "_onCmdChange", + }) + ) + + table.insert( + children, + ui.input({ + key = "description-input-" .. formRev, + value = draftDescription, + placeholder = noctalia.tr("description_field"), + controlSize = "sm", + onChange = "_onDescriptionChange", + }) + ) + + table.insert( + children, + ui.row({ gap = 8, align = "center" }, { + ui.toggle({ + checked = draftRunInBackground, + enabled = true, + onChange = "_onRunInBackgroundChange", + }), + ui.label({ + text = noctalia.tr("run_in_background_field"), + fontSize = 13, + color = "on_surface", + }), + }) + ) + + table.insert( + children, + ui.row({ gap = 8, align = "center" }, { + ui.toggle({ + checked = draftRunInTerminal, + enabled = true, + onChange = "_onRunInTerminalChange", + }), + ui.label({ + text = noctalia.tr("run_in_terminal_field"), + fontSize = 13, + color = "on_surface", + }), + }) + ) + end + + if draftError ~= nil then + table.insert(children, ui.label({ text = draftError, color = "error", fontSize = 12 })) + end + + table.insert( + children, + ui.row({ gap = 8, justify = "end" }, { + ui.button({ + text = noctalia.tr("save_button"), + variant = "primary", + onClick = "_onSaveBookmark", + }), + ui.button({ + text = noctalia.tr("cancel_button"), + variant = "destructive", + onClick = "_onCancelBookmark", + }), + }) + ) + + return ui.column({ gap = 10, padding = 14 }, children) +end + +function render() + if view == "new" then + panel.render(formView()) + else + panel.render(listView()) + end +end + +function _onNewBookmark() + editingIndex = nil + editingRoot = false + draftKind = "bookmark" + draftGlyph = "bookmark" + draftLabel = "" + draftCmd = "" + draftDescription = "" + draftRunInBackground = false + draftRunInTerminal = false + draftError = nil + formRev += 1 + view = "new" + render() +end + +function _onNewFolder() + -- Folders are root-only (one level of nesting), so this should be + -- unreachable while inside a folder, but guard anyway since it's + -- reached via a button click, not just render-time gating. + if currentFolder ~= nil then + return + end + editingIndex = nil + editingRoot = false + draftKind = "folder" + draftGlyph = "folder" + draftLabel = "" + draftCmd = "" + draftDescription = "" + draftRunInBackground = false + draftRunInTerminal = false + draftError = nil + formRev += 1 + view = "new" + render() +end + +function _onOpenFolder(index) + local list = activeList() + local entry = list[index] + if entry == nil or entryType(entry) ~= "folder" then + return + end + -- currentFolder is only ever set from root (folders are one level deep), + -- so `index` here is always an index into the root `bookmarks` array. + currentFolder = index + selectedIndex = 1 + render() +end + +function _onBackToRoot() + currentFolder = nil + selectedIndex = 1 + render() +end + +-- Editing the currently open folder's own name/glyph: the folder entry +-- lives in root `bookmarks` at `currentFolder`, not in activeList() (which +-- is the folder's *contents*), so this goes through root explicitly. +function _onEditFolderHeader() + if currentFolder == nil then + return + end + local entry = bookmarks[currentFolder] + if entry == nil then + return + end + editingIndex = currentFolder + editingRoot = true + draftKind = "folder" + draftGlyph = entry.glyph or "folder" + draftLabel = entry.label or "" + draftCmd = "" + draftDescription = "" + draftRunInBackground = false + draftRunInTerminal = false + draftError = nil + formRev += 1 + view = "new" + render() +end + +function _onDeleteFolder() + if currentFolder == nil then + return + end + local entry = bookmarks[currentFolder] + if entry == nil or entryType(entry) ~= "folder" then + return + end + + local removed = table.remove(bookmarks, currentFolder) + if removed == nil then + return + end + local ok, err = saveBookmarks() + if not ok then + table.insert(bookmarks, currentFolder, removed) + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("err_delete_failed", { error = err }) + ) + render() + return + end + + noctalia.notify( + noctalia.tr("title"), + noctalia.tr("folder_deleted", { label = removed.label or "" }) + ) + currentFolder = nil + render() +end + +function _onEditEntry(index) + local list = activeList() + editingRoot = false + local entry = list[index] + if entry == nil then + return + end + editingIndex = index + draftKind = entryType(entry) + draftGlyph = entry.glyph or (draftKind == "folder" and "folder" or "bookmark") + draftLabel = entry.label or "" + draftCmd = entry.cmd or "" + draftDescription = entry.description or "" + + -- Strictly enforce the native boolean type right from JSON initialization + draftRunInBackground = parseBool(entry.runInBackground) + draftRunInTerminal = parseBool(entry.runInTerminal) + + draftError = nil + formRev += 1 + view = "new" + render() +end + +function _onCancelBookmark() + editingIndex = nil + editingRoot = false + view = "list" + render() +end + +function _onGlyphChange(value) + draftGlyph = value + render() +end + +function _onLabelChange(value) + draftLabel = value +end + +function _onCmdChange(value) + draftCmd = value +end + +function _onDescriptionChange(value) + draftDescription = value +end + +function _onRunInBackgroundChange(value) + -- Discard any UI objects or strings gracefully back into native booleans + draftRunInBackground = parseBool(value) + -- Mutually exclusive with "run in terminal": enabling one turns the + -- other off immediately, rather than letting both sit checked until + -- the form is reopened. + if draftRunInBackground then + draftRunInTerminal = false + end + render() +end + +function _onRunInTerminalChange(value) + -- Discard any UI objects or strings gracefully back into native booleans + draftRunInTerminal = parseBool(value) + if draftRunInTerminal then + draftRunInBackground = false + end + render() +end + +function _onSaveBookmark() + local label = noctalia.string.trim(draftLabel) + local glyph = noctalia.string.trim(draftGlyph) + local isFolder = draftKind == "folder" + + if label == "" then + draftError = noctalia.tr(isFolder and "err_label_required" or "err_label_cmd_required") + render() + return + end + + local cmd = nil + if not isFolder then + cmd = noctalia.string.trim(draftCmd) + if cmd == "" then + draftError = noctalia.tr("err_label_cmd_required") + render() + return + end + end + + if glyph == "" then + glyph = isFolder and "folder" or "bookmark" + end + + local list = editingRoot and bookmarks or activeList() + local newEntry + if isFolder then + local previousItems = nil + if editingIndex ~= nil then + local existing = list[editingIndex] + previousItems = existing and existing.items or nil + end + newEntry = { type = "folder", glyph = glyph, label = label, items = previousItems or {} } + else + newEntry = { + type = "bookmark", + glyph = glyph, + label = label, + cmd = cmd, + description = noctalia.string.trim(draftDescription), + runInBackground = draftRunInBackground, + runInTerminal = draftRunInTerminal, + } + end + + local previous = nil + if editingIndex ~= nil then + previous = list[editingIndex] + list[editingIndex] = newEntry + else + table.insert(list, newEntry) + end + + local ok, err = saveBookmarks() + if not ok then + -- roll back the in-memory change on write failure + if editingIndex ~= nil then + list[editingIndex] = previous + else + table.remove(list) + end + draftError = noctalia.tr("err_save_failed", { error = err }) + render() + return + end + + noctalia.notify( + noctalia.tr("title"), + noctalia.tr(editingIndex ~= nil and "entry_updated" or "entry_saved", { label = label }) + ) + editingIndex = nil + editingRoot = false + view = "list" + render() +end + +function _onToggleEditMode() + editMode = not editMode + render() +end + +function _onSearchChanged(value) + searchQuery = value + selectedIndex = 1 + render() +end + +function _onSearchSubmit() + local rows = visibleRows() + activateRow(rows[selectedIndex] or rows[1]) +end + +-- Drop callback for the bookmark list's drag-and-drop reordering. +-- `payload` is the dragged row's original index (string, from +-- ui.dragSource); `target` is "before|N" or "after|N" from the drop +-- zone's `value`, N being the anchor row's index at render time. +function _onReorderEntry(payload, target) + local fromIndex = tonumber(payload) + if fromIndex == nil then + return + end + local place, rest = target:match("^([^|]+)|(.+)$") + if place == nil then + return + end + + -- "into|": drop a bookmark from the root list onto a + -- folder row to file it inside, appended at the end of that folder's + -- items. Root-only (folders are only ever dragged/dropped at root). + if place == "into" then + if currentFolder ~= nil then + return + end + local folderIndex = tonumber(rest) + if folderIndex == nil then + return + end + local folder = bookmarks[folderIndex] + if folder == nil or entryType(folder) ~= "folder" then + return + end + folder.items = folder.items or {} + moveEntryAcross(bookmarks, fromIndex, folder.items) + render() + return + end + + -- "out|root": drop a bookmark from inside the open folder back onto + -- root, appended at the end. Only meaningful while a folder is open. + if place == "out" then + if currentFolder == nil then + return + end + local list = activeList() + moveEntryAcross(list, fromIndex, bookmarks) + render() + return + end + + local anchorIndex = tonumber(rest) + if anchorIndex == nil then + return + end + local beforeIndex = anchorIndex + if place == "after" then + beforeIndex = anchorIndex + 1 + end + + local list = activeList() + moveEntryTo(list, fromIndex, beforeIndex) + render() +end + +function _onDeleteEntry(index) + local list = activeList() + local entry = list[index] + if entry == nil then + return + end + if entryType(entry) == "folder" and #(entry.items or {}) > 0 then + -- Deleting a non-empty folder takes its contents with it; the row's + -- delete button has no separate confirm dialog available (panels + -- have no native modal), so this is a same-click destructive action + -- same as bookmark delete. Surfaced via notification after the fact + -- rather than blocking, to stay consistent with existing delete UX. + noctalia.notify( + noctalia.tr("title"), + noctalia.tr("folder_deleted", { label = entry.label or "" }) + ) + end + + local removed = table.remove(list, index) + if removed == nil then + return + end + local ok, err = saveBookmarks() + if not ok then + table.insert(list, index, removed) + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("err_delete_failed", { error = err }) + ) + render() + return + end + render() +end + +-- `context == "search"` lets an external trigger (a compositor keybind via +-- `noctalia msg panel-open dunarand/bookmarks:panel search`, or any other +-- IPC caller) open the panel straight into a focused search box, turning +-- the bookmark list into a fast fuzzy launcher without a second keypress. +-- Root-only, same as ctrl+s: searching already isn't offered inside a +-- folder, so context is ignored there. +function onOpen(context) + view = "list" + editingIndex = nil + editingRoot = false + editMode = false + currentFolder = nil + searchQuery = "" + searchRev += 1 + selectedIndex = 1 + loadBookmarks() + noctalia.state.set("bookmarks_open", true) + if context == "search" then + focusSearchOnRender = true + end + render() +end + +function onClose() + noctalia.state.set("bookmarks_open", false) +end + +-- Keyboard navigation: ctrl+n/down and ctrl+p/up move the selection through +-- the currently visible rows (search results or the active folder's list), +-- looping past either end; Return activates whatever is selected; ctrl+s +-- jumps focus to the search box (root view only, since that's the only +-- place it's rendered). Only acts on key-down (pressed == true) so each +-- chord fires once per press. +function onKey(chord, pressed) + if not pressed then + return + end + if view ~= "list" then + return + end + + if chord == "ctrl+s" then + if currentFolder == nil then + searchRev += 1 + focusSearchOnRender = true + render() + end + return + end + + local rows = visibleRows() + local count = #rows + if count == 0 then + selectedIndex = nil + return + end + + if chord == "ctrl+n" or chord == "down" then + if selectedIndex == nil then + selectedIndex = 1 + else + selectedIndex = selectedIndex % count + 1 + end + render() + elseif chord == "ctrl+p" or chord == "up" then + if selectedIndex == nil then + selectedIndex = count + else + selectedIndex = (selectedIndex - 2) % count + 1 + end + render() + elseif chord == "Return" then + local row = rows[selectedIndex] + activateRow(row) + end +end + +function update() + render() +end diff --git a/bookmarks/plugin.toml b/bookmarks/plugin.toml new file mode 100644 index 0000000..da8d013 --- /dev/null +++ b/bookmarks/plugin.toml @@ -0,0 +1,60 @@ +id = "dunarand/bookmarks" +name = "Bookmarks" +version = "1.2.1" +plugin_api = 13 +author = "dunarand" +license = "MIT" +icon = "bookmark" +description = "A user-defined list of bookmarks. Each item has a label, a glyph, and a shell command." +tags = ["launcher", "panel", "productivity", "utility"] +dependencies = ["nohup"] + +[[setting]] +key = "data_path" +type = "file" +label_key = "settings.data_path.label" +description_key = "settings.data_path.description" +default = "" +extensions = [".json"] + +[[setting]] +key = "show_info_button" +type = "bool" +label_key = "settings.show_info_button.label" +description_key = "settings.show_info_button.description" +default = true + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "bookmark" + + [[widget.setting]] + key = "tooltip" + type = "string" + label_key = "settings.tooltip.label" + description_key = "settings.tooltip.description" + default = "Bookmarks" + + [[widget.setting]] + key = "text" + type = "string" + label_key = "settings.text.label" + description_key = "settings.text.description" + default = "Bookmarks" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 380 +height = 470 +placement = "attached" +position = "auto" +open_near_click = true +capture_keys = ["ctrl+n", "ctrl+p", "up", "down", "Return", "ctrl+s"] diff --git a/bookmarks/thumbnail.webp b/bookmarks/thumbnail.webp new file mode 100644 index 0000000..3e666ae Binary files /dev/null and b/bookmarks/thumbnail.webp differ diff --git a/bookmarks/translations/en.json b/bookmarks/translations/en.json new file mode 100644 index 0000000..87d748a --- /dev/null +++ b/bookmarks/translations/en.json @@ -0,0 +1,66 @@ +{ + "title": "Bookmarks", + "new_bookmark": "New Bookmark", + "edit_bookmark": "Edit Bookmark", + "no_bookmarks": "No bookmarks yet", + "search_placeholder": "Search bookmarks...", + "no_search_results": "No matching bookmarks", + "new_folder": "New Folder", + "delete_folder_button": "Delete folder", + "edit_folder": "Edit Folder", + "folder_name_field": "Folder name", + "open_tooltip": "Open", + "back_button": "Back", + "edit_tooltip": "Edit", + "delete_tooltip": "Delete", + "drag_tooltip": "Drag to reorder, or onto a folder to file it inside", + "drop_out_of_folder": "Drop here to move back to the main panel", + "show_controls_tooltip": "Show reorder/edit/delete controls", + "hide_controls_tooltip": "Hide reorder/edit/delete controls", + "cancel_button": "Cancel", + "save_button": "Save", + "glyph_field": "Glyph name (e.g. bookmark)", + "label_field": "Label", + "cmd_field": "Shell command", + "description_field": "Description (optional)", + "info_cmd_line": "Command: {cmd}", + "info_description_line": "Description: {description}", + "run_in_background_field": "Run in background", + "run_in_terminal_field": "Run in terminal", + "err_read_failed": "Failed to read bookmarks: {error}", + "err_corrupt": "Bookmarks file is corrupt: {error}", + "err_no_data_dir": "No data directory available", + "err_reorder_failed": "Failed to reorder: {error}", + "err_no_command": "This bookmark has no command", + "err_exit_code": "\"{label}\" exited with code {code}", + "err_launch_failed": "Failed to launch \"{label}\"", + "err_delete_failed": "Failed to delete: {error}", + "err_save_failed": "Failed to save: {error}", + "err_label_required": "Label is required", + "err_label_cmd_required": "Label and command are required", + "folder_deleted": "Deleted folder \"{label}\" and its contents", + "entry_updated": "Updated \"{label}\"", + "entry_saved": "Saved \"{label}\"", + "settings": { + "glyph": { + "label": "Glyph", + "description": "Bar widget's glyph" + }, + "tooltip": { + "label": "Tooltip", + "description": "Bar widget's tooltip" + }, + "text": { + "label": "Text", + "description": "Bar widget's label. Leave empty to display only the glyph." + }, + "data_path": { + "label": "Data file", + "description": "Where data.json is stored. Leave empty to use the default plugin data directory." + }, + "show_info_button": { + "label": "Show info button", + "description": "Show a \"?\" button on bookmarks (not folders) to view their description and command on hover." + } + } +} diff --git a/bookmarks/translations/tr.json b/bookmarks/translations/tr.json new file mode 100644 index 0000000..91a8546 --- /dev/null +++ b/bookmarks/translations/tr.json @@ -0,0 +1,66 @@ +{ + "title": "Yer İmleri", + "new_bookmark": "Yeni Yer İmi", + "edit_bookmark": "Yer İmini Düzenle", + "no_bookmarks": "Henüz bir yer imi yok", + "new_folder": "Yeni Klasör", + "search_placeholder": "Yer imlerinde ara...", + "no_search_results": "Eşleşen yer imi yok", + "delete_folder_button": "Klasörü Sil", + "edit_folder": "Klasörü Düzenle", + "folder_name_field": "Klasör ismi", + "open_tooltip": "Aç", + "back_button": "Geri", + "edit_tooltip": "Düzenle", + "delete_tooltip": "Sil", + "drag_tooltip": "Sıralamak için sürükle veya bir klasörün içine bırak", + "drop_out_of_folder": "Ana panele göndermek için buraya bırak", + "show_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini göster", + "hide_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini gizle", + "cancel_button": "İptal", + "save_button": "Kaydet", + "glyph_field": "Sembol (ör. bookmark)", + "label_field": "İsim etiketi", + "cmd_field": "Shell Komutu", + "description_field": "Açıklama (İsteğe bağlı)", + "info_cmd_line": "Komut: {cmd}", + "info_description_line": "Açıklama: {description}", + "run_in_background_field": "Arka planda çalıştır", + "run_in_terminal_field": "Konsolda çalıştır", + "err_read_failed": "Yer imleri okunamadı: {error}", + "err_corrupt": "Bozuk yer imleri dosyası: {error}", + "err_no_data_dir": "Eksik veri dizgini", + "err_reorder_failed": "Sıralama Yapılamadı: {error}", + "err_no_command": "Bu yer iminin bir komutu yok", + "err_exit_code": "\"{label}\" {code} ile sonlandı", + "err_launch_failed": "\"{label}\" açılamadı", + "err_delete_failed": "Silinemedi: {error}", + "err_save_failed": "Kaydedilemedi: {error}", + "err_label_required": "Etiket gerekli", + "err_label_cmd_required": "Etiket ve komut alanları gerekli", + "folder_deleted": "\"{label}\" klasörü ve içeriği silindi", + "entry_updated": "\"{label}\" güncellendi", + "entry_saved": "\"{label}\" kaydedildi", + "settings": { + "glyph": { + "label": "Sembol", + "description": "Bar aracının sembolü (ör. bookmark)" + }, + "tooltip": { + "label": "İsim etiketi", + "description": "Bar aracının isim etiketi" + }, + "text": { + "label": "Yazı", + "description": "Bar aracının etiketi. Barda sadece sembolün görünmesi için boş bırakın." + }, + "data_path": { + "label": "Veri dosyası", + "description": "data.json dosyasının saklandığı konum. Varsayılan eklenti veri konumunu kullanmak için boş bırakın." + }, + "show_info_button": { + "label": "Bilgi butonunu göster", + "description": "Yer imlerinde (klasörlerde değil) üzerine gelindiğinde açıklamalarını ve komutlarını görüntülemek için bir \"?\" düğmesi göster." + } + } +}