Skip to content

Commit 23831ce

Browse files
committed
feat(world_clock): add multi-timezone panel and bar widget
1 parent d8616f0 commit 23831ce

8 files changed

Lines changed: 513 additions & 1 deletion

File tree

noctalia.d.luau

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,10 @@ export type Noctalia = {
176176
clipboardText: () -> string?, -- latest text clipboard content (nil when empty or non-text)
177177
getenv: (name: string) -> string?,
178178
expandPath: (path: string) -> string,
179-
formatTime: (pattern: string, unixSeconds: number?) -> string,
179+
formatTime: (pattern: string, unixSeconds: number?, timezone: string?) -> string,
180+
-- True when `name` is empty (system local) or names a zone in the active timezone database.
181+
-- Plugin API 19.
182+
isValidTimezone: (name: string) -> boolean,
180183
-- Wall-clock milliseconds since the Unix epoch. formatTime and os.time are both
181184
-- whole-second, so this is the only sub-second clock. Plugin API 12.
182185
nowMs: () -> number,

world_clock/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# World Clock
2+
3+
World Clock tracks multiple IANA timezones in a panel opened from the bar. The
4+
headless service owns the zone list and publishes live times; the bar widget and
5+
panel are thin clients of that shared state.
6+
7+
## Plugin
8+
9+
| Field | Value |
10+
| --- | --- |
11+
| ID | `noctalia/world_clock` |
12+
| Entries | Service: `service`; bar widget: `bar`; panel: `panel` |
13+
14+
## Usage
15+
16+
### Bar widget
17+
18+
Add the `bar` widget to your bar. It shows a world glyph; click it to open the
19+
world-clock panel.
20+
21+
### Panel
22+
23+
The panel lists every configured timezone with its current time and UTC offset.
24+
Type an IANA zone name (for example `Europe/Berlin`) and press Enter or the plus
25+
button to add it. Use the trash control to remove a zone (confirm with the check).
26+
Drag the grip on the left of a row to reorder.
27+
28+
On first run the list is seeded with:
29+
30+
- `UTC`
31+
- `America/New_York`
32+
- `Europe/Berlin`
33+
- `Asia/Tokyo`
34+
35+
Zones are stored under the plugin data directory and survive plugin updates.
36+
37+
### IPC
38+
39+
```sh
40+
# Open the panel
41+
noctalia msg panel-toggle noctalia/world_clock:panel
42+
43+
# Manage zones
44+
noctalia msg plugin noctalia/world_clock:service all add "America/Los_Angeles"
45+
noctalia msg plugin noctalia/world_clock:service all remove "UTC"
46+
noctalia msg plugin noctalia/world_clock:service all list
47+
noctalia msg plugin noctalia/world_clock:service all clear
48+
```
49+
50+
`list` shows the configured zones in a notification.
51+
52+
## Notes
53+
54+
Requires `plugin_api = 19` for `noctalia.formatTime(..., timezone)` and
55+
`noctalia.isValidTimezone()`.

world_clock/bar.luau

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
--!nonstrict
2+
-- World Clock bar widget — icon that opens the panel.
3+
4+
local function render()
5+
barWidget.render(ui.glyph({ name = "world" }))
6+
end
7+
8+
function update()
9+
render()
10+
end
11+
12+
function onClick()
13+
noctalia.togglePanel("noctalia/world_clock:panel")
14+
end
15+
16+
render()

world_clock/panel.luau

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
--!nonstrict
2+
-- World Clock panel — list of configured timezones with live times.
3+
-- Add IANA zones (e.g. Europe/Berlin); remove with trash; reorder via drag grip.
4+
5+
local DRAG_TYPE = "world-clock-zone"
6+
7+
local zones = {}
8+
local draft = ""
9+
local draftKey = 0
10+
local pendingDelete = nil
11+
12+
local function insertionZone(index)
13+
return ui.dropZone({
14+
key = "gap-" .. index,
15+
accepts = { DRAG_TYPE },
16+
value = tostring(index),
17+
onDrop = "onZoneDropped",
18+
height = 3,
19+
radius = 4,
20+
expandOnDrag = true,
21+
hitSlop = 24,
22+
})
23+
end
24+
25+
local function zoneRow(id)
26+
for _, row in ipairs(zones) do
27+
if row.id == id then
28+
return row
29+
end
30+
end
31+
return nil
32+
end
33+
34+
function onZoneDropped(payload, value)
35+
local insertAt = tonumber(value)
36+
if type(payload) ~= "string" or payload == "" or insertAt == nil then
37+
return
38+
end
39+
noctalia.state.set("world_clock.cmd", { op = "reorder", zone = payload, index = insertAt })
40+
end
41+
42+
local function render()
43+
local rows = {}
44+
if #zones == 0 then
45+
table.insert(rows, ui.label({
46+
text = noctalia.tr("ui.empty"),
47+
color = "on_surface_variant",
48+
fontSize = 13,
49+
}))
50+
else
51+
for i, row in ipairs(zones) do
52+
local id = row.id
53+
local deleting = pendingDelete == id
54+
local actions
55+
if deleting then
56+
actions = ui.row({ gap = 4, align = "center" }, {
57+
ui.button({
58+
glyph = "check",
59+
variant = "destructive",
60+
onClick = function()
61+
noctalia.state.set("world_clock.cmd", { op = "remove", zone = id })
62+
pendingDelete = nil
63+
end,
64+
}),
65+
ui.button({
66+
glyph = "close",
67+
variant = "ghost",
68+
onClick = function()
69+
pendingDelete = nil
70+
render()
71+
end,
72+
}),
73+
})
74+
else
75+
actions = ui.button({
76+
glyph = "trash",
77+
variant = "ghost",
78+
onClick = function()
79+
pendingDelete = id
80+
render()
81+
end,
82+
})
83+
end
84+
85+
table.insert(rows, insertionZone(i))
86+
table.insert(
87+
rows,
88+
ui.row({
89+
key = "zone-" .. id,
90+
gap = 8,
91+
align = "center",
92+
paddingV = 5,
93+
paddingH = 8,
94+
fill = "surface_variant/0.35",
95+
radius = 6,
96+
}, {
97+
ui.dragSource({
98+
key = "grip-" .. id,
99+
dragType = DRAG_TYPE,
100+
payload = id,
101+
previewAncestor = 1,
102+
liftFromLayout = true,
103+
width = 20,
104+
height = 20,
105+
align = "center",
106+
justify = "center",
107+
tooltip = noctalia.tr("ui.drag_tooltip"),
108+
}, {
109+
ui.glyph({ name = "menu-2", size = 14, color = "on_surface_variant" }),
110+
}),
111+
ui.column({ flexGrow = 1, gap = 1 }, {
112+
ui.label({ text = row.label or id, fontWeight = "bold", fontSize = 13 }),
113+
ui.label({ text = id, color = "on_surface_variant", fontSize = 10 }),
114+
}),
115+
ui.column({ align = "end", gap = 1 }, {
116+
ui.label({ text = row.time or "", fontWeight = "bold", fontSize = 15, color = "primary" }),
117+
ui.label({ text = row.offset or "", color = "on_surface_variant", fontSize = 10 }),
118+
}),
119+
actions,
120+
})
121+
)
122+
end
123+
table.insert(rows, insertionZone(#zones + 1))
124+
end
125+
126+
panel.render(ui.column({ flexGrow = 1, gap = 10, padding = 14 }, {
127+
ui.row({ align = "center", justify = "space_between" }, {
128+
ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }),
129+
ui.button({ glyph = "close", onClick = function()
130+
panel.close()
131+
end }),
132+
}),
133+
ui.row({ gap = 8, align = "center" }, {
134+
ui.input({
135+
key = "add-" .. draftKey,
136+
value = draft,
137+
placeholder = noctalia.tr("ui.add_placeholder"),
138+
flexGrow = 1,
139+
onChange = function(value)
140+
draft = value
141+
end,
142+
onSubmit = "onAdd",
143+
}),
144+
ui.button({
145+
glyph = "plus",
146+
variant = "primary",
147+
onClick = "onAdd",
148+
}),
149+
}),
150+
ui.scroll({ flexGrow = 1, gap = 0 }, rows),
151+
}))
152+
end
153+
154+
function onAdd()
155+
local zone = noctalia.string.trim(draft)
156+
draft = ""
157+
draftKey += 1
158+
pendingDelete = nil
159+
if zone ~= "" then
160+
noctalia.state.set("world_clock.cmd", { op = "add", zone = zone })
161+
end
162+
render()
163+
end
164+
165+
function onOpen(_context)
166+
zones = noctalia.state.get("world_clock.zones") or {}
167+
pendingDelete = nil
168+
draft = ""
169+
draftKey += 1
170+
panel.setWantsSecondTicks(true)
171+
render()
172+
end
173+
174+
noctalia.state.watch("world_clock.zones", function(value)
175+
if type(value) == "table" then
176+
zones = value
177+
else
178+
zones = {}
179+
end
180+
if pendingDelete ~= nil then
181+
local stillThere = false
182+
for _, row in ipairs(zones) do
183+
if row.id == pendingDelete then
184+
stillThere = true
185+
break
186+
end
187+
end
188+
if not stillThere then
189+
pendingDelete = nil
190+
end
191+
end
192+
render()
193+
end)

world_clock/plugin.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# World Clock is a multi-timezone clock with a bar widget and an attached panel.
2+
# Add or remove IANA zones; the service keeps times ticking and publishes them
3+
# to every entry through noctalia.state.
4+
5+
id = "noctalia/world_clock"
6+
name = "World Clock"
7+
version = "1.0.0"
8+
plugin_api = 19
9+
author = "noctalia"
10+
license = "MIT"
11+
dependencies = []
12+
tags = ["clock", "timezone", "utility"]
13+
icon = "world"
14+
description = "A world clock panel and bar widget for tracking multiple timezones."
15+
16+
[[widget]]
17+
id = "bar"
18+
entry = "bar.luau"
19+
20+
[[panel]]
21+
id = "panel"
22+
entry = "panel.luau"
23+
width = 360
24+
height = 520
25+
placement = "attached"
26+
position = "auto"
27+
open_near_click = true
28+
29+
[[service]]
30+
id = "service"
31+
entry = "service.luau"

0 commit comments

Comments
 (0)