Update nightwatch75/todo to 0.0.9 — drag-and-drop reorder (plugin API 5) (#91)
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 <nightwatch75@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
nightwatch75
Claude Opus 4.8
parent
7fa3903113
commit
5e7655dc73
+5
-8
@@ -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
|
||||
|
||||
+89
-64
@@ -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()
|
||||
|
||||
+3
-3
@@ -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 = []
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user