diff --git a/.github/workflows/test_validate_plugins.py b/.github/workflows/test_validate_plugins.py index 5400fd2..43b67be 100644 --- a/.github/workflows/test_validate_plugins.py +++ b/.github/workflows/test_validate_plugins.py @@ -220,5 +220,75 @@ def test_requires_conditional_sections(self) -> None: self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings))) +class SettingUsageTests(unittest.TestCase): + """plugin.toml and the Luau sources must agree on which settings exist.""" + + def validate(self, manifest: dict, sources: dict[str, str]) -> list[str]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin_dir = root / "example" + plugin_dir.mkdir() + for name, contents in sources.items(): + path = plugin_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + validator = validate_plugins.Validator(root) + validator.validate_setting_usage(plugin_dir / "plugin.toml", plugin_dir, manifest) + return validator.errors + + def test_accepts_matching_manifest_and_source(self) -> None: + manifest = {"setting": [{"key": "interval"}, {"key": "notify"}]} + source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("notify")\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_rejects_reading_an_undeclared_setting(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("intervl")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("intervl" in error for error in errors)) + + def test_rejects_a_declared_setting_nothing_reads(self) -> None: + manifest = {"setting": [{"key": "interval"}, {"key": "orphan"}]} + source = 'local a = noctalia.getConfig("interval")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("orphan" in error for error in errors)) + + def test_collects_entry_level_settings(self) -> None: + manifest = {"widget": [{"id": "w", "entry": "w.luau", "setting": [{"key": "bar_width"}]}]} + source = 'local w = noctalia.getConfig("bar_width")\n' + self.assertEqual(self.validate(manifest, {"w.luau": source}), []) + + def test_reads_across_multiple_sources(self) -> None: + manifest = {"setting": [{"key": "a"}, {"key": "b"}]} + sources = {"one.luau": 'noctalia.getConfig("a")\n', "two.luau": 'noctalia.getConfig("b")\n'} + self.assertEqual(self.validate(manifest, sources), []) + + def test_ignores_getconfig_inside_comments(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig("interval")\n-- noctalia.getConfig("ghost")\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_ignores_getconfig_inside_block_comments(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig("interval")\n--[[ noctalia.getConfig("ghost") ]]\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_skips_unused_check_when_a_key_is_dynamic(self) -> None: + # A key read through a variable cannot be seen, so "declared but unread" would be a + # false positive. The undeclared-read check still applies. + manifest = {"setting": [{"key": "interval"}, {"key": "maybe_used"}]} + source = 'local k = "interval"\nnoctalia.getConfig(k)\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_still_flags_undeclared_reads_when_a_key_is_dynamic(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig(k)\nnoctalia.getConfig("ghost")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("ghost" in error for error in errors)) + + def test_accepts_a_plugin_with_no_settings(self) -> None: + self.assertEqual(self.validate({}, {"main.luau": "local x = 1\n"}), []) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index 15b86b3..6cb1454 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -156,6 +156,10 @@ OBSOLETE_CONFIG_ACCESSOR_RE = re.compile( r"\b(barWidget|desktopWidget|panel|launcher)\s*\.\s*getConfig\b" ) +# getConfig("key") / getConfig('key') — the argument must be a plain literal to be read. +CONFIG_KEY_LITERAL_RE = re.compile(r"\bgetConfig\s*\(\s*[\"']([^\"']+)[\"']\s*\)") +# Any getConfig call whose argument is not a bare literal (a variable, a concatenation). +CONFIG_KEY_DYNAMIC_RE = re.compile(r"\bgetConfig\s*\(\s*(?![\"'][^\"']*[\"']\s*\))") def is_non_empty_string(value: Any) -> bool: @@ -257,8 +261,13 @@ def section_body(markdown: str, headings: list[tuple[int, str, int, int]], index return markdown[body_start:body_end].strip() -def obsolete_config_accessors(source: str) -> list[tuple[str, int]]: - """Find removed entry-specific getConfig aliases outside Luau comments and strings.""" +def mask_luau(source: str, *, mask_strings: bool) -> str: + """Blank out Luau comments, and optionally string literals, preserving offsets. + + Newlines survive so line numbers still line up. Callers that only need to spot code + pass mask_strings=True; callers that need to read a literal argument pass False and + keep comments masked, so a getConfig call inside a comment is still ignored. + """ visible = list(source) length = len(source) index = 0 @@ -286,7 +295,8 @@ def mask(start: int, end: int) -> None: if source.startswith("[[", index): end = source.find("]]", index + 2) end = length if end == -1 else end + 2 - mask(index, end) + if mask_strings: + mask(index, end) index = end continue @@ -300,19 +310,37 @@ def mask(start: int, end: int) -> None: end += 1 if source[end - 1] == quote: break - mask(index, end) + if mask_strings: + mask(index, end) index = end continue index += 1 - code = "".join(visible) + return "".join(visible) + + +def obsolete_config_accessors(source: str) -> list[tuple[str, int]]: + """Find removed entry-specific getConfig aliases outside Luau comments and strings.""" + code = mask_luau(source, mask_strings=True) return [ (f"{match.group(1)}.getConfig", code.count("\n", 0, match.start()) + 1) for match in OBSOLETE_CONFIG_ACCESSOR_RE.finditer(code) ] +def config_keys_read(source: str) -> tuple[set[str], bool]: + """Setting keys read via noctalia.getConfig, and whether any call is not a literal. + + A non-literal call — getConfig(key), getConfig("a" .. b) — means the set is not the + whole story, so callers must not conclude a declared setting is unused. + """ + code = mask_luau(source, mask_strings=False) + keys = set(CONFIG_KEY_LITERAL_RE.findall(code)) + dynamic = CONFIG_KEY_DYNAMIC_RE.search(code) is not None + return keys, dynamic + + def webp_dimensions(header: bytes) -> tuple[int, int] | None: """Width and height from a WebP header, or None if it is not one we can read. @@ -1122,6 +1150,55 @@ def validate_luau_api(self, plugin_dir: Path) -> None: f"'{accessor}' on line {line} was removed; use noctalia.getConfig", ) + def declared_setting_keys(self, manifest: dict[str, Any]) -> set[str]: + keys: set[str] = set() + for setting in manifest.get("setting") or []: + if isinstance(setting, dict) and is_non_empty_string(setting.get("key")): + keys.add(setting["key"]) + for kind in ENTRY_TYPES: + for entry in manifest.get(kind) or []: + if not isinstance(entry, dict): + continue + for setting in entry.get("setting") or []: + if isinstance(setting, dict) and is_non_empty_string(setting.get("key")): + keys.add(setting["key"]) + return keys + + def validate_setting_usage(self, manifest_path: Path, plugin_dir: Path, manifest: dict[str, Any]) -> None: + """Keep plugin.toml and the Luau sources agreed on which settings exist. + + Reading an undeclared key is a silent failure at runtime: the host only resolves + manifest-declared keys, so a typo yields nil rather than an error. The reverse — + a declared setting nothing reads — puts a dead control in the user's settings UI. + """ + declared = self.declared_setting_keys(manifest) + + read: set[str] = set() + dynamic = False + for source_path in sorted(plugin_dir.rglob("*.luau")): + try: + source = source_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue # already reported by validate_luau_api + source_keys, source_dynamic = config_keys_read(source) + read |= source_keys + dynamic = dynamic or source_dynamic + + for key in sorted(read - declared): + self.add_error( + manifest_path, + f"getConfig('{key}') reads a setting that is not declared in plugin.toml", + ) + + # Only meaningful when every call site is a literal; otherwise a key may well be + # read through a variable and flagging it would be a false positive. + if not dynamic: + for key in sorted(declared - read): + self.add_error( + manifest_path, + f"setting '{key}' is declared but never read via noctalia.getConfig", + ) + def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None: for path in plugin_dir.rglob("*"): if path.is_symlink(): @@ -1140,6 +1217,7 @@ def validate_manifest(self, manifest_path: Path) -> None: self.validate_thumbnail(manifest_path, plugin_dir) self.validate_readme(plugin_dir, manifest) self.validate_luau_api(plugin_dir) + self.validate_setting_usage(manifest_path, plugin_dir, manifest) self.validate_no_symlinks(manifest_path, plugin_dir) if "setting" in manifest: diff --git a/cpu-bars/README.md b/cpu-bars/README.md new file mode 100644 index 0000000..884e59b --- /dev/null +++ b/cpu-bars/README.md @@ -0,0 +1,58 @@ +# CPU Bars + +Shows the load on every logical CPU core at a glance, as a row of thin vertical bars +that fill from the bottom. Each bar is one core, so you can see an unbalanced load or a +single pegged thread that an averaged CPU percentage would hide. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `ioandev/cpu-bars` | +| Entries | Bar widget: `cpu-bars` | + +## Requirements + +Noctalia with plugin API 5 or newer, for `noctalia.systemStats()`. Reads per-core usage +from `/proc/stat`, so it is Linux-only. + +## Usage + +Add the **CPU Bars** widget to a bar section in Settings → Bar. One bar is drawn per +logical core, in `/proc/stat` order — `cpu0` is leftmost. Bar height is that core's share +of busy time over the last second, and a core at or above the warning threshold turns red. + +Hover the widget for a tooltip listing total CPU usage and a per-core breakdown. + +The widget updates once a second, aligned to the top of the second. On a vertical bar it +rotates automatically: cores stack downward and each bar fills from the left. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `bar_width` | `int` | `3` | Thickness of each core's bar, in pixels. | +| `bar_gap` | `int` | `2` | Space between bars, in pixels. | +| `bar_height` | `int` | `16` | Length of each bar in pixels — height on a horizontal bar, width on a vertical one. | +| `warn_threshold` | `int` | `90` | A core at or above this percentage is drawn in the warning colour. | +| `normal_color` | `color` | `primary` | Colour of a core below the threshold. Accepts a theme role or a hex value. | +| `warn_color` | `color` | `error` | Colour of a core at or above the threshold. | +| `track_color` | `color` | `on_surface` | Colour of the unfilled part of each bar, drawn at 15% opacity. | +| `show_glyph` | `bool` | `true` | Show a CPU icon to the left of the bars. | + +Colours accept either a theme role (`primary`, `error`, `tertiary`, …) or a hex literal +such as `#57ff57`. Theme roles follow your colour scheme, including light/dark switches. + +## Notes + +On a machine with many cores the widget is as wide as `cores × (bar_width + bar_gap)` — +about 120px for 24 cores at the defaults. Reduce `bar_width` and `bar_gap` to fit a +narrower bar. + +Bar height is strictly proportional to usage, with no minimum: an idle core shows an empty +track. Since each pixel is `100 / bar_height` percent, usage below half a pixel — about 3% +at the default 16px — rounds away to nothing. + +Per-core sampling is opt-in host-side: it runs only while a plugin is asking for it, and +costs one extra `/proc/stat` read per second. The plugin makes no network calls, spawns no +processes, and writes no files. diff --git a/cpu-bars/cpu_bars.luau b/cpu-bars/cpu_bars.luau new file mode 100644 index 0000000..58635ec --- /dev/null +++ b/cpu-bars/cpu_bars.luau @@ -0,0 +1,156 @@ +--!nonstrict +-- CPU Bars: one vertical bar per logical core, filling bottom-up, from +-- noctalia.systemStats().cpu.cores (plugin API 5). + +-- Never request a tick shorter than this while converging on the second boundary. +local MIN_TICK_MS: number = 500 +-- Aim a few ms past the boundary rather than exactly at it. Tick-to-tick scheduling jitter +-- is a handful of ms, so aiming at 0 lands half the updates just *before* the second and +-- reads the previous second's sample; this bias keeps every update on the correct side. +local TARGET_OFFSET_MS: number = 6 + +-- Whether a bar row has ever been drawn, so a later empty sample holds the last frame +-- rather than blanking the widget. +local hasRendered: boolean = false + +-- Tick alignment state. See scheduleNextTick(). +local lastOffset: number? = nil +local lastRequest: number? = nil +local lag: number = 0 + +local function cfg(key: string): any + return noctalia.getConfig(key) +end + +-- Aim update() at the top of each wall-clock second. +-- +-- Two things push us off it, and neither is directly observable: the host adds a fixed +-- per-widget phase to every timer restart (so plugins don't all wake at once), and the +-- script runs a moment after the timer fires. So instead of guessing, measure the round +-- trip -- how far the last requested interval actually moved us -- and subtract it. +-- Converges within a few ticks and re-converges after a missed update. +local function scheduleNextTick() + local offset = noctalia.nowMs() % 1000 + if lastOffset ~= nil and lastRequest ~= nil then + lag = ((offset - lastOffset) - lastRequest) % 1000 + end + + local request = (TARGET_OFFSET_MS - offset - lag) % 1000 + if request < MIN_TICK_MS then + request += 1000 + end + + lastOffset = offset + lastRequest = request + noctalia.setUpdateInterval(request) +end + +local function buildTooltip(total: number, cores: { number }): { { key: string, value: string } } + local rows = { { key = noctalia.tr("tooltip.total"), value = string.format("%.0f%%", total) } } + for i, pct in ipairs(cores) do + -- /proc/stat is 0-indexed, so core N is at Lua index N+1. + table.insert(rows, { key = string.format("cpu%d", i - 1), value = string.format("%.0f%%", pct) }) + end + return rows +end + +function update() + local stats = noctalia.systemStats() + if stats == nil then + barWidget.setVisible(false) + scheduleNextTick() + return + end + + local cores: { number } = stats.cpu.cores or {} + -- No sample yet: on the very first tick after enabling there is no previous reading to + -- diff against. Hold whatever is already drawn instead of hiding, so a momentary gap + -- never blanks the widget; only hide when nothing has ever been drawn. + if #cores == 0 then + if not hasRendered then + barWidget.setVisible(false) + end + scheduleNextTick() + return + end + hasRendered = true + barWidget.setVisible(true) + + local vertical: boolean = barWidget.isVertical() + local width: number = cfg("bar_width") + local length: number = cfg("bar_height") + local gap: number = cfg("bar_gap") + local threshold: number = cfg("warn_threshold") + local normalColor: string = cfg("normal_color") + local warnColor: string = cfg("warn_color") + local trackColor: string = cfg("track_color") + + local children = {} + if cfg("show_glyph") then + table.insert(children, ui.glyph({ name = "cpu", size = 14, color = "on_surface_variant" })) + end + + for i, pct in ipairs(cores) do + local hot: boolean = pct >= threshold + -- Height is strictly proportional, with no minimum: an idle core shows an empty + -- track, not a sliver. Anything under half a pixel rounds away. + local filled: number = math.floor((length * pct / 100) + 0.5) + local lit: boolean = filled > 0 + local fill: string = hot and warnColor or normalColor + -- The fill colour is part of the key: the host reconciler is retained-mode, and + -- keying by state guarantees the box is rebuilt when a core crosses the threshold. + local key: string = `core{i}-{hot and "hot" or "ok"}` + + if vertical then + -- On a vertical bar the widget grows downward, so each core is a horizontal + -- bar filling from the left. + table.insert( + children, + ui.row({ + key = key, + width = length, + height = width, + radius = 1, + fill = `{trackColor}/0.15`, + justify = "start", + }, { + -- Kept in the tree (hidden) rather than dropped, so the retained-mode + -- reconciler diffs a stable child list. Height never reaches 0. + ui.box({ + width = math.max(1, filled), + height = width, + radius = 1, + fill = fill, + visible = lit, + }), + }) + ) + else + table.insert( + children, + ui.column({ + key = key, + width = width, + height = length, + radius = 1, + fill = `{trackColor}/0.15`, + justify = "end", + }, { + ui.box({ + width = width, + height = math.max(1, filled), + radius = 1, + fill = fill, + visible = lit, + }), + }) + ) + end + end + + local container = vertical and ui.column or ui.row + barWidget.render(container({ gap = gap, align = "center" }, children)) + barWidget.setTooltip(buildTooltip(stats.cpu.total, cores)) + + scheduleNextTick() +end diff --git a/cpu-bars/plugin.toml b/cpu-bars/plugin.toml new file mode 100644 index 0000000..467f46c --- /dev/null +++ b/cpu-bars/plugin.toml @@ -0,0 +1,79 @@ +id = "ioandev/cpu-bars" +name = "CPU Bars" +version = "1.0.0" +plugin_api = 5 +author = "ioandev" +license = "MIT" +dependencies = [] +icon = "cpu" +description = "A bar widget showing per-core CPU usage as vertical bars, turning red when a core saturates." +deprecated = false +tags = ["system", "bar", "hardware", "indicator"] + +[[widget]] +id = "cpu-bars" +entry = "cpu_bars.luau" + + [[widget.setting]] + key = "bar_width" + type = "int" + label_key = "settings.bar_width.label" + description_key = "settings.bar_width.description" + default = 3 + min = 1 + max = 12 + + [[widget.setting]] + key = "bar_gap" + type = "int" + label_key = "settings.bar_gap.label" + description_key = "settings.bar_gap.description" + default = 2 + min = 0 + max = 8 + + [[widget.setting]] + key = "bar_height" + type = "int" + label_key = "settings.bar_height.label" + description_key = "settings.bar_height.description" + default = 16 + min = 6 + max = 40 + + [[widget.setting]] + key = "warn_threshold" + type = "int" + label_key = "settings.warn_threshold.label" + description_key = "settings.warn_threshold.description" + default = 90 + min = 50 + max = 100 + + [[widget.setting]] + key = "normal_color" + type = "color" + label_key = "settings.normal_color.label" + description_key = "settings.normal_color.description" + default = "primary" + + [[widget.setting]] + key = "warn_color" + type = "color" + label_key = "settings.warn_color.label" + description_key = "settings.warn_color.description" + default = "error" + + [[widget.setting]] + key = "track_color" + type = "color" + label_key = "settings.track_color.label" + description_key = "settings.track_color.description" + default = "on_surface" + + [[widget.setting]] + key = "show_glyph" + type = "bool" + label_key = "settings.show_glyph.label" + description_key = "settings.show_glyph.description" + default = true diff --git a/cpu-bars/thumbnail.webp b/cpu-bars/thumbnail.webp new file mode 100644 index 0000000..aad47f9 Binary files /dev/null and b/cpu-bars/thumbnail.webp differ diff --git a/cpu-bars/translations/en.json b/cpu-bars/translations/en.json new file mode 100644 index 0000000..5739b41 --- /dev/null +++ b/cpu-bars/translations/en.json @@ -0,0 +1,39 @@ +{ + "settings": { + "bar_width": { + "label": "Bar width", + "description": "Thickness of each core's bar, in pixels." + }, + "bar_gap": { + "label": "Bar gap", + "description": "Space between bars, in pixels." + }, + "bar_height": { + "label": "Bar length", + "description": "Length of each bar, in pixels. This is the height on a horizontal bar and the width on a vertical one." + }, + "warn_threshold": { + "label": "Warning threshold", + "description": "A core at or above this usage percentage is drawn in the warning colour." + }, + "normal_color": { + "label": "Bar colour", + "description": "Colour of a core below the warning threshold. Accepts a theme role such as primary, or a hex value." + }, + "warn_color": { + "label": "Warning colour", + "description": "Colour of a core at or above the warning threshold." + }, + "track_color": { + "label": "Track colour", + "description": "Colour of the unfilled part of each bar, drawn at 15% opacity." + }, + "show_glyph": { + "label": "Show icon", + "description": "Show a CPU icon to the left of the bars." + } + }, + "tooltip": { + "total": "Total" + } +}