Skip to content

Commit 3b70428

Browse files
committed
scripted: widgets can now declare their settings in a manifest so they show up in the GUI
1 parent 951e7cf commit 3b70428

13 files changed

Lines changed: 669 additions & 63 deletions

assets/scripts/bongocat.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@
2222
-- bc=idle, dc=left slap, ba=right slap, da=both slap
2323
-- ef=sleep, gh=blink
2424

25+
-- Self-describing manifest: lets Noctalia list this widget in the Add-widget
26+
-- picker and render its settings in the GUI. Must be the first statement, ahead
27+
-- of the keyboard-reader spawn below, so manifest extraction stays side-effect free.
28+
barWidget.define({
29+
label = "Bongo Cat",
30+
icon = "cat",
31+
description = "A cat that slaps your bar when you type or to the beat",
32+
settings = {
33+
{ key = "input_device", type = "string", label = "Keyboard device",
34+
description = "/dev/input/eventN — requires evtest and membership in the input group" },
35+
{ key = "audio_spectrum", type = "bool", label = "React to audio", default = false },
36+
{ key = "tappy_mode", type = "bool", label = "Tap to the beat", default = false,
37+
visible_when = { key = "audio_spectrum", values = { "true" } } },
38+
{ key = "rave_mode", type = "bool", label = "Rave colors on beat", default = false,
39+
visible_when = { key = "audio_spectrum", values = { "true" } } },
40+
{ key = "use_mpris_filter", type = "bool", label = "Only react while media plays", default = false,
41+
visible_when = { key = "audio_spectrum", values = { "true" } } },
42+
},
43+
})
44+
2545
barWidget.setFont("fonts/bongocat.otf")
2646
barWidget.setText("bc")
2747
barWidget.setUpdateInterval(50)

assets/scripts/screen_recorder.lua

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,59 @@
66
-- Right click — toggle replay buffer (if enabled), or save replay (if active)
77
-- Middle click — save replay buffer
88

9+
-- Self-describing manifest: lets Noctalia list this widget in the Add-widget
10+
-- picker and render its settings in the GUI. Must be the first statement.
11+
barWidget.define({
12+
label = "Screen Recorder",
13+
icon = "video",
14+
description = "Record the screen with gpu-screen-recorder",
15+
settings = {
16+
{ key = "video_source", type = "string", label = "Video source", default = "portal",
17+
description = "\"portal\", \"focused-monitor\", or a monitor/output name" },
18+
{ key = "directory", type = "string", label = "Output directory",
19+
description = "Defaults to ~/Videos when empty" },
20+
{ key = "filename_pattern", type = "string", label = "Filename pattern",
21+
default = "recording_%Y%m%d_%H%M%S", description = "os.date pattern, without extension" },
22+
{ key = "frame_rate", type = "int", label = "Frame rate", default = 60, min = 1, max = 240 },
23+
{ key = "video_codec", type = "select", label = "Video codec", default = "h264",
24+
options = {
25+
{ value = "h264", label = "H.264" }, { value = "hevc", label = "HEVC" },
26+
{ value = "av1", label = "AV1" }, { value = "vp8", label = "VP8" }, { value = "vp9", label = "VP9" },
27+
} },
28+
{ key = "quality", type = "select", label = "Quality", default = "very_high",
29+
options = {
30+
{ value = "medium", label = "Medium" }, { value = "high", label = "High" },
31+
{ value = "very_high", label = "Very high" }, { value = "ultra", label = "Ultra" },
32+
} },
33+
{ key = "resolution", type = "string", label = "Resolution", default = "original",
34+
description = "\"original\" or WIDTHxHEIGHT, e.g. 1920x1080" },
35+
{ key = "audio_source", type = "select", label = "Audio source", default = "default_output",
36+
options = {
37+
{ value = "default_output", label = "System output" },
38+
{ value = "default_input", label = "Microphone" },
39+
{ value = "both", label = "Output + microphone" },
40+
{ value = "none", label = "No audio" },
41+
} },
42+
{ key = "audio_codec", type = "select", label = "Audio codec", default = "opus",
43+
options = {
44+
{ value = "opus", label = "Opus" }, { value = "aac", label = "AAC" }, { value = "flac", label = "FLAC" },
45+
} },
46+
{ key = "show_cursor", type = "bool", label = "Show cursor", default = true },
47+
{ key = "color_range", type = "select", label = "Color range", default = "limited",
48+
options = { { value = "limited", label = "Limited" }, { value = "full", label = "Full" } } },
49+
{ key = "copy_to_clipboard", type = "bool", label = "Copy path to clipboard", default = false },
50+
{ key = "hide_inactive", type = "bool", label = "Hide widget when idle", default = false },
51+
{ key = "replay_enabled", type = "bool", label = "Enable replay buffer", default = false },
52+
{ key = "replay_duration", type = "int", label = "Replay seconds", default = 30, min = 5, max = 3600,
53+
visible_when = { key = "replay_enabled", values = { "true" } } },
54+
{ key = "replay_storage", type = "select", label = "Replay storage", default = "ram",
55+
options = { { value = "ram", label = "RAM" }, { value = "disk", label = "Disk" } },
56+
visible_when = { key = "replay_enabled", values = { "true" } } },
57+
{ key = "restore_portal", type = "bool", label = "Restore portal session", default = false,
58+
advanced = true },
59+
},
60+
})
61+
962
local CHECK_TICKS = 8 -- 8 * 250ms = 2s between process checks
1063
local PENDING_TICKS = 8
1164

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ _noctalia_sources = files(
433433
'src/scripting/script_runtime.cpp',
434434
'src/scripting/script_worker_pool.cpp',
435435
'src/scripting/scripted_widget_bindings.cpp',
436+
'src/scripting/scripted_widget_manifest.cpp',
436437
'src/shell/bar/bar.cpp',
437438
'src/shell/clipboard/clipboard_paste.cpp',
438439
'src/shell/dock/dock.cpp',

src/scripting/scripted_widget_bindings.cpp

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "lualib.h"
55

66
#include <algorithm>
7+
#include <optional>
78
#include <string>
89
#include <string_view>
910
#include <variant>
@@ -136,6 +137,173 @@ namespace {
136137
return 1;
137138
}
138139

140+
// ── Manifest parsing (barWidget.define) ──────────────────────────────────
141+
142+
std::string tableStringField(lua_State* L, int tableIndex, const char* key, std::string fallback = {}) {
143+
lua_getfield(L, tableIndex, key);
144+
std::string out = lua_isstring(L, -1) ? std::string(lua_tostring(L, -1)) : std::move(fallback);
145+
lua_pop(L, 1);
146+
return out;
147+
}
148+
149+
bool tableBoolField(lua_State* L, int tableIndex, const char* key, bool fallback) {
150+
lua_getfield(L, tableIndex, key);
151+
bool out = lua_isnil(L, -1) ? fallback : (lua_toboolean(L, -1) != 0);
152+
lua_pop(L, 1);
153+
return out;
154+
}
155+
156+
std::optional<double> tableNumberField(lua_State* L, int tableIndex, const char* key) {
157+
lua_getfield(L, tableIndex, key);
158+
std::optional<double> out;
159+
if (lua_isnumber(L, -1)) {
160+
out = lua_tonumber(L, -1);
161+
}
162+
lua_pop(L, 1);
163+
return out;
164+
}
165+
166+
scripting::ManifestFieldType parseFieldType(std::string_view type) {
167+
if (type == "bool" || type == "boolean") {
168+
return scripting::ManifestFieldType::Bool;
169+
}
170+
if (type == "int" || type == "integer") {
171+
return scripting::ManifestFieldType::Int;
172+
}
173+
if (type == "double" || type == "number" || type == "float") {
174+
return scripting::ManifestFieldType::Double;
175+
}
176+
if (type == "select" || type == "enum") {
177+
return scripting::ManifestFieldType::Select;
178+
}
179+
if (type == "color") {
180+
return scripting::ManifestFieldType::Color;
181+
}
182+
return scripting::ManifestFieldType::String;
183+
}
184+
185+
void parseFieldDefault(lua_State* L, int fieldIndex, scripting::ManifestField& field) {
186+
lua_getfield(L, fieldIndex, "default");
187+
switch (field.type) {
188+
case scripting::ManifestFieldType::Bool:
189+
field.boolDefault = lua_toboolean(L, -1) != 0;
190+
break;
191+
case scripting::ManifestFieldType::Int:
192+
case scripting::ManifestFieldType::Double:
193+
field.numberDefault = lua_isnumber(L, -1) ? lua_tonumber(L, -1) : 0.0;
194+
break;
195+
default:
196+
field.stringDefault = lua_isstring(L, -1) ? std::string(lua_tostring(L, -1)) : std::string{};
197+
break;
198+
}
199+
lua_pop(L, 1);
200+
}
201+
202+
void parseFieldOptions(lua_State* L, int fieldIndex, scripting::ManifestField& field) {
203+
lua_getfield(L, fieldIndex, "options");
204+
if (lua_istable(L, -1)) {
205+
const int optionsIndex = lua_gettop(L);
206+
const int count = static_cast<int>(lua_objlen(L, optionsIndex));
207+
for (int i = 1; i <= count; ++i) {
208+
lua_rawgeti(L, optionsIndex, i);
209+
if (lua_istable(L, -1)) {
210+
const int optIndex = lua_gettop(L);
211+
scripting::ManifestSelectOption opt;
212+
opt.value = tableStringField(L, optIndex, "value");
213+
opt.label = tableStringField(L, optIndex, "label", opt.value);
214+
if (!opt.value.empty()) {
215+
field.options.push_back(std::move(opt));
216+
}
217+
} else if (lua_isstring(L, -1)) {
218+
std::string value = lua_tostring(L, -1);
219+
field.options.push_back(scripting::ManifestSelectOption{.value = value, .label = value});
220+
}
221+
lua_pop(L, 1);
222+
}
223+
}
224+
lua_pop(L, 1);
225+
}
226+
227+
void parseFieldVisibility(lua_State* L, int fieldIndex, scripting::ManifestField& field) {
228+
lua_getfield(L, fieldIndex, "visible_when");
229+
if (lua_istable(L, -1)) {
230+
const int visIndex = lua_gettop(L);
231+
scripting::ManifestVisibility vis;
232+
vis.key = tableStringField(L, visIndex, "key");
233+
lua_getfield(L, visIndex, "values");
234+
if (lua_istable(L, -1)) {
235+
const int valuesIndex = lua_gettop(L);
236+
const int count = static_cast<int>(lua_objlen(L, valuesIndex));
237+
for (int i = 1; i <= count; ++i) {
238+
lua_rawgeti(L, valuesIndex, i);
239+
if (lua_isstring(L, -1)) {
240+
vis.values.emplace_back(lua_tostring(L, -1));
241+
} else if (lua_isboolean(L, -1)) {
242+
vis.values.emplace_back(lua_toboolean(L, -1) != 0 ? "true" : "false");
243+
}
244+
lua_pop(L, 1);
245+
}
246+
}
247+
lua_pop(L, 1);
248+
if (!vis.key.empty() && !vis.values.empty()) {
249+
field.visibleWhen = std::move(vis);
250+
}
251+
}
252+
lua_pop(L, 1);
253+
}
254+
255+
void parseManifest(lua_State* L, int tableIndex, scripting::ScriptWidgetManifest& manifest) {
256+
manifest.label = tableStringField(L, tableIndex, "label");
257+
manifest.icon = tableStringField(L, tableIndex, "icon");
258+
manifest.description = tableStringField(L, tableIndex, "description");
259+
manifest.pickable = tableBoolField(L, tableIndex, "pickable", true);
260+
261+
lua_getfield(L, tableIndex, "settings");
262+
if (lua_istable(L, -1)) {
263+
const int settingsIndex = lua_gettop(L);
264+
const int count = static_cast<int>(lua_objlen(L, settingsIndex));
265+
for (int i = 1; i <= count; ++i) {
266+
lua_rawgeti(L, settingsIndex, i);
267+
if (lua_istable(L, -1)) {
268+
const int fieldIndex = lua_gettop(L);
269+
scripting::ManifestField field;
270+
field.key = tableStringField(L, fieldIndex, "key");
271+
if (!field.key.empty()) {
272+
field.type = parseFieldType(tableStringField(L, fieldIndex, "type", "string"));
273+
field.label = tableStringField(L, fieldIndex, "label", field.key);
274+
field.description = tableStringField(L, fieldIndex, "description");
275+
field.advanced = tableBoolField(L, fieldIndex, "advanced", false);
276+
field.minValue = tableNumberField(L, fieldIndex, "min");
277+
field.maxValue = tableNumberField(L, fieldIndex, "max");
278+
if (auto step = tableNumberField(L, fieldIndex, "step"); step.has_value()) {
279+
field.step = *step;
280+
}
281+
parseFieldDefault(L, fieldIndex, field);
282+
parseFieldOptions(L, fieldIndex, field);
283+
parseFieldVisibility(L, fieldIndex, field);
284+
manifest.settings.push_back(std::move(field));
285+
}
286+
}
287+
lua_pop(L, 1);
288+
}
289+
}
290+
lua_pop(L, 1);
291+
}
292+
293+
int luau_define(lua_State* L) {
294+
auto* context = getContext(L);
295+
if (context != nullptr && context->manifestOut != nullptr && lua_istable(L, 1)) {
296+
*context->manifestOut = {};
297+
parseManifest(L, 1, *context->manifestOut);
298+
context->defineCalled = true;
299+
}
300+
// Abort the chunk during extraction so no top-level side effects run.
301+
if (context != nullptr && context->manifestExtractionMode) {
302+
luaL_error(L, "__manifest_captured");
303+
}
304+
return 0;
305+
}
306+
139307
const luaL_Reg kWidgetLib[] = {
140308
{"setText", luau_setText},
141309
{"setGlyph", luau_setGlyph},
@@ -146,6 +314,7 @@ namespace {
146314
{"setUpdateInterval", luau_setUpdateInterval},
147315
{"setVisible", luau_setVisible},
148316
{"getConfig", luau_getConfig},
317+
{"define", luau_define},
149318
{nullptr, nullptr},
150319
};
151320

src/scripting/scripted_widget_bindings.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include "scripting/scripted_widget_manifest.h"
34
#include "scripting/scripted_widget_types.h"
45

56
#include <vector>
@@ -16,6 +17,13 @@ namespace scripting {
1617
ScriptWidgetPatch patch;
1718
std::vector<ScriptWidgetSideEffect> sideEffects;
1819

20+
// Manifest extraction: when `manifestExtractionMode` is set, `barWidget.define`
21+
// captures its table into `manifestOut`, flips `defineCalled`, then aborts the
22+
// chunk so no later top-level side effects run.
23+
bool manifestExtractionMode = false;
24+
bool defineCalled = false;
25+
ScriptWidgetManifest* manifestOut = nullptr;
26+
1927
void beginCall(ScriptWidgetSnapshot nextSnapshot) {
2028
snapshot = std::move(nextSnapshot);
2129
patch = {};

0 commit comments

Comments
 (0)