brightctrl 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # BrightCtrl
2
+
3
+ A lightweight DDC/CI monitor brightness controller for Linux — now a terminal UI built with [React](https://react.dev) / [Ink](https://github.com/vadimdemedes/ink) on [Bun](https://bun.sh).
4
+
5
+ ![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)
6
+
7
+ ## Features
8
+
9
+ - Auto-detects all DDC/CI-capable monitors
10
+ - Per-monitor brightness bars with live percentage
11
+ - Arrow key / vim (`h`/`l`) brightness adjustment (5% steps)
12
+ - **Sync mode** (`s`) — control all monitors at once
13
+ - Direct input dialog (`i`) — type a specific brightness value
14
+ - 500ms debounce on DDC writes (no flooding the i2c bus)
15
+ - Brightness-dependent color coding (red ≤20%, cyan 21-89%, yellow ≥90%)
16
+
17
+ ## Requirements
18
+
19
+ ### System
20
+
21
+ | Distro | Command |
22
+ |---|---|
23
+ | Arch / Manjaro | `sudo pacman -S ddcutil` |
24
+ | Debian / Ubuntu | `sudo apt install ddcutil` |
25
+ | Fedora / RHEL | `sudo dnf install ddcutil` |
26
+
27
+ Then set up the i2c kernel module and add yourself to the i2c group:
28
+
29
+ ```bash
30
+ sudo usermod -aG i2c $USER
31
+ sudo modprobe i2c-dev
32
+
33
+ # Persist across reboots:
34
+ echo 'i2c-dev' | sudo tee /etc/modules-load.d/i2c.conf
35
+
36
+ # Log out and back in for the group change to take effect.
37
+ ```
38
+
39
+ Verify everything works:
40
+
41
+ ```bash
42
+ ddcutil detect
43
+ ```
44
+
45
+ ### Runtime
46
+
47
+ - [Node.js](https://nodejs.org) >= 18
48
+ - Or [Bun](https://bun.sh) 1.x (for development)
49
+
50
+ ## Install & Run
51
+
52
+ ### Quick — no install needed
53
+
54
+ ```bash
55
+ npx brightctrl
56
+ ```
57
+
58
+ ### Global install
59
+
60
+ ```bash
61
+ npm install -g brightctrl
62
+ brightctrl
63
+ ```
64
+
65
+ ### From source
66
+
67
+ ```bash
68
+ git clone https://github.com/shahriyardx/brightctrl.git
69
+ cd brightctrl
70
+ bun install
71
+ bun src/index.tsx
72
+ ```
73
+
74
+ ### Controls
75
+
76
+ | Key | Action |
77
+ |---|---|
78
+ | `↑` / `↓` | Select monitor |
79
+ | `←` / `h` | Decrease brightness 5% |
80
+ | `→` / `l` | Increase brightness 5% |
81
+ | `i` | Open input dialog (type 0–100, Enter to confirm) |
82
+ | `s` | Toggle sync mode (all monitors) |
83
+ | `r` | Refresh monitor detection |
84
+ | `q` | Quit |
85
+
package/dist/index.js ADDED
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // src/index.tsx
5
+ import { render } from "ink";
6
+
7
+ // src/app.tsx
8
+ import { Box, Text, useApp, useInput } from "ink";
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+
11
+ // src/ddcutil.ts
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ var execFileAsync = promisify(execFile);
15
+ function ddcutil(args, timeout = 8000) {
16
+ return execFileAsync("ddcutil", args, {
17
+ timeout,
18
+ encoding: "utf-8"
19
+ }).then((r) => r.stdout);
20
+ }
21
+ async function checkDdcutil() {
22
+ try {
23
+ await execFileAsync("which", ["ddcutil"]);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function detectMonitors() {
30
+ const stdout = await ddcutil(["detect", "--brief"], 15000);
31
+ const monitors = [];
32
+ let current = null;
33
+ for (const line of stdout.split(`
34
+ `)) {
35
+ const t = line.trim();
36
+ if (t.startsWith("Display ")) {
37
+ if (current?.index != null)
38
+ monitors.push(current);
39
+ const m = t.match(/Display\s+(\d+)/);
40
+ current = {
41
+ index: m ? Number.parseInt(m[1], 10) : monitors.length + 1,
42
+ name: "Unknown Monitor",
43
+ bus: ""
44
+ };
45
+ } else if (t.includes("I2C bus:")) {
46
+ if (current)
47
+ current.bus = t.replace("I2C bus:", "").trim();
48
+ } else if (t.startsWith("Monitor:")) {
49
+ if (current) {
50
+ const p = t.replace("Monitor:", "").trim();
51
+ current.name = p.replace(/\s+unspecified/i, "").trim() || "Monitor";
52
+ }
53
+ }
54
+ }
55
+ if (current?.index != null)
56
+ monitors.push(current);
57
+ return monitors;
58
+ }
59
+ async function getBrightness(displayIndex) {
60
+ try {
61
+ const stdout = await ddcutil(["getvcp", "10", `--display=${displayIndex}`], 5000);
62
+ const m = stdout.match(/current value\s*=\s*(\d+)/);
63
+ return m ? Number.parseInt(m[1], 10) : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ async function setBrightness(displayIndex, value) {
69
+ try {
70
+ await ddcutil(["setvcp", "10", String(Math.round(value)), `--display=${displayIndex}`], 5000);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ // src/app.tsx
78
+ import { jsxDEV } from "react/jsx-dev-runtime";
79
+ function BrightnessBar({
80
+ value,
81
+ width = 25
82
+ }) {
83
+ const filled = Math.round(value / 100 * width);
84
+ const empty = width - filled;
85
+ const color = value <= 20 ? "red" : value >= 90 ? "yellow" : "cyan";
86
+ return /* @__PURE__ */ jsxDEV(Box, {
87
+ children: [
88
+ /* @__PURE__ */ jsxDEV(Text, {
89
+ color,
90
+ children: filled > 0 ? "█".repeat(filled) : ""
91
+ }, undefined, false, undefined, this),
92
+ /* @__PURE__ */ jsxDEV(Text, {
93
+ color: "#444",
94
+ children: empty > 0 ? "░".repeat(empty) : ""
95
+ }, undefined, false, undefined, this),
96
+ /* @__PURE__ */ jsxDEV(Text, {
97
+ children: " "
98
+ }, undefined, false, undefined, this),
99
+ /* @__PURE__ */ jsxDEV(Text, {
100
+ color,
101
+ bold: true,
102
+ children: [
103
+ Math.round(value),
104
+ "%"
105
+ ]
106
+ }, undefined, true, undefined, this)
107
+ ]
108
+ }, undefined, true, undefined, this);
109
+ }
110
+ function MonitorCard({
111
+ monitor,
112
+ selected
113
+ }) {
114
+ return /* @__PURE__ */ jsxDEV(Box, {
115
+ borderStyle: selected ? "round" : "single",
116
+ borderColor: selected ? "cyan" : "gray",
117
+ flexDirection: "column",
118
+ paddingX: 1,
119
+ paddingY: 0,
120
+ children: [
121
+ /* @__PURE__ */ jsxDEV(Box, {
122
+ gap: 1,
123
+ children: [
124
+ /* @__PURE__ */ jsxDEV(Text, {
125
+ bold: true,
126
+ color: selected ? "cyan" : "white",
127
+ children: [
128
+ "Display ",
129
+ monitor.index
130
+ ]
131
+ }, undefined, true, undefined, this),
132
+ /* @__PURE__ */ jsxDEV(Text, {
133
+ color: "white",
134
+ children: monitor.name
135
+ }, undefined, false, undefined, this),
136
+ /* @__PURE__ */ jsxDEV(Text, {
137
+ color: "gray",
138
+ children: monitor.bus
139
+ }, undefined, false, undefined, this),
140
+ selected ? /* @__PURE__ */ jsxDEV(Text, {
141
+ color: "cyan",
142
+ children: "◄"
143
+ }, undefined, false, undefined, this) : null
144
+ ]
145
+ }, undefined, true, undefined, this),
146
+ /* @__PURE__ */ jsxDEV(Box, {
147
+ children: /* @__PURE__ */ jsxDEV(BrightnessBar, {
148
+ value: monitor.brightness
149
+ }, undefined, false, undefined, this)
150
+ }, undefined, false, undefined, this)
151
+ ]
152
+ }, undefined, true, undefined, this);
153
+ }
154
+ function ErrorPanel() {
155
+ return /* @__PURE__ */ jsxDEV(Box, {
156
+ flexDirection: "column",
157
+ marginTop: 1,
158
+ children: [
159
+ /* @__PURE__ */ jsxDEV(Text, {
160
+ color: "yellow",
161
+ children: "Troubleshooting:"
162
+ }, undefined, false, undefined, this),
163
+ /* @__PURE__ */ jsxDEV(Text, {
164
+ color: "gray",
165
+ children: " sudo pacman -S ddcutil"
166
+ }, undefined, false, undefined, this),
167
+ /* @__PURE__ */ jsxDEV(Text, {
168
+ color: "gray",
169
+ children: " sudo usermod -aG i2c $USER"
170
+ }, undefined, false, undefined, this),
171
+ /* @__PURE__ */ jsxDEV(Text, {
172
+ color: "gray",
173
+ children: " sudo modprobe i2c-dev"
174
+ }, undefined, false, undefined, this),
175
+ /* @__PURE__ */ jsxDEV(Text, {
176
+ color: "gray",
177
+ children: "echo 'i2c-dev' | sudo tee /etc/modules-load.d/i2c.conf"
178
+ }, undefined, false, undefined, this),
179
+ /* @__PURE__ */ jsxDEV(Text, {
180
+ color: "gray",
181
+ children: " (log out and in for group change)"
182
+ }, undefined, false, undefined, this)
183
+ ]
184
+ }, undefined, true, undefined, this);
185
+ }
186
+ function App() {
187
+ const { exit } = useApp();
188
+ const [monitors, setMonitors] = useState([]);
189
+ const [selected, setSelected] = useState(0);
190
+ const [syncMode, setSyncMode] = useState(false);
191
+ const [status, setStatus] = useState("Starting...");
192
+ const [error, setError] = useState(null);
193
+ const [loading, setLoading] = useState(true);
194
+ const [typing, setTyping] = useState(false);
195
+ const [typed, setTyped] = useState("");
196
+ const debounceTimer = useRef(null);
197
+ const pendingRef = useRef(new Map);
198
+ function scheduleBrightness(index, value) {
199
+ pendingRef.current.set(index, value);
200
+ if (debounceTimer.current)
201
+ clearTimeout(debounceTimer.current);
202
+ debounceTimer.current = setTimeout(() => {
203
+ const vals = new Map(pendingRef.current);
204
+ pendingRef.current.clear();
205
+ for (const [idx, v] of vals) {
206
+ setBrightness(idx, v);
207
+ }
208
+ }, 500);
209
+ }
210
+ const load = useCallback(async () => {
211
+ setLoading(true);
212
+ setError(null);
213
+ setStatus("Checking ddcutil...");
214
+ const hasDdcutil = await checkDdcutil();
215
+ if (!hasDdcutil) {
216
+ setError("ddcutil not found. Install: sudo pacman -S ddcutil");
217
+ setStatus("ddcutil missing");
218
+ setLoading(false);
219
+ return;
220
+ }
221
+ setStatus("Detecting monitors...");
222
+ const infoList = await detectMonitors();
223
+ if (infoList.length === 0) {
224
+ setError("No DDC/CI monitors detected");
225
+ setStatus("No monitors found");
226
+ setLoading(false);
227
+ return;
228
+ }
229
+ setStatus(`Reading brightness for ${infoList.length} monitor(s)...`);
230
+ const loaded = [];
231
+ for (const info of infoList) {
232
+ const b = await getBrightness(info.index);
233
+ loaded.push({ ...info, brightness: b ?? 50 });
234
+ }
235
+ setMonitors(loaded);
236
+ setSelected((s) => Math.min(s, loaded.length - 1));
237
+ setStatus(`${loaded.length} monitor(s) — DDC/CI via ddcutil`);
238
+ setLoading(false);
239
+ }, []);
240
+ useEffect(() => {
241
+ load();
242
+ }, [load]);
243
+ function adjustBrightness(delta) {
244
+ if (monitors.length === 0)
245
+ return;
246
+ const idx = selected;
247
+ setMonitors((prev) => prev.map((m, i) => {
248
+ if (!syncMode && i !== idx)
249
+ return m;
250
+ const v = Math.max(0, Math.min(100, m.brightness + delta));
251
+ scheduleBrightness(m.index, v);
252
+ return { ...m, brightness: v };
253
+ }));
254
+ }
255
+ useInput((input, key) => {
256
+ if (typing) {
257
+ if (key.escape) {
258
+ setTyping(false);
259
+ setTyped("");
260
+ } else if (key.return) {
261
+ const v = Number.parseInt(typed, 10);
262
+ if (!Number.isNaN(v) && v >= 0 && v <= 100) {
263
+ setMonitors((prev) => prev.map((m, i) => {
264
+ if (!syncMode && i !== selected)
265
+ return m;
266
+ scheduleBrightness(m.index, v);
267
+ return { ...m, brightness: v };
268
+ }));
269
+ }
270
+ setTyping(false);
271
+ setTyped("");
272
+ } else if (key.backspace) {
273
+ setTyped((p) => p.slice(0, -1));
274
+ } else if (/^[0-9]$/.test(input)) {
275
+ setTyped((p) => (p + input).slice(0, 3));
276
+ }
277
+ return;
278
+ }
279
+ if (input === "q")
280
+ exit();
281
+ if (key.upArrow) {
282
+ setSelected((s) => Math.max(0, s - 1));
283
+ } else if (key.downArrow) {
284
+ setSelected((s) => Math.min(monitors.length - 1, s + 1));
285
+ } else if (input === "h" || key.leftArrow) {
286
+ adjustBrightness(-5);
287
+ } else if (input === "l" || key.rightArrow) {
288
+ adjustBrightness(5);
289
+ } else if (input === "s") {
290
+ setSyncMode((s) => !s);
291
+ } else if (input === "r") {
292
+ load();
293
+ } else if (input === "i") {
294
+ setTyping(true);
295
+ setTyped("");
296
+ }
297
+ });
298
+ return /* @__PURE__ */ jsxDEV(Box, {
299
+ flexDirection: "column",
300
+ padding: 1,
301
+ children: [
302
+ /* @__PURE__ */ jsxDEV(Box, {
303
+ gap: 2,
304
+ children: [
305
+ /* @__PURE__ */ jsxDEV(Text, {
306
+ bold: true,
307
+ color: "cyan",
308
+ children: "BrightCtrl"
309
+ }, undefined, false, undefined, this),
310
+ /* @__PURE__ */ jsxDEV(Text, {
311
+ color: "gray",
312
+ children: "DDC/CI Brightness Controller"
313
+ }, undefined, false, undefined, this)
314
+ ]
315
+ }, undefined, true, undefined, this),
316
+ /* @__PURE__ */ jsxDEV(Text, {
317
+ color: "#333",
318
+ children: "─".repeat(56)
319
+ }, undefined, false, undefined, this),
320
+ /* @__PURE__ */ jsxDEV(Box, {
321
+ marginBottom: 1,
322
+ gap: 3,
323
+ children: [
324
+ /* @__PURE__ */ jsxDEV(Text, {
325
+ color: syncMode ? "green" : "gray",
326
+ children: [
327
+ "[",
328
+ syncMode ? "✓" : " ",
329
+ "] Sync All ",
330
+ /* @__PURE__ */ jsxDEV(Text, {
331
+ color: "gray",
332
+ children: "(s)"
333
+ }, undefined, false, undefined, this)
334
+ ]
335
+ }, undefined, true, undefined, this),
336
+ /* @__PURE__ */ jsxDEV(Text, {
337
+ children: [
338
+ "⟳ Refresh ",
339
+ /* @__PURE__ */ jsxDEV(Text, {
340
+ color: "gray",
341
+ children: "(r)"
342
+ }, undefined, false, undefined, this)
343
+ ]
344
+ }, undefined, true, undefined, this),
345
+ /* @__PURE__ */ jsxDEV(Text, {
346
+ children: [
347
+ "✕ Quit ",
348
+ /* @__PURE__ */ jsxDEV(Text, {
349
+ color: "gray",
350
+ children: "(q)"
351
+ }, undefined, false, undefined, this)
352
+ ]
353
+ }, undefined, true, undefined, this)
354
+ ]
355
+ }, undefined, true, undefined, this),
356
+ /* @__PURE__ */ jsxDEV(Box, {
357
+ marginBottom: 1,
358
+ children: loading ? /* @__PURE__ */ jsxDEV(Text, {
359
+ color: "gray",
360
+ children: [
361
+ status,
362
+ "..."
363
+ ]
364
+ }, undefined, true, undefined, this) : error ? /* @__PURE__ */ jsxDEV(Text, {
365
+ color: "red",
366
+ children: error
367
+ }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV(Text, {
368
+ color: "gray",
369
+ children: status
370
+ }, undefined, false, undefined, this)
371
+ }, undefined, false, undefined, this),
372
+ !loading && !error && monitors.length > 0 ? /* @__PURE__ */ jsxDEV(Box, {
373
+ flexDirection: "column",
374
+ gap: 0,
375
+ children: monitors.map((m, i) => /* @__PURE__ */ jsxDEV(MonitorCard, {
376
+ monitor: m,
377
+ selected: i === selected
378
+ }, m.index, false, undefined, this))
379
+ }, undefined, false, undefined, this) : null,
380
+ !loading && error ? /* @__PURE__ */ jsxDEV(ErrorPanel, {}, undefined, false, undefined, this) : null,
381
+ typing ? /* @__PURE__ */ jsxDEV(Box, {
382
+ flexDirection: "column",
383
+ marginTop: 1,
384
+ marginBottom: 1,
385
+ children: /* @__PURE__ */ jsxDEV(Box, {
386
+ borderStyle: "round",
387
+ borderColor: "cyan",
388
+ paddingX: 1,
389
+ flexDirection: "column",
390
+ children: [
391
+ /* @__PURE__ */ jsxDEV(Box, {
392
+ gap: 1,
393
+ children: [
394
+ /* @__PURE__ */ jsxDEV(Text, {
395
+ bold: true,
396
+ color: "cyan",
397
+ children: "Brightness:"
398
+ }, undefined, false, undefined, this),
399
+ /* @__PURE__ */ jsxDEV(Text, {
400
+ bold: true,
401
+ color: "white",
402
+ children: [
403
+ typed || "0",
404
+ "_"
405
+ ]
406
+ }, undefined, true, undefined, this)
407
+ ]
408
+ }, undefined, true, undefined, this),
409
+ /* @__PURE__ */ jsxDEV(Text, {
410
+ color: "gray",
411
+ children: "Enter 0-100 ↵ confirm ⎋ cancel"
412
+ }, undefined, false, undefined, this)
413
+ ]
414
+ }, undefined, true, undefined, this)
415
+ }, undefined, false, undefined, this) : null,
416
+ !loading ? /* @__PURE__ */ jsxDEV(Box, {
417
+ marginTop: 1,
418
+ children: error ? null : /* @__PURE__ */ jsxDEV(Text, {
419
+ color: "gray",
420
+ children: "↑↓ select h/l ←→ brightness i input s sync r refresh q quit"
421
+ }, undefined, false, undefined, this)
422
+ }, undefined, false, undefined, this) : null
423
+ ]
424
+ }, undefined, true, undefined, this);
425
+ }
426
+
427
+ // src/index.tsx
428
+ import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
429
+ var { waitUntilExit } = render(/* @__PURE__ */ jsxDEV2(App, {}, undefined, false, undefined, this));
430
+ await waitUntilExit();
package/package.json CHANGED
@@ -1,11 +1,47 @@
1
1
  {
2
2
  "name": "brightctrl",
3
- "version": "0.0.0",
4
- "description": "Placeholder for brightctrl",
5
- "main": "index.js",
6
- "scripts": {},
7
- "keywords": [],
8
- "author": "shahriyardx (https://www.npmjs.com/~shahriyardx)",
9
- "license": "UNLICENSED",
10
- "private": false
11
- }
3
+ "version": "0.0.1",
4
+ "description": "Terminal UI for DDC/CI monitor brightness control",
5
+ "type": "module",
6
+ "bin": {
7
+ "brightctrl": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "build": "bun build src/index.tsx --outdir dist --target node --format esm --external ink --external react --external react-reconciler --external scheduler --external '@types/react' && sed -i 's_^#!/usr/bin/env bun_#!/usr/bin/env node_' dist/index.js",
18
+ "prepublishOnly": "bun run build",
19
+ "lint": "biome check src/",
20
+ "format": "biome check --write src/",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "dependencies": {
24
+ "ink": "^5.2.0",
25
+ "react": "^18.3.0"
26
+ },
27
+ "devDependencies": {
28
+ "@biomejs/biome": "^2.4.15",
29
+ "@types/react": "^18.3.0",
30
+ "typescript": "^5.0.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/shahriyardx/brightctrl.git"
35
+ },
36
+ "homepage": "https://github.com/shahriyardx/brightctrl",
37
+ "keywords": [
38
+ "brightness",
39
+ "ddc-ci",
40
+ "ddcutil",
41
+ "monitor",
42
+ "tui",
43
+ "ink",
44
+ "waybar"
45
+ ],
46
+ "license": "MIT"
47
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- // Placeholder