Add nightwatch75/todo 0.0.7 — prioritised to-do list (bar widget + panel) (#18)
* Add nightwatch75/todo 0.0.6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * To Do 0.0.7: make priority sort a view; never rewrite the manual order Review fix: switching to priority mode used to sort the items array and persist it, destroying the manual ordering. items and the on-disk tasks array now always hold the manual order; priority mode stable-sorts a copy at render time only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Massimiliano <m.angei@iotron.it> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Massimiliano
Claude Fable 5
parent
c50b326223
commit
138e991a8e
+106
@@ -0,0 +1,106 @@
|
||||
# To Do
|
||||
|
||||
A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: a
|
||||
prioritised to-do list. Click the bar glyph to toggle a panel of task rows —
|
||||
add tasks with **+**, tick them off (the text is struck through), delete them,
|
||||
and set each task's priority. The list is kept sorted by priority and stored as
|
||||
a single JSON file; no external commands are run.
|
||||
|
||||
| Action | Effect |
|
||||
|-------------------------------|-----------------------------------------------------|
|
||||
| Left click (bar glyph) | Open/close the To Do 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) |
|
||||
| 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) |
|
||||
| 🗑 button (row) | Delete the task |
|
||||
|
||||
## Priorities
|
||||
|
||||
Each task carries a priority, shown at the start of the row as a small coloured
|
||||
square. Click the square to cycle it. A legend at the foot of the panel maps
|
||||
each colour to its category:
|
||||
|
||||
| Priority | Colour |
|
||||
|-----------|--------|
|
||||
| Important | red |
|
||||
| Medium | amber |
|
||||
| Low | green |
|
||||
|
||||
## Ordering
|
||||
|
||||
The panel header carries a toggle that switches between two ordering modes; the
|
||||
choice is remembered.
|
||||
|
||||
- **Priority** (default) — rows are sorted by priority: important first, then
|
||||
medium, then low. Changing a task's priority moves it into its new group but
|
||||
keeps its position relative to its peers; equal-priority rows are never
|
||||
reshuffled. No grips are shown.
|
||||
- **Manual** — rows keep the order you give them. Each row grows a ☰ grip on the
|
||||
left; here changing a priority only recolours the chip and never moves the row.
|
||||
|
||||
Priority mode is only a view: the stored order is always the manual one, so
|
||||
switching between the two modes (as often as you like) never loses your custom
|
||||
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.
|
||||
|
||||
## Editing
|
||||
|
||||
Rows are static lines by default. Click a task's text (or its ✎ pencil button)
|
||||
to edit it; press **Enter** or the ✓ button to commit back to a static line. A
|
||||
new task (**+**) opens straight into edit mode — committing it while still empty
|
||||
simply discards it. Edits are also autosaved after a short idle pause and on
|
||||
close.
|
||||
|
||||
Tick a task (☐ → ☑) to complete it — its text is struck through until you
|
||||
un-tick it. The bar glyph's tooltip shows how many tasks are still to do.
|
||||
|
||||
## Storage
|
||||
|
||||
Tasks live in one file, `todo.json`, inside the configured **To Do folder**
|
||||
(default `~/Documents/Todo`). It is a small JSON object,
|
||||
`{ "version": 2, "sort": "priority" | "manual", "tasks": [ … ] }`, where `tasks`
|
||||
is the array of `{ id, text, priority, done }` objects (in manual order) — easy
|
||||
to read, hand-edit, sync, or back up. An older plain-array file is still read
|
||||
automatically. The plugin runs no external programs.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | What it does |
|
||||
|--------------|----------------------------------------------------------|
|
||||
| To Do folder | Where `todo.json` is stored (default `~/Documents/Todo`).|
|
||||
| Bar glyph | The glyph shown for the widget on the bar. |
|
||||
|
||||
## Install
|
||||
|
||||
Install **To Do** from Noctalia's plugin store (*Settings → Plugins*), then add
|
||||
the widget to a bar from *Settings → Bar*. Plugin options live in
|
||||
*Settings → Plugins*.
|
||||
|
||||
For local development, add your working copy as a path source instead
|
||||
(`.luau` edits hot-reload):
|
||||
|
||||
```sh
|
||||
noctalia msg plugins source add dev path /path/to/plugins
|
||||
noctalia msg plugins enable nightwatch75/todo
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- noctalia ≥ 5.0.0
|
||||
- No external dependencies
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
+641
@@ -0,0 +1,641 @@
|
||||
--!nonstrict
|
||||
-- To Do — a prioritised task list in an attached panel.
|
||||
--
|
||||
-- A task is { id, text, priority, done }. The whole list is persisted to
|
||||
-- <todo_folder>/todo.json (noctalia.json) as an object { version, sort, tasks }:
|
||||
-- `tasks` is the array — ALWAYS in manual order — and `sort` records the chosen
|
||||
-- display mode. A legacy plain-array file is still read (treated as manual
|
||||
-- order, sort defaulting to "priority"). Empty (blank-text) tasks are never
|
||||
-- written: they only exist transiently while being entered.
|
||||
--
|
||||
-- Ordering has two modes, switched from a header toggle. `items` (and the disk
|
||||
-- array) hold the manual order in both; priority mode is a VIEW — a stable sort
|
||||
-- applied at render time only — so switching modes never rewrites the user's
|
||||
-- manual order:
|
||||
-- * "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.
|
||||
--
|
||||
-- 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
|
||||
-- line. The coloured chip cycles the priority (a legend at the foot of the
|
||||
-- panel maps each colour to its category). The ☐/☑ button toggles done, which
|
||||
-- strikes the text through. A header trash button deletes every done row after
|
||||
-- an inline confirmation strip (confirm/cancel) — the plugin UI has no dialog
|
||||
-- primitive. No external commands are run.
|
||||
--
|
||||
-- The bar widget (todo.luau) lights its glyph while the panel is open via the
|
||||
-- shared "todo_open" state; it reads todo.json itself for the pending count.
|
||||
|
||||
local PRIORITIES = { "important", "medium", "low" }
|
||||
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 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)
|
||||
local folder = ""
|
||||
local filePath = ""
|
||||
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 confirmingClear = false -- header trash pressed, awaiting confirm/cancel
|
||||
|
||||
local render
|
||||
|
||||
-- Per-row callbacks need distinct global names; the reconciler dispatches
|
||||
-- callbacks by name only. getfenv() is the script environment lua_getglobal
|
||||
-- reads from, so assigning into it defines the callback the host will find.
|
||||
-- Keyed by the stable id, so a callback stays valid as rows reorder.
|
||||
local env = getfenv()
|
||||
local function rowCallback(prefix, id, fn)
|
||||
local name = prefix .. "_" .. id
|
||||
env[name] = fn
|
||||
return name
|
||||
end
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function isBlank(text)
|
||||
return noctalia.string.trim(text or "") == ""
|
||||
end
|
||||
|
||||
local function todoFolder()
|
||||
local dir = noctalia.getConfig("todo_folder")
|
||||
if dir == nil or dir == "" then
|
||||
dir = noctalia.expandPath("~/Documents/Todo")
|
||||
else
|
||||
dir = noctalia.expandPath(dir)
|
||||
end
|
||||
return (dir:gsub("/+$", ""))
|
||||
end
|
||||
|
||||
-- Overlay every character with U+0336 (combining long stroke) so a plain label
|
||||
-- renders struck through — labels/buttons have no line-through prop.
|
||||
local function strike(text)
|
||||
if text == "" then
|
||||
return ""
|
||||
end
|
||||
local out = {}
|
||||
for _, code in utf8.codes(text) do
|
||||
table.insert(out, utf8.char(code))
|
||||
table.insert(out, "\u{0336}")
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function normalizePriority(value)
|
||||
return RANK[value] ~= nil and value or "medium"
|
||||
end
|
||||
|
||||
-- The rows to render, in display order. `items` itself is never reordered by
|
||||
-- priority mode: it sorts a COPY (stable by priority via decorate-by-index, so
|
||||
-- equal-priority rows keep their manual relative order). Keeping the sort out
|
||||
-- of the model is what preserves the manual order across mode switches.
|
||||
local function displayItems()
|
||||
if sortMode ~= "priority" then
|
||||
return items
|
||||
end
|
||||
local decorated = {}
|
||||
for index, item in ipairs(items) do
|
||||
decorated[index] = { item = item, index = index }
|
||||
end
|
||||
table.sort(decorated, function(a, b)
|
||||
local ra, rb = RANK[a.item.priority], RANK[b.item.priority]
|
||||
if ra ~= rb then
|
||||
return ra < rb
|
||||
end
|
||||
return a.index < b.index -- stable: keep the manual within-priority order
|
||||
end)
|
||||
local sorted = {}
|
||||
for index, entry in ipairs(decorated) do
|
||||
sorted[index] = entry.item
|
||||
end
|
||||
return sorted
|
||||
end
|
||||
|
||||
local function findItem(id)
|
||||
for _, item in ipairs(items) do
|
||||
if item.id == id then
|
||||
return item
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function removeItem(id)
|
||||
for i, item in ipairs(items) do
|
||||
if item.id == id then
|
||||
table.remove(items, i)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function load()
|
||||
items = {}
|
||||
nextId = 1
|
||||
sortMode = "priority"
|
||||
local raw = noctalia.readFile(filePath)
|
||||
if raw ~= nil and raw ~= "" then
|
||||
local decoded = noctalia.json.decode(raw)
|
||||
local list = nil
|
||||
if type(decoded) == "table" then
|
||||
-- Object form { version, sort, tasks }; a legacy plain array has no
|
||||
-- `tasks` key and is read as-is (manual order, priority sort).
|
||||
if decoded.tasks ~= nil then
|
||||
if decoded.sort == "manual" or decoded.sort == "priority" then
|
||||
sortMode = decoded.sort
|
||||
end
|
||||
list = decoded.tasks
|
||||
else
|
||||
list = decoded
|
||||
end
|
||||
end
|
||||
if type(list) == "table" then
|
||||
for _, entry in ipairs(list) do
|
||||
if type(entry) == "table" then
|
||||
local id = tonumber(entry.id) or nextId
|
||||
table.insert(items, {
|
||||
id = id,
|
||||
text = type(entry.text) == "string" and entry.text or "",
|
||||
priority = normalizePriority(entry.priority),
|
||||
done = entry.done == true,
|
||||
})
|
||||
if id >= nextId then
|
||||
nextId = id + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- The on-disk order IS the manual order; priority mode sorts at render time.
|
||||
loaded = true
|
||||
end
|
||||
|
||||
local function save()
|
||||
if not loaded then
|
||||
return -- never write before the first load reads what is on disk
|
||||
end
|
||||
-- Blank tasks are in-progress placeholders; they never reach disk.
|
||||
local toStore = {}
|
||||
for _, item in ipairs(items) do
|
||||
if not isBlank(item.text) then
|
||||
table.insert(toStore, item)
|
||||
end
|
||||
end
|
||||
local encoded = noctalia.json.encode({ version = 2, sort = sortMode, tasks = toStore }, true)
|
||||
if encoded == nil then
|
||||
return
|
||||
end
|
||||
local ok, err = noctalia.writeFile(filePath, encoded)
|
||||
if not ok then
|
||||
noctalia.notifyError(tr("title"), err or tr("save_failed"))
|
||||
return
|
||||
end
|
||||
dirty = false
|
||||
end
|
||||
|
||||
local function addItem()
|
||||
local item = { id = nextId, text = "", priority = "medium", done = false }
|
||||
nextId += 1
|
||||
-- Appended at the foot of the manual order; the priority view shows it at
|
||||
-- the foot of its group.
|
||||
table.insert(items, item)
|
||||
editingId = item.id -- new task opens straight into edit mode
|
||||
render()
|
||||
end
|
||||
|
||||
local function deleteItem(id)
|
||||
removeItem(id)
|
||||
if editingId == id then
|
||||
editingId = nil
|
||||
end
|
||||
if grabbedId == id then
|
||||
grabbedId = nil
|
||||
end
|
||||
save()
|
||||
render()
|
||||
end
|
||||
|
||||
local function enterEdit(id)
|
||||
-- Leaving a still-blank row behind (e.g. a just-added task) would strand an
|
||||
-- empty line; drop it as we move focus elsewhere.
|
||||
if editingId ~= nil and editingId ~= id then
|
||||
local prev = findItem(editingId)
|
||||
if prev ~= nil and isBlank(prev.text) then
|
||||
removeItem(editingId)
|
||||
end
|
||||
end
|
||||
editingId = id
|
||||
render()
|
||||
end
|
||||
|
||||
local function commitEdit(id, value)
|
||||
local item = findItem(id)
|
||||
if item == nil then
|
||||
return
|
||||
end
|
||||
item.text = value
|
||||
editingId = nil
|
||||
if isBlank(item.text) then
|
||||
removeItem(id) -- committing a blank task discards it
|
||||
end
|
||||
save()
|
||||
render()
|
||||
end
|
||||
|
||||
local function toggleDone(id)
|
||||
local item = findItem(id)
|
||||
if item ~= nil then
|
||||
item.done = not item.done
|
||||
save()
|
||||
render()
|
||||
end
|
||||
end
|
||||
|
||||
local function cyclePriority(id)
|
||||
local item = findItem(id)
|
||||
if item ~= nil then
|
||||
-- important → medium → low → important. The manual position is
|
||||
-- untouched; the priority view re-sorts on render.
|
||||
item.priority = PRIORITIES[(RANK[item.priority] % #PRIORITIES) + 1]
|
||||
save()
|
||||
render()
|
||||
end
|
||||
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.
|
||||
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
|
||||
for i, item in ipairs(items) do
|
||||
if item.id == grabbedId then
|
||||
grabbed = table.remove(items, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
grabbedId = nil
|
||||
if grabbed == nil then
|
||||
render()
|
||||
return
|
||||
end
|
||||
local targetIndex = #items + 1
|
||||
for i, item in ipairs(items) do
|
||||
if item.id == targetId then
|
||||
targetIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
table.insert(items, targetIndex, grabbed)
|
||||
save()
|
||||
render()
|
||||
end
|
||||
|
||||
local function doneCount()
|
||||
local n = 0
|
||||
for _, item in ipairs(items) do
|
||||
if item.done then
|
||||
n += 1
|
||||
end
|
||||
end
|
||||
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.
|
||||
local function clearDone()
|
||||
local kept = {}
|
||||
for _, item in ipairs(items) do
|
||||
if not item.done then
|
||||
table.insert(kept, item)
|
||||
end
|
||||
end
|
||||
if #kept ~= #items then
|
||||
items = kept
|
||||
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
|
||||
render()
|
||||
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.
|
||||
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),
|
||||
})
|
||||
end
|
||||
|
||||
local chip = ui.box({
|
||||
-- priority in the key so the retained box picks up the new fill colour
|
||||
key = "chip-" .. id .. "-" .. item.priority,
|
||||
width = 16,
|
||||
height = 16,
|
||||
radius = 4,
|
||||
fill = COLORS[item.priority],
|
||||
onClick = rowCallback("todoPrio", id, function()
|
||||
cyclePriority(id)
|
||||
end),
|
||||
})
|
||||
local children = {}
|
||||
if grip ~= nil then
|
||||
table.insert(children, grip)
|
||||
end
|
||||
table.insert(children, chip)
|
||||
|
||||
if editing then
|
||||
table.insert(children, ui.input({
|
||||
key = "text-" .. id,
|
||||
value = item.text,
|
||||
placeholder = tr("placeholder"),
|
||||
focus = true,
|
||||
flexGrow = 1,
|
||||
onChange = rowCallback("todoText", id, function(value)
|
||||
item.text = value
|
||||
dirty = true
|
||||
idleTicks = 0
|
||||
end),
|
||||
onSubmit = rowCallback("todoSubmit", id, function(value)
|
||||
commitEdit(id, value)
|
||||
end),
|
||||
}))
|
||||
-- ✓ commits the edit, mirroring Enter.
|
||||
table.insert(children, ui.button({
|
||||
glyph = "check",
|
||||
variant = "primary",
|
||||
tooltip = tr("tip_commit"),
|
||||
onClick = rowCallback("todoOk", id, function()
|
||||
commitEdit(id, item.text)
|
||||
end),
|
||||
}))
|
||||
else
|
||||
-- Static text; clicking it re-opens the editor ("press on the text").
|
||||
table.insert(children, ui.button({
|
||||
key = "view-" .. id,
|
||||
text = item.done and strike(item.text) or item.text,
|
||||
variant = "ghost",
|
||||
contentAlign = "start",
|
||||
flexGrow = 1,
|
||||
onClick = rowCallback("todoEdit", id, function()
|
||||
enterEdit(id)
|
||||
end),
|
||||
}))
|
||||
-- Done toggle: strikes the text through.
|
||||
table.insert(children, ui.button({
|
||||
glyph = item.done and "square-check" or "square",
|
||||
variant = "ghost",
|
||||
tooltip = tr(item.done and "tip_undone" or "tip_done"),
|
||||
onClick = rowCallback("todoDone", id, function()
|
||||
toggleDone(id)
|
||||
end),
|
||||
}))
|
||||
-- Explicit edit affordance next to the row.
|
||||
table.insert(children, ui.button({
|
||||
glyph = "pencil",
|
||||
variant = "ghost",
|
||||
tooltip = tr("tip_edit"),
|
||||
onClick = rowCallback("todoPencil", id, function()
|
||||
enterEdit(id)
|
||||
end),
|
||||
}))
|
||||
end
|
||||
|
||||
table.insert(children, ui.button({
|
||||
glyph = "trash",
|
||||
variant = "ghost",
|
||||
tooltip = tr("tip_delete"),
|
||||
onClick = rowCallback("todoDel", id, function()
|
||||
deleteItem(id)
|
||||
end),
|
||||
}))
|
||||
|
||||
return ui.row({
|
||||
-- edit/done/mode/grab 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 ""),
|
||||
gap = 8,
|
||||
align = "center",
|
||||
}, children)
|
||||
end
|
||||
|
||||
-- A footer legend mapping each chip colour to its category.
|
||||
local function legendEntry(priority)
|
||||
return ui.row({ gap = 6, align = "center" }, {
|
||||
ui.box({ width = 12, height = 12, radius = 3, fill = COLORS[priority] }),
|
||||
ui.label({ text = tr("prio_" .. priority), fontSize = 11, color = "on_surface_variant" }),
|
||||
})
|
||||
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.
|
||||
local sortToggle = ui.button({
|
||||
key = "header-sort-" .. sortMode,
|
||||
glyph = sortMode == "manual" and "menu-2" or "palette",
|
||||
text = sortMode == "manual" and tr("sort_manual") or tr("sort_priority"),
|
||||
variant = "ghost",
|
||||
tooltip = tr("tip_sort"),
|
||||
onClick = "onSortToggle",
|
||||
})
|
||||
|
||||
-- With nothing done the trash has nothing to clear; keep it visible but
|
||||
-- disabled, and fold away a confirmation left open while the last done
|
||||
-- row was toggled back.
|
||||
local done = doneCount()
|
||||
if done == 0 then
|
||||
confirmingClear = false
|
||||
end
|
||||
|
||||
local header = ui.row({ align = "center", gap = 8 }, {
|
||||
ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }),
|
||||
sortToggle,
|
||||
ui.button({
|
||||
key = "header-clear-done" .. (done == 0 and "-off" or ""),
|
||||
glyph = "trash",
|
||||
variant = "ghost",
|
||||
enabled = done > 0,
|
||||
tooltip = tr("tip_clear_done"),
|
||||
onClick = "onClearDone",
|
||||
}),
|
||||
ui.button({ key = "header-add", glyph = "plus", variant = "primary", tooltip = tr("tip_add"), onClick = "onAdd" }),
|
||||
ui.button({ key = "header-close", glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
||||
})
|
||||
|
||||
-- Confirmation strip for the header trash: the question plus an explicit
|
||||
-- destructive confirm and a cancel, in place of a modal (the plugin UI has
|
||||
-- no dialog primitive).
|
||||
local confirmStrip = nil
|
||||
if confirmingClear then
|
||||
confirmStrip = ui.row({ key = "confirm-clear", gap = 8, align = "center" }, {
|
||||
ui.label({ text = tr("clear_done_confirm"), color = "on_surface", flexGrow = 1 }),
|
||||
ui.button({ text = tr("clear_done_yes"), variant = "destructive", onClick = "onClearDoneConfirm" }),
|
||||
ui.button({ text = tr("clear_done_no"), variant = "ghost", onClick = "onClearDoneCancel" }),
|
||||
})
|
||||
end
|
||||
|
||||
local legend = ui.column({ gap = 8 }, {
|
||||
ui.separator({}),
|
||||
ui.row({ gap = 16, align = "center", justify = "center" }, {
|
||||
legendEntry("important"),
|
||||
legendEntry("medium"),
|
||||
legendEntry("low"),
|
||||
}),
|
||||
})
|
||||
|
||||
local body
|
||||
if #items == 0 then
|
||||
body = ui.column({ flexGrow = 1, align = "center", justify = "center" }, {
|
||||
ui.label({ text = tr("empty"), color = "on_surface_variant" }),
|
||||
})
|
||||
else
|
||||
local rows = {}
|
||||
for _, item in ipairs(displayItems()) do
|
||||
table.insert(rows, taskRow(item))
|
||||
end
|
||||
body = ui.scroll({ flexGrow = 1, gap = 6 }, rows)
|
||||
end
|
||||
|
||||
local parts = { header }
|
||||
if confirmStrip ~= nil then
|
||||
table.insert(parts, confirmStrip)
|
||||
end
|
||||
table.insert(parts, body)
|
||||
table.insert(parts, legend)
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, parts))
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
folder = todoFolder()
|
||||
filePath = folder .. "/" .. FILE_NAME
|
||||
local ok, err = noctalia.mkdirAll(folder)
|
||||
if not ok then
|
||||
noctalia.notifyError(tr("title"), err or "")
|
||||
end
|
||||
load()
|
||||
editingId = nil
|
||||
grabbedId = nil
|
||||
confirmingClear = false
|
||||
dirty = false
|
||||
idleTicks = 0
|
||||
panel.setWantsSecondTicks(true)
|
||||
noctalia.state.set("todo_open", true)
|
||||
render()
|
||||
end
|
||||
|
||||
function onClose()
|
||||
editingId = nil
|
||||
grabbedId = nil
|
||||
confirmingClear = false
|
||||
if dirty then
|
||||
save()
|
||||
end
|
||||
noctalia.state.set("todo_open", false)
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
if dirty then
|
||||
save()
|
||||
end
|
||||
folder = todoFolder()
|
||||
filePath = folder .. "/" .. FILE_NAME
|
||||
noctalia.mkdirAll(folder)
|
||||
editingId = nil
|
||||
grabbedId = nil
|
||||
confirmingClear = false
|
||||
load()
|
||||
render()
|
||||
end
|
||||
|
||||
function update()
|
||||
if dirty then
|
||||
idleTicks += 1
|
||||
if idleTicks >= AUTOSAVE_IDLE_TICKS then
|
||||
save()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function onAdd()
|
||||
addItem()
|
||||
end
|
||||
|
||||
function onSortToggle()
|
||||
setSortMode(sortMode == "manual" and "priority" or "manual")
|
||||
end
|
||||
|
||||
function onClearDone()
|
||||
confirmingClear = true
|
||||
render()
|
||||
end
|
||||
|
||||
function onClearDoneConfirm()
|
||||
clearDone()
|
||||
end
|
||||
|
||||
function onClearDoneCancel()
|
||||
confirmingClear = false
|
||||
render()
|
||||
end
|
||||
|
||||
function onClosePanel()
|
||||
panel.close()
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
# To Do — a prioritised task list on the bar. Click the glyph to toggle a panel
|
||||
# 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.
|
||||
# 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"
|
||||
min_noctalia = "5.0.0"
|
||||
author = "nightwatch75"
|
||||
license = "MIT"
|
||||
dependencies = []
|
||||
tags = ["bar", "panel", "productivity"]
|
||||
icon = "checklist"
|
||||
description = "A prioritised to-do list in a bar panel: editable tasks with low/medium/important colour chips, a done/to-do toggle that strikes completed text through, and ordering that switches between priority-sorted and manual (drag-free ☰ grip reorder)."
|
||||
|
||||
# Plugin-level setting: the folder holding the task file, shared by the panel
|
||||
# (reads/writes it) and the bar widget (reads it for the pending count).
|
||||
[[setting]]
|
||||
key = "todo_folder"
|
||||
type = "folder"
|
||||
label_key = "settings.todo_folder.label"
|
||||
description_key = "settings.todo_folder.description"
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
width = 400
|
||||
height = 460
|
||||
placement = "attached"
|
||||
open_near_click = true
|
||||
|
||||
[[widget]]
|
||||
id = "todo"
|
||||
entry = "todo.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
description_key = "settings.glyph.description"
|
||||
default = "checklist"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,73 @@
|
||||
--!nonstrict
|
||||
-- To Do — bar widget that toggles the task panel.
|
||||
--
|
||||
-- The glyph is configurable (widget setting). It lights up while the panel is
|
||||
-- open (shared "todo_open" state, published by panel.luau) and its tooltip
|
||||
-- shows how many tasks are still to do. The count is read straight from
|
||||
-- todo.json, so it is correct before the panel is ever opened and reflects
|
||||
-- edits made to the file outside noctalia.
|
||||
|
||||
local PANEL_ID = "nightwatch75/todo:panel"
|
||||
local FILE_NAME = "todo.json"
|
||||
|
||||
local open = false
|
||||
local pending = 0
|
||||
|
||||
local function todoFile()
|
||||
local dir = noctalia.getConfig("todo_folder")
|
||||
if dir == nil or dir == "" then
|
||||
dir = noctalia.expandPath("~/Documents/Todo")
|
||||
else
|
||||
dir = noctalia.expandPath(dir)
|
||||
end
|
||||
return (dir:gsub("/+$", "")) .. "/" .. FILE_NAME
|
||||
end
|
||||
|
||||
local function pendingCount()
|
||||
local raw = noctalia.readFile(todoFile())
|
||||
if raw == nil or raw == "" then
|
||||
return 0
|
||||
end
|
||||
local decoded = noctalia.json.decode(raw)
|
||||
if type(decoded) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
-- Object form { version, sort, tasks }; a legacy plain array is read as-is.
|
||||
local list = decoded.tasks ~= nil and decoded.tasks or decoded
|
||||
if type(list) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
local n = 0
|
||||
for _, entry in ipairs(list) do
|
||||
if type(entry) == "table" and entry.done ~= true then
|
||||
n += 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local function render()
|
||||
barWidget.setGlyph(noctalia.getConfig("glyph"))
|
||||
barWidget.setGlyphColor(open and "primary" or "on_surface")
|
||||
barWidget.setTooltip(noctalia.tr("tooltip", { count = pending }))
|
||||
end
|
||||
|
||||
noctalia.state.watch("todo_open", function(value)
|
||||
open = value == true
|
||||
render()
|
||||
end)
|
||||
|
||||
-- Periodic re-read keeps the pending count and glyph in sync with edits and
|
||||
-- setting changes.
|
||||
function update()
|
||||
pending = pendingCount()
|
||||
render()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
|
||||
noctalia.setUpdateInterval(2000)
|
||||
pending = pendingCount()
|
||||
render()
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"title": "To Do",
|
||||
"empty": "No tasks yet — add one with +",
|
||||
"placeholder": "New task…",
|
||||
"tooltip": "To Do — {count} to do",
|
||||
"save_failed": "Failed to save the task list",
|
||||
"prio_low": "Low",
|
||||
"prio_medium": "Medium",
|
||||
"prio_important": "Important",
|
||||
"sort_priority": "Priority",
|
||||
"sort_manual": "Manual",
|
||||
"clear_done_confirm": "Delete all done entries?",
|
||||
"clear_done_yes": "Delete",
|
||||
"clear_done_no": "Cancel",
|
||||
"tip_sort": "Switch ordering mode",
|
||||
"tip_clear_done": "Delete all done tasks",
|
||||
"tip_add": "Add a task",
|
||||
"tip_close": "Close",
|
||||
"tip_grip": "Reorder — click to pick this row up",
|
||||
"tip_grip_drop": "Click another grip to drop here, or this one to cancel",
|
||||
"tip_commit": "Save (Enter)",
|
||||
"tip_done": "Mark as done",
|
||||
"tip_undone": "Mark as to do",
|
||||
"tip_edit": "Edit",
|
||||
"tip_delete": "Delete task",
|
||||
"settings.todo_folder.label": "To Do folder",
|
||||
"settings.todo_folder.description": "Folder holding the task list (todo.json). Defaults to ~/Documents/Todo.",
|
||||
"settings.glyph.label": "Bar glyph",
|
||||
"settings.glyph.description": "The glyph shown for the To Do widget on the bar."
|
||||
}
|
||||
Reference in New Issue
Block a user