candela-ctrl 0.1.0__py3-none-any.whl
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.
- candela.py +389 -0
- candela_ctrl-0.1.0.dist-info/METADATA +94 -0
- candela_ctrl-0.1.0.dist-info/RECORD +7 -0
- candela_ctrl-0.1.0.dist-info/WHEEL +5 -0
- candela_ctrl-0.1.0.dist-info/entry_points.txt +2 -0
- candela_ctrl-0.1.0.dist-info/licenses/LICENSE +21 -0
- candela_ctrl-0.1.0.dist-info/top_level.txt +1 -0
candela.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Candela - Manual brightness and color temperature control for Linux.
|
|
4
|
+
Requires: PyQt6, xrandr (xorg-xrandr), redshift
|
|
5
|
+
X11 only (not Wayland).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from PyQt6.QtWidgets import (
|
|
13
|
+
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
|
|
14
|
+
QSlider, QLabel, QComboBox, QGroupBox, QPushButton, QCheckBox,
|
|
15
|
+
QSystemTrayIcon, QMenu
|
|
16
|
+
)
|
|
17
|
+
from PyQt6.QtGui import QIcon
|
|
18
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
STYLESHEET = """
|
|
22
|
+
QWidget {
|
|
23
|
+
background-color: #1e1e2e;
|
|
24
|
+
color: #cdd6f4;
|
|
25
|
+
font-size: 13px;
|
|
26
|
+
font-family: 'Segoe UI', Ubuntu, sans-serif;
|
|
27
|
+
}
|
|
28
|
+
QGroupBox {
|
|
29
|
+
border: 1px solid #45475a;
|
|
30
|
+
border-radius: 6px;
|
|
31
|
+
margin-top: 10px;
|
|
32
|
+
padding: 14px 10px 10px 10px;
|
|
33
|
+
font-weight: bold;
|
|
34
|
+
color: #89b4fa;
|
|
35
|
+
}
|
|
36
|
+
QGroupBox::title {
|
|
37
|
+
subcontrol-origin: margin;
|
|
38
|
+
left: 10px;
|
|
39
|
+
padding: 0 5px;
|
|
40
|
+
}
|
|
41
|
+
QSlider::groove:horizontal {
|
|
42
|
+
height: 6px;
|
|
43
|
+
background: #45475a;
|
|
44
|
+
border-radius: 3px;
|
|
45
|
+
}
|
|
46
|
+
QSlider::handle:horizontal {
|
|
47
|
+
width: 20px;
|
|
48
|
+
height: 20px;
|
|
49
|
+
margin: -7px 0;
|
|
50
|
+
background: #89b4fa;
|
|
51
|
+
border-radius: 10px;
|
|
52
|
+
}
|
|
53
|
+
QSlider::sub-page:horizontal {
|
|
54
|
+
background: #89b4fa;
|
|
55
|
+
border-radius: 3px;
|
|
56
|
+
}
|
|
57
|
+
QComboBox {
|
|
58
|
+
background: #313244;
|
|
59
|
+
border: 1px solid #585b70;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
padding: 4px 10px;
|
|
62
|
+
min-width: 120px;
|
|
63
|
+
}
|
|
64
|
+
QComboBox::drop-down { border: none; }
|
|
65
|
+
QPushButton {
|
|
66
|
+
background: #313244;
|
|
67
|
+
border: 1px solid #585b70;
|
|
68
|
+
border-radius: 4px;
|
|
69
|
+
padding: 5px 14px;
|
|
70
|
+
color: #cdd6f4;
|
|
71
|
+
}
|
|
72
|
+
QPushButton:hover { background: #45475a; }
|
|
73
|
+
QLabel#hint { color: #585b70; font-size: 11px; }
|
|
74
|
+
QLabel#value { color: #a6e3a1; font-weight: bold; min-width: 62px; }
|
|
75
|
+
QCheckBox { color: #6c7086; font-size: 11px; }
|
|
76
|
+
QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #585b70; border-radius: 3px; background: #313244; }
|
|
77
|
+
QCheckBox::indicator:checked { background: #89b4fa; border-color: #89b4fa; }
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_monitors():
|
|
82
|
+
"""Return connected output names from xrandr."""
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run(
|
|
85
|
+
["xrandr", "--query"], capture_output=True, text=True, timeout=3
|
|
86
|
+
)
|
|
87
|
+
return [
|
|
88
|
+
line.split()[0]
|
|
89
|
+
for line in result.stdout.splitlines()
|
|
90
|
+
if " connected" in line
|
|
91
|
+
] or ["HDMI-1"]
|
|
92
|
+
except Exception:
|
|
93
|
+
return ["HDMI-1"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_redshift():
|
|
97
|
+
"""Return True if redshift is available on PATH."""
|
|
98
|
+
try:
|
|
99
|
+
subprocess.run(["redshift", "--help"], capture_output=True, timeout=2)
|
|
100
|
+
return True
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
CONFIG_PATH = Path.home() / ".config" / "candela" / "settings.json"
|
|
106
|
+
DEFAULTS = {"brightness": 100, "temp_k": 6500, "keep_on_close": False}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config():
|
|
110
|
+
try:
|
|
111
|
+
return {**DEFAULTS, **json.loads(CONFIG_PATH.read_text())}
|
|
112
|
+
except Exception:
|
|
113
|
+
return dict(DEFAULTS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save_config(brightness, temp_k, keep_on_close):
|
|
117
|
+
try:
|
|
118
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
CONFIG_PATH.write_text(json.dumps(
|
|
120
|
+
{"brightness": brightness, "temp_k": temp_k, "keep_on_close": keep_on_close},
|
|
121
|
+
indent=2
|
|
122
|
+
))
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MonitorControl(QWidget):
|
|
128
|
+
def __init__(self):
|
|
129
|
+
super().__init__()
|
|
130
|
+
self.monitors = get_monitors()
|
|
131
|
+
self.has_redshift = check_redshift()
|
|
132
|
+
self.config = load_config()
|
|
133
|
+
|
|
134
|
+
self.apply_timer = QTimer()
|
|
135
|
+
self.apply_timer.setSingleShot(True)
|
|
136
|
+
self.apply_timer.timeout.connect(self.apply_settings)
|
|
137
|
+
|
|
138
|
+
self._quitting = False
|
|
139
|
+
|
|
140
|
+
self.init_ui()
|
|
141
|
+
self.restore_config()
|
|
142
|
+
self.setup_tray()
|
|
143
|
+
|
|
144
|
+
if not self.has_redshift:
|
|
145
|
+
self.temp_group.setTitle("Color Temperature ⚠ (install redshift)")
|
|
146
|
+
self.temp_slider.setEnabled(False)
|
|
147
|
+
|
|
148
|
+
def init_ui(self):
|
|
149
|
+
self.setWindowTitle("Candela")
|
|
150
|
+
self.setMinimumWidth(400)
|
|
151
|
+
self.setMaximumWidth(520)
|
|
152
|
+
self.setStyleSheet(STYLESHEET)
|
|
153
|
+
|
|
154
|
+
root = QVBoxLayout(self)
|
|
155
|
+
root.setSpacing(10)
|
|
156
|
+
root.setContentsMargins(18, 18, 18, 18)
|
|
157
|
+
|
|
158
|
+
# --- Monitor selector ---
|
|
159
|
+
if len(self.monitors) > 1:
|
|
160
|
+
row = QHBoxLayout()
|
|
161
|
+
row.addWidget(QLabel("Monitor:"))
|
|
162
|
+
self.monitor_combo = QComboBox()
|
|
163
|
+
self.monitor_combo.addItems(self.monitors)
|
|
164
|
+
self.monitor_combo.currentIndexChanged.connect(self.schedule_apply)
|
|
165
|
+
row.addWidget(self.monitor_combo)
|
|
166
|
+
row.addStretch()
|
|
167
|
+
root.addLayout(row)
|
|
168
|
+
else:
|
|
169
|
+
self.monitor_combo = QComboBox()
|
|
170
|
+
self.monitor_combo.addItems(self.monitors)
|
|
171
|
+
|
|
172
|
+
# --- Brightness ---
|
|
173
|
+
bright_group = QGroupBox("Brightness")
|
|
174
|
+
bright_layout = QVBoxLayout(bright_group)
|
|
175
|
+
|
|
176
|
+
bright_row = QHBoxLayout()
|
|
177
|
+
self.brightness_slider = QSlider(Qt.Orientation.Horizontal)
|
|
178
|
+
self.brightness_slider.setRange(10, 100)
|
|
179
|
+
self.brightness_slider.setValue(100)
|
|
180
|
+
self.brightness_slider.valueChanged.connect(self.on_brightness_changed)
|
|
181
|
+
|
|
182
|
+
self.brightness_label = QLabel("100%")
|
|
183
|
+
self.brightness_label.setObjectName("value")
|
|
184
|
+
self.brightness_label.setAlignment(
|
|
185
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
bright_row.addWidget(self.brightness_slider)
|
|
189
|
+
bright_row.addWidget(self.brightness_label)
|
|
190
|
+
bright_layout.addLayout(bright_row)
|
|
191
|
+
|
|
192
|
+
bright_hints = QHBoxLayout()
|
|
193
|
+
dim = QLabel("Dim")
|
|
194
|
+
dim.setObjectName("hint")
|
|
195
|
+
full = QLabel("Full")
|
|
196
|
+
full.setObjectName("hint")
|
|
197
|
+
full.setAlignment(Qt.AlignmentFlag.AlignRight)
|
|
198
|
+
bright_hints.addWidget(dim)
|
|
199
|
+
bright_hints.addWidget(full)
|
|
200
|
+
bright_layout.addLayout(bright_hints)
|
|
201
|
+
|
|
202
|
+
root.addWidget(bright_group)
|
|
203
|
+
|
|
204
|
+
# --- Color Temperature ---
|
|
205
|
+
self.temp_group = QGroupBox("Color Temperature")
|
|
206
|
+
temp_layout = QVBoxLayout(self.temp_group)
|
|
207
|
+
|
|
208
|
+
temp_row = QHBoxLayout()
|
|
209
|
+
self.temp_slider = QSlider(Qt.Orientation.Horizontal)
|
|
210
|
+
self.temp_slider.setRange(2000, 6500)
|
|
211
|
+
self.temp_slider.setValue(6500)
|
|
212
|
+
self.temp_slider.valueChanged.connect(self.on_temp_changed)
|
|
213
|
+
|
|
214
|
+
self.temp_label = QLabel("6500 K")
|
|
215
|
+
self.temp_label.setObjectName("value")
|
|
216
|
+
self.temp_label.setAlignment(
|
|
217
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
temp_row.addWidget(self.temp_slider)
|
|
221
|
+
temp_row.addWidget(self.temp_label)
|
|
222
|
+
temp_layout.addLayout(temp_row)
|
|
223
|
+
|
|
224
|
+
temp_hints = QHBoxLayout()
|
|
225
|
+
warm = QLabel("Warm / Candlelight (2000K)")
|
|
226
|
+
warm.setObjectName("hint")
|
|
227
|
+
cool = QLabel("Daylight (6500K)")
|
|
228
|
+
cool.setObjectName("hint")
|
|
229
|
+
cool.setAlignment(Qt.AlignmentFlag.AlignRight)
|
|
230
|
+
temp_hints.addWidget(warm)
|
|
231
|
+
temp_hints.addWidget(cool)
|
|
232
|
+
temp_layout.addLayout(temp_hints)
|
|
233
|
+
|
|
234
|
+
root.addWidget(self.temp_group)
|
|
235
|
+
|
|
236
|
+
# --- Bottom row: keep-on-close checkbox + reset button ---
|
|
237
|
+
bottom_row = QHBoxLayout()
|
|
238
|
+
|
|
239
|
+
self.keep_checkbox = QCheckBox("Keep settings on close")
|
|
240
|
+
self.keep_checkbox.setChecked(False)
|
|
241
|
+
self.keep_checkbox.setToolTip(
|
|
242
|
+
"When unchecked, closing the window resets brightness and color temperature to defaults."
|
|
243
|
+
)
|
|
244
|
+
bottom_row.addWidget(self.keep_checkbox)
|
|
245
|
+
|
|
246
|
+
bottom_row.addStretch()
|
|
247
|
+
|
|
248
|
+
reset_btn = QPushButton("Reset to Defaults")
|
|
249
|
+
reset_btn.clicked.connect(self.reset_defaults)
|
|
250
|
+
bottom_row.addWidget(reset_btn)
|
|
251
|
+
|
|
252
|
+
root.addLayout(bottom_row)
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Slider callbacks
|
|
256
|
+
|
|
257
|
+
def on_brightness_changed(self, value):
|
|
258
|
+
self.brightness_label.setText(f"{value}%")
|
|
259
|
+
self.schedule_apply()
|
|
260
|
+
|
|
261
|
+
def on_temp_changed(self, value):
|
|
262
|
+
snapped = round(value / 100) * 100
|
|
263
|
+
self.temp_label.setText(f"{snapped} K")
|
|
264
|
+
self.schedule_apply()
|
|
265
|
+
|
|
266
|
+
def schedule_apply(self):
|
|
267
|
+
self.apply_timer.start(180)
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Apply
|
|
271
|
+
|
|
272
|
+
def apply_settings(self):
|
|
273
|
+
monitor = self.monitor_combo.currentText()
|
|
274
|
+
brightness = self.brightness_slider.value() / 100.0
|
|
275
|
+
temp_k = round(self.temp_slider.value() / 100) * 100
|
|
276
|
+
|
|
277
|
+
save_config(self.brightness_slider.value(), temp_k, self.keep_checkbox.isChecked())
|
|
278
|
+
|
|
279
|
+
if self.has_redshift:
|
|
280
|
+
# Redshift handles both — passing both in one call prevents
|
|
281
|
+
# them from clobbering each other's gamma ramp changes.
|
|
282
|
+
subprocess.Popen(
|
|
283
|
+
["redshift", "-O", str(temp_k), "-b", f"{brightness:.2f}", "-P"],
|
|
284
|
+
stderr=subprocess.DEVNULL,
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
# Fallback: xrandr brightness only, no color temp
|
|
288
|
+
subprocess.Popen(
|
|
289
|
+
["xrandr", "--output", monitor, "--brightness", f"{brightness:.2f}"],
|
|
290
|
+
stderr=subprocess.DEVNULL,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def restore_config(self):
|
|
294
|
+
"""Set sliders to last saved values without triggering redundant apply."""
|
|
295
|
+
self.brightness_slider.blockSignals(True)
|
|
296
|
+
self.temp_slider.blockSignals(True)
|
|
297
|
+
self.brightness_slider.setValue(self.config["brightness"])
|
|
298
|
+
self.temp_slider.setValue(self.config["temp_k"])
|
|
299
|
+
self.brightness_label.setText(f"{self.config['brightness']}%")
|
|
300
|
+
self.temp_label.setText(f"{self.config['temp_k']} K")
|
|
301
|
+
self.keep_checkbox.setChecked(self.config["keep_on_close"])
|
|
302
|
+
self.brightness_slider.blockSignals(False)
|
|
303
|
+
self.temp_slider.blockSignals(False)
|
|
304
|
+
|
|
305
|
+
def setup_tray(self):
|
|
306
|
+
icon = QIcon.fromTheme("display-brightness",
|
|
307
|
+
QIcon.fromTheme("video-display"))
|
|
308
|
+
self.tray = QSystemTrayIcon(icon, self)
|
|
309
|
+
self.tray.setToolTip("Candela")
|
|
310
|
+
|
|
311
|
+
menu = QMenu()
|
|
312
|
+
show_action = menu.addAction("Show Candela")
|
|
313
|
+
show_action.triggered.connect(self.show_window)
|
|
314
|
+
menu.addSeparator()
|
|
315
|
+
quit_action = menu.addAction("Quit")
|
|
316
|
+
quit_action.triggered.connect(self.quit_app)
|
|
317
|
+
|
|
318
|
+
self.tray.setContextMenu(menu)
|
|
319
|
+
self.tray.activated.connect(self.on_tray_activated)
|
|
320
|
+
self.tray.show()
|
|
321
|
+
|
|
322
|
+
def show_window(self):
|
|
323
|
+
self.show()
|
|
324
|
+
self.raise_()
|
|
325
|
+
self.activateWindow()
|
|
326
|
+
|
|
327
|
+
def on_tray_activated(self, reason):
|
|
328
|
+
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
|
329
|
+
if self.isVisible():
|
|
330
|
+
self.hide()
|
|
331
|
+
else:
|
|
332
|
+
self.show_window()
|
|
333
|
+
|
|
334
|
+
def quit_app(self):
|
|
335
|
+
self._quitting = True
|
|
336
|
+
if not self.keep_checkbox.isChecked():
|
|
337
|
+
if self.has_redshift:
|
|
338
|
+
subprocess.run(
|
|
339
|
+
["redshift", "-O", "6500", "-b", "1.00", "-P"],
|
|
340
|
+
stderr=subprocess.DEVNULL,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
monitor = self.monitor_combo.currentText()
|
|
344
|
+
subprocess.run(
|
|
345
|
+
["xrandr", "--output", monitor, "--brightness", "1.0"],
|
|
346
|
+
stderr=subprocess.DEVNULL,
|
|
347
|
+
)
|
|
348
|
+
save_config(100, 6500, self.keep_checkbox.isChecked())
|
|
349
|
+
self.tray.hide()
|
|
350
|
+
QApplication.instance().quit()
|
|
351
|
+
|
|
352
|
+
def reset_defaults(self):
|
|
353
|
+
self.brightness_slider.setValue(100)
|
|
354
|
+
self.temp_slider.setValue(6500)
|
|
355
|
+
# reset_defaults triggers apply_settings via the slider signals
|
|
356
|
+
|
|
357
|
+
def closeEvent(self, event):
|
|
358
|
+
if not self._quitting and self.tray.isSystemTrayAvailable():
|
|
359
|
+
self.hide()
|
|
360
|
+
event.ignore()
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
if not self.keep_checkbox.isChecked():
|
|
364
|
+
# Bypass the debounce timer — call subprocess.run (blocking)
|
|
365
|
+
# directly so the reset completes before the process exits.
|
|
366
|
+
if self.has_redshift:
|
|
367
|
+
subprocess.run(
|
|
368
|
+
["redshift", "-O", "6500", "-b", "1.00", "-P"],
|
|
369
|
+
stderr=subprocess.DEVNULL,
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
monitor = self.monitor_combo.currentText()
|
|
373
|
+
subprocess.run(
|
|
374
|
+
["xrandr", "--output", monitor, "--brightness", "1.0"],
|
|
375
|
+
stderr=subprocess.DEVNULL,
|
|
376
|
+
)
|
|
377
|
+
save_config(100, 6500, self.keep_checkbox.isChecked())
|
|
378
|
+
event.accept()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main():
|
|
382
|
+
app = QApplication(sys.argv)
|
|
383
|
+
window = MonitorControl()
|
|
384
|
+
window.show()
|
|
385
|
+
sys.exit(app.exec())
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: candela-ctrl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manual brightness and color temperature control for Linux
|
|
5
|
+
Author: Jon Blanchard
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jfblanchard/candela
|
|
8
|
+
Project-URL: Repository, https://github.com/jfblanchard/candela
|
|
9
|
+
Keywords: brightness,monitor,color-temperature,linux,night-mode,redshift,display
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Desktop Environment
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: PyQt6
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Candela
|
|
24
|
+
|
|
25
|
+
> Manual brightness and color temperature control for Linux — named after the SI unit of luminous intensity.
|
|
26
|
+
|
|
27
|
+
A clean, dark-themed desktop utility for tuning your monitor at night (or any time). Two sliders: one for brightness, one for color temperature in Kelvin. No automatic scheduling, no daemons — just instant manual control when you want it.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## Why Candela?
|
|
32
|
+
|
|
33
|
+
Most Linux tools in this space are either time-based and automatic (Redshift, Gammastep), command-line only (`xrandr`, `sct`), or have dated UIs that expose raw R/G/B gamma channels instead of a human-friendly Kelvin temperature slider. Candela aims to be the tool you reach for when you just want to quickly dim your screen and warm the color for late-night use.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Brightness slider** — 10% to 100%
|
|
38
|
+
- **Color temperature slider** — 2000K (warm candlelight) to 6500K (daylight)
|
|
39
|
+
- **Multi-monitor support** — dropdown auto-populated from connected outputs
|
|
40
|
+
- **Instant apply** — changes take effect as you move the slider
|
|
41
|
+
- **Settings persist** after closing, within your X session (until logout/reboot)
|
|
42
|
+
- **Reset to Defaults** button restores 6500K / 100% brightness
|
|
43
|
+
- Dark UI — easy on the eyes when you need it most
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
**Python:** 3.10+
|
|
48
|
+
|
|
49
|
+
**Python packages:**
|
|
50
|
+
```bash
|
|
51
|
+
pip install PyQt6
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**System packages:**
|
|
55
|
+
```bash
|
|
56
|
+
sudo apt install redshift # Debian / Ubuntu / Mint
|
|
57
|
+
sudo dnf install redshift # Fedora
|
|
58
|
+
sudo pacman -S redshift # Arch / Manjaro
|
|
59
|
+
```
|
|
60
|
+
`redshift` handles color temperature. Brightness works without it (software gamma via `xrandr`).
|
|
61
|
+
|
|
62
|
+
> **Note:** X11 only. Wayland is not currently supported.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/jfblanchard/candela.git
|
|
68
|
+
cd candela
|
|
69
|
+
pip install PyQt6
|
|
70
|
+
python candela.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install via pip *(coming soon)*
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install candela-ctrl
|
|
77
|
+
candela
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Build a standalone binary
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install pyinstaller
|
|
84
|
+
pyinstaller --onefile --windowed --name candela candela.py
|
|
85
|
+
# Produces: dist/candela — single executable, no Python required
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Contributing
|
|
89
|
+
|
|
90
|
+
Issues and PRs welcome. If your monitor supports DDC/CI, hardware brightness control (via `ddcutil`) is the next planned feature — real backlight reduction instead of software gamma.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
candela.py,sha256=tNtnXp54fkHk_nt5s38QvJDrDiXrT44R5BQ3JRRBXSw,12510
|
|
2
|
+
candela_ctrl-0.1.0.dist-info/licenses/LICENSE,sha256=NXJ3UWQRdkxhy69KCoPT3ZZTC597eKvobgSQgF8x9yk,1070
|
|
3
|
+
candela_ctrl-0.1.0.dist-info/METADATA,sha256=a4BBttNKIytHDDjJFqjXFS9BxmeaO-_PycSpKi_k_Ww,3135
|
|
4
|
+
candela_ctrl-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
candela_ctrl-0.1.0.dist-info/entry_points.txt,sha256=zZeI_D5v-NHoQlfiosjZsLemTaktA34wFdW4c-0zNks,41
|
|
6
|
+
candela_ctrl-0.1.0.dist-info/top_level.txt,sha256=vsSWH9E7ZPoWLNEBNs9z5XM9pyQX0D8m_ab2DeWMYTI,8
|
|
7
|
+
candela_ctrl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jon Blanchard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
candela
|