From 5e7655dc73968231e9b0cbd69dcde1e2aae2db02 Mon Sep 17 00:00:00 2001 From: nightwatch75 <30896720+nightwatch75@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:10:58 +0200 Subject: [PATCH] =?UTF-8?q?Update=20nightwatch75/todo=20to=200.0.9=20?= =?UTF-8?q?=E2=80=94=20drag-and-drop=20reorder=20(plugin=20API=205)=20(#91?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual-mode reordering now uses declarative drag-and-drop (ui.dragSource / ui.dropZone, plugin_api 5) instead of the two-click grip; also folds in the 0.0.8 crash-loop fix (strike() gated on utf8.len) and an onExit autosave flush. Translation keys nested for the validator. Co-authored-by: nightwatch75 Co-authored-by: Claude Opus 4.8 (1M context) --- todo/README.md | 13 ++-- todo/panel.luau | 153 ++++++++++++++++++++++---------------- todo/plugin.toml | 6 +- todo/translations/en.json | 3 +- 4 files changed, 98 insertions(+), 77 deletions(-) diff --git a/todo/README.md b/todo/README.md index 25b1ca9..bb3e701 100644 --- a/todo/README.md +++ b/todo/README.md @@ -28,7 +28,7 @@ noctalia msg panel-toggle nightwatch75/todo:panel | **+** (panel header) | Add a new task and start typing it | | Sort toggle (panel header) | Switch ordering between **Priority** and **Manual** | | Colour chip (row) | Cycle the task's priority: important → medium → low | -| ☰ grip (row, manual only) | Pick the row up / drop the held row here (reorder) | +| ☰ grip (row, manual only) | Drag the row to a new position (reorder) | | Click the text, or ✎ (pencil) | Edit the task's text | | **Enter**, or ✓ (row) | Commit the edit — the row goes back to a static line | | ☐ / ☑ button (row) | Toggle done/to-do (done tasks are struck through) | @@ -64,12 +64,9 @@ ordering. ### Reordering in manual mode -The noctalia plugin UI exposes no drag callbacks (only clicks), so the ☰ grip -reorders with two clicks instead of a drag: - -1. Click a row's ☰ grip — it lights up; that row is now "held". -2. Click another row's ☰ grip — the held row drops in just above it. -3. Click the held row's own grip again to cancel. +Grab a row by its ☰ grip and drag it. Thin insertion zones open up between the +rows as you drag; drop the row on one to move it there. This uses noctalia's +declarative drag-and-drop, which needs plugin API ≥ 5. ## Editing @@ -114,7 +111,7 @@ noctalia msg plugins enable nightwatch75/todo ## Requirements -- noctalia ≥ 5.0.0 +- noctalia with plugin API ≥ 5 (declarative drag-and-drop) - No external dependencies ## License diff --git a/todo/panel.luau b/todo/panel.luau index 663ea95..8cf7f92 100644 --- a/todo/panel.luau +++ b/todo/panel.luau @@ -15,11 +15,9 @@ -- * "priority" — rows display sorted important → medium → low; equal-priority -- rows keep their manual relative order. No drag handles are shown. -- * "manual" — the stored order is shown verbatim. A ☰ grip on each row --- reorders it (see below); changing a priority only recolours the chip and --- never moves the row. Because the noctalia plugin UI exposes no pointer --- drag callbacks (only onClick), the grip works as a two-click grab/drop: --- click a row's grip to pick it up (it highlights), click another row's --- grip to drop the held row just above it, or click the held grip to cancel. +-- drags it to a new position (declarative drag-and-drop, plugin_api >= 5): +-- grab the grip and drop onto one of the thin insertion zones between rows. +-- Changing a priority only recolours the chip and never moves the row. -- -- Interaction: a row is a static line by default. Click its text or the pencil -- button to edit; press Enter or the ✓ button to commit back to the static @@ -37,6 +35,7 @@ local RANK = { important = 1, medium = 2, low = 3 } local COLORS = { important = "#e06c75", medium = "#e5c07b", low = "#98c379" } local FILE_NAME = "todo.json" local AUTOSAVE_IDLE_TICKS = 2 -- 1s panel ticks with no edits before a flush +local DRAG_TYPE = "todo-row" -- drag identifier matched by the row drop zones local items = {} -- array, ALWAYS in manual order (priority mode sorts a copy for display) local nextId = 1 -- monotonic id source (identity only, not a sort key) @@ -46,8 +45,7 @@ local dirty = false -- unsaved inline text edits local idleTicks = 0 local editingId = nil -- id of the row currently in edit mode (at most one) local loaded = false -- guard so an early save() can never truncate the file -local sortMode = "priority" -- "priority" (auto sort) or "manual" (grip reorder) -local grabbedId = nil -- id of the row "picked up" by its grip, awaiting a drop +local sortMode = "priority" -- "priority" (auto sort) or "manual" (drag reorder) local confirmingClear = false -- header trash pressed, awaiting confirm/cancel local render @@ -83,9 +81,12 @@ end -- Overlay every character with U+0336 (combining long stroke) so a plain label -- renders struck through — labels/buttons have no line-through prop. +-- utf8.codes THROWS on invalid UTF-8, and render() would re-raise it on every +-- frame until the host disables the plugin; utf8.len returns nil instead, so +-- gate on it and fall back to the plain text (the ☑ still marks it done). local function strike(text) - if text == "" then - return "" + if text == "" or utf8.len(text) == nil then + return text end local out = {} for _, code in utf8.codes(text) do @@ -223,9 +224,6 @@ local function deleteItem(id) if editingId == id then editingId = nil end - if grabbedId == id then - grabbedId = nil - end save() render() end @@ -279,45 +277,38 @@ end -- Switch ordering mode. Only the `sort` choice is persisted — the task array -- is never rewritten, so the manual order survives any number of round trips --- through priority mode. Any half-finished grab is dropped. +-- through priority mode. local function setSortMode(mode) if mode ~= "manual" and mode ~= "priority" then return end sortMode = mode - grabbedId = nil save() render() end --- Move the grabbed row to sit just above `targetId` (manual reorder via grips). --- A drop onto the held row itself, or onto a vanished target, simply cancels. -local function dropGrabbedOn(targetId) - if grabbedId == nil or grabbedId == targetId then - grabbedId = nil - render() - return - end - local grabbed = nil +-- Move task `id` to sit at manual position `insertAt` (1-based, in `items`). +-- The insertion zones number the gaps 1..#items+1, so `insertAt` is where the +-- row lands before removing itself; drop onto its own gap is a no-op. Only +-- reachable in manual mode, where the render order IS the `items` order. +local function moveItemTo(id, insertAt) + local fromIndex = nil for i, item in ipairs(items) do - if item.id == grabbedId then - grabbed = table.remove(items, i) + if item.id == id then + fromIndex = i break end end - grabbedId = nil - if grabbed == nil then - render() + if fromIndex == nil or type(insertAt) ~= "number" then return end - local targetIndex = #items + 1 - for i, item in ipairs(items) do - if item.id == targetId then - targetIndex = i - break - end + local moved = table.remove(items, fromIndex) + -- Removing the row shifts every later gap down by one. + if fromIndex < insertAt then + insertAt -= 1 end - table.insert(items, targetIndex, grabbed) + insertAt = math.max(1, math.min(insertAt, #items + 1)) + table.insert(items, insertAt, moved) save() render() end @@ -332,8 +323,8 @@ local function doneCount() return n end --- Remove every done row (the header trash, after confirmation). Editing or --- grab state pointing at a removed row is dropped with it. +-- Remove every done row (the header trash, after confirmation). Editing state +-- pointing at a removed row is dropped with it. local function clearDone() local kept = {} for _, item in ipairs(items) do @@ -346,9 +337,6 @@ local function clearDone() if editingId ~= nil and findItem(editingId) == nil then editingId = nil end - if grabbedId ~= nil and findItem(grabbedId) == nil then - grabbedId = nil - end save() end confirmingClear = false @@ -358,26 +346,27 @@ end local function taskRow(item) local id = item.id local editing = editingId == id - local grabbed = grabbedId == id - -- Manual mode only: a ☰ grip that "picks up" the row, or drops the held one - -- here. Same glyph noctalia's own bar reorder uses ("menu-2"); it lights up - -- (primary) while this row is the one being held. + -- Manual mode only: a ☰ grip that drags the whole row to a new position. + -- The grip is a drag source holding the glyph, but previewAncestor = 1 + -- makes the drag ghost the whole row and liftFromLayout pulls the row out + -- of the list while it moves; the thin insertion zones between rows are the + -- drop targets. Same glyph noctalia's own bar reorder uses ("menu-2"). local grip = nil if sortMode == "manual" then - grip = ui.button({ - key = "grip-" .. id .. (grabbed and "-on" or ""), - glyph = "menu-2", - variant = grabbed and "primary" or "ghost", - tooltip = tr(grabbed and "tip_grip_drop" or "tip_grip"), - onClick = rowCallback("todoGrip", id, function() - if grabbedId == nil then - grabbedId = id - render() - else - dropGrabbedOn(id) - end - end), + grip = ui.dragSource({ + key = "grip-" .. id, + dragType = DRAG_TYPE, + payload = tostring(id), + previewAncestor = 1, + liftFromLayout = true, + width = 24, + height = 24, + align = "center", + justify = "center", + tooltip = tr("tip_grip"), + }, { + ui.glyph({ name = "menu-2", size = 14, color = "on_surface_variant" }), }) end @@ -465,13 +454,12 @@ local function taskRow(item) })) return ui.row({ - -- edit/done/mode/grab state flips the row's controls; key it to recreate cleanly + -- edit/done/mode state flips the row's controls; key it to recreate cleanly key = "row-" .. id .. (editing and "-edit" or "-view") .. (item.done and "-done" or "") - .. (sortMode == "manual" and "-m" or "") - .. (grabbed and "-grab" or ""), + .. (sortMode == "manual" and "-m" or ""), gap = 8, align = "center", }, children) @@ -485,6 +473,22 @@ local function legendEntry(priority) }) end +-- A thin drop target in the gap before manual-order row `index` (and one past +-- the end, at #items+1). expandOnDrag opens a row-height gap where the dragged +-- row will land; hitSlop makes the 3px line reachable from the rows around it. +local function insertionZone(index) + return ui.dropZone({ + key = "gap-" .. index, + accepts = { DRAG_TYPE }, + value = tostring(index), + onDrop = "onTodoDrop", + height = 3, + radius = 6, + expandOnDrag = true, + hitSlop = 28, + }) +end + render = function() -- Ordering toggle: shows the current mode and flips it. "menu-2" (the grip -- glyph) for manual, "palette" for the colour/priority sort. @@ -548,9 +552,19 @@ render = function() }) else local rows = {} - for _, item in ipairs(displayItems()) do + -- Manual mode interleaves an insertion zone before each row (and one + -- after the last) so a drag can land in any gap. In manual mode the + -- render order IS the items order, so gap N targets items index N. + local manual = sortMode == "manual" + for index, item in ipairs(displayItems()) do + if manual then + table.insert(rows, insertionZone(index)) + end table.insert(rows, taskRow(item)) end + if manual then + table.insert(rows, insertionZone(#items + 1)) + end body = ui.scroll({ flexGrow = 1, gap = 6 }, rows) end @@ -572,7 +586,6 @@ function onOpen(_context) end load() editingId = nil - grabbedId = nil confirmingClear = false dirty = false idleTicks = 0 @@ -583,7 +596,6 @@ end function onClose() editingId = nil - grabbedId = nil confirmingClear = false if dirty then save() @@ -591,6 +603,14 @@ function onClose() noctalia.state.set("todo_open", false) end +-- Fires on every teardown (shutdown and reload), even paths that skip onClose; +-- flush a pending inline edit so it is never lost. +function onExit() + if dirty then + save() + end +end + function onConfigChanged() if dirty then save() @@ -599,7 +619,6 @@ function onConfigChanged() filePath = folder .. "/" .. FILE_NAME noctalia.mkdirAll(folder) editingId = nil - grabbedId = nil confirmingClear = false load() render() @@ -622,6 +641,12 @@ function onSortToggle() setSortMode(sortMode == "manual" and "priority" or "manual") end +-- Drop callback for the insertion zones (declarative drag-and-drop). payload is +-- the dragged row's id as text, value the target gap index as text. +function onTodoDrop(payload, value) + moveItemTo(tonumber(payload), tonumber(value)) +end + function onClearDone() confirmingClear = true render() diff --git a/todo/plugin.toml b/todo/plugin.toml index f7b72b8..56334b9 100644 --- a/todo/plugin.toml +++ b/todo/plugin.toml @@ -2,14 +2,14 @@ # of editable task rows: add with +, tick to complete (the text is struck # through), delete, and click each row's colour chip to cycle its priority # (important → medium → low). A header toggle switches ordering between priority -# (auto-sorted) and manual, where a ☰ grip on each row reorders it by clicking. +# (auto-sorted) and manual, where a ☰ grip on each row drags it to reorder. # The whole list is a single JSON file in the configured folder; no external # commands are run. id = "nightwatch75/todo" name = "To Do" -version = "0.0.7" -plugin_api = 3 +version = "0.0.9" +plugin_api = 5 author = "nightwatch75" license = "MIT" dependencies = [] diff --git a/todo/translations/en.json b/todo/translations/en.json index 8e15a46..f4b4e0f 100644 --- a/todo/translations/en.json +++ b/todo/translations/en.json @@ -27,8 +27,7 @@ "tip_delete": "Delete task", "tip_done": "Mark as done", "tip_edit": "Edit", - "tip_grip": "Reorder — click to pick this row up", - "tip_grip_drop": "Click another grip to drop here, or this one to cancel", + "tip_grip": "Drag to reorder", "tip_sort": "Switch ordering mode", "tip_undone": "Mark as to do", "title": "To Do",