├── .gitignore ├── assets ├── icon-256.ico └── icon-256.png ├── screenshots ├── status.png ├── location.png └── brightness.png ├── installer ├── plugins │ └── nsProcess.dll ├── README.md ├── include │ └── nsProcess.nsh └── windows.nsi ├── src ├── lib.rs ├── event_watcher │ ├── mod.rs │ ├── linux.rs │ └── windows.rs ├── unique │ ├── mod.rs │ ├── windows.rs │ └── linux.rs ├── common.rs ├── gui │ ├── help.rs │ ├── status.rs │ ├── location_settings.rs │ ├── app.rs │ ├── brightness_settings.rs │ ├── monitor_overrides.rs │ └── mod.rs ├── cli.rs ├── tray.rs ├── config.rs ├── controller.rs ├── main.rs ├── apply.rs └── calculator.rs ├── Makefile ├── .github └── workflows │ └── rust.yml ├── README.md ├── Cargo.toml ├── linux ├── README.md └── ubuntu_install.sh └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/*.rs.bk 3 | .idea/ 4 | 5 | *.exe -------------------------------------------------------------------------------- /assets/icon-256.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/assets/icon-256.ico -------------------------------------------------------------------------------- /assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/assets/icon-256.png -------------------------------------------------------------------------------- /screenshots/status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/screenshots/status.png -------------------------------------------------------------------------------- /screenshots/location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/screenshots/location.png -------------------------------------------------------------------------------- /screenshots/brightness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/screenshots/brightness.png -------------------------------------------------------------------------------- /installer/plugins/nsProcess.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-pro/solar-screen-brightness/HEAD/installer/plugins/nsProcess.dll -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod apply; 2 | pub mod calculator; 3 | pub mod common; 4 | pub mod config; 5 | pub mod controller; 6 | pub mod event_watcher; 7 | pub mod gui; 8 | pub mod tray; 9 | pub mod unique; 10 | -------------------------------------------------------------------------------- /installer/README.md: -------------------------------------------------------------------------------- 1 | # Windows NSIS Installer 2 | 3 | Requires NsProcess plugin: 4 | - https://nsis.sourceforge.io/NsProcess_plugin 5 | - https://nsis.sourceforge.io/mediawiki/images/1/18/NsProcess.zip (The Unicode version nsProcessW.dll) 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | cargo fmt -- --check 4 | cargo-sort --check --workspace 5 | cargo clippy --all-features --workspace -- -D warnings 6 | cargo test --all-features --workspace 7 | 8 | .PHONY: format 9 | format: 10 | cargo fmt 11 | cargo-sort --workspace 12 | 13 | .PHONY: installer 14 | installer: 15 | makensis installer/windows.nsi 16 | -------------------------------------------------------------------------------- /src/event_watcher/mod.rs: -------------------------------------------------------------------------------- 1 | //! Detects system events such as: 2 | //! - Monitor connect/disconnect events 3 | //! - Messages from another process to show the main window 4 | 5 | cfg_if::cfg_if! { 6 | if #[cfg(target_os = "linux")] { 7 | pub mod linux; 8 | use self::linux as platform; 9 | } else if #[cfg(windows)] { 10 | pub mod windows; 11 | use self::windows as platform; 12 | } else { 13 | compile_error!("unsupported platform"); 14 | } 15 | } 16 | 17 | pub use platform::EventWatcher; 18 | -------------------------------------------------------------------------------- /installer/include/nsProcess.nsh: -------------------------------------------------------------------------------- 1 | !define nsProcess::FindProcess `!insertmacro nsProcess::FindProcess` 2 | 3 | !macro nsProcess::FindProcess _FILE _ERR 4 | nsProcess::_FindProcess /NOUNLOAD `${_FILE}` 5 | Pop ${_ERR} 6 | !macroend 7 | 8 | 9 | !define nsProcess::KillProcess `!insertmacro nsProcess::KillProcess` 10 | 11 | !macro nsProcess::KillProcess _FILE _ERR 12 | nsProcess::_KillProcess /NOUNLOAD `${_FILE}` 13 | Pop ${_ERR} 14 | !macroend 15 | 16 | !define nsProcess::CloseProcess `!insertmacro nsProcess::CloseProcess` 17 | 18 | !macro nsProcess::CloseProcess _FILE _ERR 19 | nsProcess::_CloseProcess /NOUNLOAD `${_FILE}` 20 | Pop ${_ERR} 21 | !macroend 22 | 23 | 24 | !define nsProcess::Unload `!insertmacro nsProcess::Unload` 25 | 26 | !macro nsProcess::Unload 27 | nsProcess::_Unload 28 | !macroend 29 | -------------------------------------------------------------------------------- /src/unique/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utility to ensure a single instance of SSB is running 2 | 3 | use std::fmt::Debug; 4 | use thiserror::Error; 5 | 6 | cfg_if::cfg_if! { 7 | if #[cfg(target_os = "linux")] { 8 | mod linux; 9 | use self::linux as platform; 10 | } else if #[cfg(windows)] { 11 | mod windows; 12 | use self::windows as platform; 13 | } else { 14 | compile_error!("unsupported platform"); 15 | } 16 | } 17 | 18 | pub use platform::ExistingInstance; 19 | pub use platform::SsbUniqueInstance; 20 | 21 | #[derive(Debug, Error)] 22 | pub enum Error { 23 | #[error("Unable to check if Solar Screen Brightness is already running: {0}")] 24 | PlatformError(#[source] Box), 25 | #[error("Solar Screen Brightness is already running")] 26 | AlreadyRunning(ExistingInstance), 27 | } 28 | -------------------------------------------------------------------------------- /src/unique/windows.rs: -------------------------------------------------------------------------------- 1 | use crate::unique::Error; 2 | use win32_utils::instance::UniqueInstance; 3 | use windows::Win32::UI::WindowsAndMessaging::{SendMessageW, HWND_BROADCAST}; 4 | 5 | const APP_ID: &str = "solar-screen-brightness"; 6 | 7 | #[allow(dead_code)] 8 | pub struct SsbUniqueInstance(UniqueInstance); 9 | 10 | impl SsbUniqueInstance { 11 | pub fn try_acquire() -> Result { 12 | match UniqueInstance::acquire_unique_to_session(APP_ID) { 13 | Ok(u) => Ok(SsbUniqueInstance(u)), 14 | Err(win32_utils::instance::Error::AlreadyExists) => { 15 | Err(Error::AlreadyRunning(ExistingInstance)) 16 | } 17 | Err(e) => Err(Error::PlatformError(Box::new(e))), 18 | } 19 | } 20 | } 21 | 22 | /// Represents an already running instance of Solar Screen Brightness 23 | #[derive(Debug)] 24 | pub struct ExistingInstance; 25 | 26 | impl ExistingInstance { 27 | /// Sends a broadcast message to the existing instance, telling it to maximise its window 28 | pub fn wakeup(&self) { 29 | let message = crate::event_watcher::windows::register_open_window_message(); 30 | unsafe { SendMessageW(HWND_BROADCAST, message, None, None) }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust Build 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | 10 | windows: 11 | runs-on: windows-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Cargo Test 17 | run: cargo test 18 | 19 | - name: Cargo Clippy 20 | run: cargo clippy -- -D warnings 21 | 22 | - name: Cargo Release Build 23 | run: cargo build --release 24 | 25 | - name: Create installer 26 | run: makensis installer/windows.nsi 27 | 28 | - if: startsWith(github.ref, 'refs/tags/') 29 | name: Publish Release Artifacts 30 | uses: softprops/action-gh-release@v1 31 | with: 32 | files: | 33 | ./target/release/ssb.exe 34 | ./target/release/ssb-cli.exe 35 | ./installer/ssb-installer.exe 36 | 37 | ubuntu: 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: Install Ubuntu dependencies 42 | run: sudo apt update && sudo apt install -y libudev-dev libgtk-3-dev libxdo-dev 43 | 44 | - uses: actions/checkout@v2 45 | 46 | - name: Cargo Test 47 | run: cargo test 48 | 49 | - name: Cargo Clippy 50 | run: cargo clippy -- -D warnings 51 | 52 | check_style: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v2 56 | 57 | - name: Cargo Format 58 | run: cargo fmt -- --check 59 | 60 | - name: Cargo Sort 61 | run: cargo install cargo-sort --debug && cargo-sort --check --workspace 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solar Screen Brightness 2 | 3 | [![Build status](https://github.com/jacob-pro/solar-screen-brightness/actions/workflows/rust.yml/badge.svg)](https://github.com/jacob-pro/solar-screen-brightness/actions) 4 | 5 | Automatically and smoothly adjusts monitor brightness based on the sunrise/sunset times at your location. 6 | 7 | > #### New 2.0 Release! 8 | > 9 | > I am pleased to announce a new 2.0 version with a modernised UI, new features, and bug fixes! 10 | 11 | ## About 12 | 13 | ### What is this for? 14 | 15 | Supports Windows and Linux computers. Recommended for desktop PCs where you don't have an ambient light sensor to 16 | automatically adjust screen brightness. 17 | 18 | ### How is this different to [f.lux](https://justgetflux.com/) or similar Night Mode programs? 19 | 20 | This changes the screen brightness via monitor control APIs, whereas those utilities vary the colour temperature. 21 | 22 | ### How to Install 23 | 24 | For Windows, you can download pre-compiled binaries from 25 | [Releases](https://github.com/jacob-pro/solar-screen-brightness/releases). 26 | 27 | If you are using Linux, please read the [Linux Guide](linux/README.md) 28 | 29 | There is also a CLI only version of the application available. 30 | 31 | ### How to Use 32 | 33 | 1. An icon will appear in your tray when it is running. 34 | 2. Click on the icon to open the settings menu. 35 | 3. Use the menus to set: 36 | - Daytime and Nighttime brightness percentages. 37 | - Transition time (the time it takes to switch between the two brightness values at either sunset or sunrise). 38 | - Your location (either manually enter coordinates, or using the search tool). 39 | 4. Click save and this configuration will be applied and persisted to disk. 40 | 5. You can close the window, and it will continue to update your brightness in the background. 41 | 42 | ## Screenshots 43 | 44 | ![](./screenshots/status.png) 45 | 46 | ![](./screenshots/brightness.png) 47 | 48 | ![](./screenshots/location.png) 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "solar-screen-brightness" 3 | version = "2.3.2" 4 | authors = ["Jacob Halsey "] 5 | edition = "2021" 6 | build = "build.rs" 7 | description = "Uitlity for automatically setting monitor brightness according to sunrise/sunset times." 8 | 9 | [[bin]] 10 | name = "ssb" 11 | path = "src/main.rs" 12 | 13 | [[bin]] 14 | name = "ssb-cli" 15 | path = "src/cli.rs" 16 | 17 | [dependencies] 18 | anyhow = "1.0.43" 19 | brightness = { version = "0.5.0", default-features = false } 20 | cfg-if = "1.0.0" 21 | chrono = "0.4" 22 | clap = { version = "4.3.11", features = ["derive"] } 23 | ctrlc = "3.4.0" 24 | dirs = "5.0.1" 25 | egui = { version = "0.22.0" } 26 | egui-wgpu = { version = "0.22.0", features = ["winit"] } 27 | egui-winit = { version = "0.22.0" } 28 | ellipse = "0.2.0" 29 | enum-iterator = "1.4.1" 30 | geocoding = "0.4.0" 31 | human-repr = "1.1.0" 32 | image = "0.24.7" 33 | itertools = "0.11.0" 34 | log = "0.4.14" 35 | num = "0.4.1" 36 | png = "0.17.10" 37 | pollster = "0.3.0" 38 | serde = { version = "1.0.110", features = ["derive"] } 39 | serde_json = "1.0.104" 40 | simplelog = "0.12.1" 41 | sunrise-sunset-calculator = "1.0.1" 42 | tempfile = "3.7.0" 43 | thiserror = "1.0" 44 | tray-icon = "0.11.1" 45 | validator = { version = "0.16.1", features = ["derive"] } 46 | wildmatch = "2.1.1" 47 | 48 | [target.'cfg(windows)'.dependencies] 49 | win32-utils = { git = "https://github.com/jacob-pro/win32-utils", features = ["window", "instance"], rev = "12cb15c0c2d249ff0de6e0249466dbff20448871" } 50 | console = "0.15.7" 51 | 52 | [target.'cfg(windows)'.dependencies.windows] 53 | version = "0.52.0" 54 | features = [ 55 | "Win32_Foundation", 56 | "Win32_UI_WindowsAndMessaging", 57 | "Win32_System_LibraryLoader", 58 | "Win32_Graphics_Gdi", 59 | "Win32_System_Console", 60 | "Win32_System_RemoteDesktop" 61 | ] 62 | 63 | [target.'cfg(unix)'.dependencies] 64 | nix = "0.22.1" 65 | 66 | [target.'cfg(target_os="linux")'.dependencies] 67 | udev = "0.7.0" 68 | gtk = "0.18" 69 | 70 | [build-dependencies] 71 | winres = "0.1" 72 | -------------------------------------------------------------------------------- /src/common.rs: -------------------------------------------------------------------------------- 1 | //! Common constants and helper functions used in both CLI and GUI applications 2 | 3 | use anyhow::Context; 4 | use log::LevelFilter; 5 | use simplelog::{ColorChoice, CombinedLogger, SharedLogger, TermLogger, TerminalMode, WriteLogger}; 6 | use std::fs; 7 | use std::fs::File; 8 | use std::path::PathBuf; 9 | 10 | pub const APP_NAME: &str = "Solar Screen Brightness"; 11 | 12 | pub const APP_DIRECTORY_NAME: &str = "solar-screen-brightness"; 13 | 14 | /// Path to the local application data folder 15 | /// This is where the SSB logs will be stored 16 | pub fn local_data_directory() -> PathBuf { 17 | let path = dirs::data_local_dir() 18 | .expect("Unable to get data_local_dir()") 19 | .join(APP_DIRECTORY_NAME); 20 | fs::create_dir_all(&path).expect("Unable to create data directory"); 21 | path 22 | } 23 | 24 | /// Path to the local application config folder 25 | /// This is where the SSB config will be stored 26 | pub fn config_directory() -> PathBuf { 27 | let path = dirs::config_local_dir() 28 | .expect("Unable to get data_local_dir()") 29 | .join(APP_DIRECTORY_NAME); 30 | fs::create_dir_all(&path).expect("Unable to create local config directory"); 31 | path 32 | } 33 | 34 | pub fn install_logger(debug: bool, to_disk: bool) -> anyhow::Result<()> { 35 | let filter = if debug { 36 | LevelFilter::Debug 37 | } else { 38 | LevelFilter::Info 39 | }; 40 | let config = simplelog::ConfigBuilder::default() 41 | .add_filter_ignore_str("wgpu") 42 | .add_filter_ignore_str("naga") 43 | .set_target_level(LevelFilter::Debug) 44 | .build(); 45 | let mut loggers: Vec> = vec![TermLogger::new( 46 | filter, 47 | config.clone(), 48 | TerminalMode::Stderr, 49 | ColorChoice::Auto, 50 | )]; 51 | if to_disk { 52 | let file = File::create(get_log_path()).context("Unable to create log file")?; 53 | let file_logger = WriteLogger::new(filter, config, file); 54 | loggers.push(file_logger); 55 | } 56 | CombinedLogger::init(loggers)?; 57 | if debug { 58 | log::warn!("Debug logging enabled"); 59 | } 60 | Ok(()) 61 | } 62 | 63 | pub fn get_log_path() -> PathBuf { 64 | local_data_directory().join("log.txt") 65 | } 66 | -------------------------------------------------------------------------------- /installer/windows.nsi: -------------------------------------------------------------------------------- 1 | Unicode True 2 | !addplugindir plugins 3 | !addincludedir include 4 | 5 | !include "MUI2.nsh" 6 | !include "nsProcess.nsh" 7 | 8 | RequestExecutionLevel user 9 | OutFile "ssb-installer.exe" 10 | Name "Solar Screen Brightness" 11 | InstallDir "$LocalAppdata\solar-screen-brightness" 12 | 13 | !define SHORT_CUT "$SMPROGRAMS\Solar Screen Brightness.lnk" 14 | !define SHORT_CUT_UNINSTALL "$SMPROGRAMS\Uninstall Solar Screen Brightness.lnk" 15 | !define SHORT_CUT_START_UP "$SMSTARTUP\Solar Screen Brightness (Minimised).lnk" 16 | 17 | !define MUI_ICON "..\assets\icon-256.ico" 18 | !define MUI_WELCOMEPAGE_TITLE "Solar Screen Brightness" 19 | !define MUI_WELCOMEPAGE_TEXT "Click Install to start the installation." 20 | !define MUI_FINISHPAGE_RUN "$INSTDIR\ssb.exe" 21 | 22 | !insertmacro MUI_PAGE_WELCOME 23 | !insertmacro MUI_PAGE_INSTFILES 24 | !insertmacro MUI_PAGE_FINISH 25 | 26 | !define MUI_WELCOMEPAGE_TITLE "Solar Screen Brightness" 27 | !define MUI_WELCOMEPAGE_TEXT "Click Uninstall to start the uninstallation." 28 | 29 | !insertmacro MUI_UNPAGE_WELCOME 30 | !insertmacro MUI_UNPAGE_INSTFILES 31 | 32 | !insertmacro MUI_LANGUAGE "English" 33 | 34 | Section 35 | SetOutPath "$INSTDIR" 36 | 37 | # Stop version 1 38 | ${nsProcess::KillProcess} "solar-screen-brightness.exe" $R0 39 | DetailPrint "Stopping solar-screen-brightness.exe code: $R0" 40 | # Stop version 2 41 | ${nsProcess::KillProcess} "ssb.exe" $R0 42 | DetailPrint "Stopping ssb.exe code: $R0" 43 | 44 | # Allow time for processes to stop 45 | Sleep 500 46 | 47 | # Delete version 1 48 | RMDir /r "$APPDATA\Solar Screen Brightness" 49 | 50 | File "..\target\release\ssb.exe" 51 | File "..\target\release\ssb-cli.exe" 52 | WriteUninstaller "$INSTDIR\uninstall.exe" 53 | CreateShortcut "${SHORT_CUT}" "$INSTDIR\ssb.exe" 54 | CreateShortcut "${SHORT_CUT_UNINSTALL}" "$INSTDIR\uninstall.exe" 55 | CreateShortcut "${SHORT_CUT_START_UP}" "$INSTDIR\ssb.exe" "--minimised" 56 | SectionEnd 57 | 58 | Section "uninstall" 59 | # Stop version 2 60 | ${nsProcess::KillProcess} "ssb.exe" $R0 61 | DetailPrint "Stopping ssb.exe code: $R0" 62 | 63 | # Allow time for processes to stop 64 | Sleep 500 65 | 66 | Delete "${SHORT_CUT}" 67 | Delete "${SHORT_CUT_UNINSTALL}" 68 | Delete "${SHORT_CUT_START_UP}" 69 | RMDir /r "$INSTDIR" 70 | SectionEnd 71 | 72 | -------------------------------------------------------------------------------- /src/gui/help.rs: -------------------------------------------------------------------------------- 1 | use crate::common::get_log_path; 2 | use crate::config::get_default_config_path; 3 | use crate::gui::app::{AppState, Page, SPACING}; 4 | use ellipse::Ellipse; 5 | 6 | pub struct HelpPage { 7 | log_file_path: String, 8 | config_file_path: String, 9 | } 10 | 11 | impl Default for HelpPage { 12 | fn default() -> Self { 13 | Self { 14 | log_file_path: get_log_path().display().to_string(), 15 | config_file_path: get_default_config_path().display().to_string(), 16 | } 17 | } 18 | } 19 | 20 | impl Page for HelpPage { 21 | fn render(&mut self, ui: &mut egui::Ui, _context: &mut AppState) { 22 | ui.spacing_mut().item_spacing.y = SPACING; 23 | 24 | ui.hyperlink_to( 25 | " Home page", 26 | "https://github.com/jacob-pro/solar-screen-brightness", 27 | ); 28 | 29 | ui.horizontal(|ui| { 30 | ui.spacing_mut().item_spacing.x = 0.0; 31 | ui.label("Software version: "); 32 | let version_url = format!( 33 | "https://github.com/jacob-pro/solar-screen-brightness/releases/tag/{}", 34 | env!("CARGO_PKG_VERSION") 35 | ); 36 | ui.hyperlink_to(env!("CARGO_PKG_VERSION"), version_url); 37 | }); 38 | 39 | ui.horizontal(|ui| { 40 | ui.spacing_mut().item_spacing.x = 0.0; 41 | ui.label("Please report any bugs on the "); 42 | ui.hyperlink_to( 43 | "issues page", 44 | "https://github.com/jacob-pro/solar-screen-brightness/issues", 45 | ); 46 | }); 47 | 48 | let entries = vec![ 49 | ("Log file", self.log_file_path.as_str()), 50 | ("Config file", self.config_file_path.as_str()), 51 | ]; 52 | 53 | egui::Grid::new("file paths grid") 54 | .striped(false) 55 | .num_columns(3) 56 | .show(ui, |ui| { 57 | for (name, path) in entries { 58 | ui.label(name); 59 | ui.hyperlink_to(path.truncate_ellipse(45), path); 60 | if ui.button("📋").on_hover_text("Copy path").clicked() { 61 | ui.output_mut(|o| o.copied_text = path.to_string()); 62 | } 63 | ui.end_row(); 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /linux/README.md: -------------------------------------------------------------------------------- 1 | # Linux Guide 2 | 3 | ## Installation 4 | 5 | For Ubuntu you can use the [installation script](./ubuntu_install.sh): 6 | 7 | ```bash 8 | curl -sSLf https://github.com/jacob-pro/solar-screen-brightness/raw/master/linux/ubuntu_install.sh?raw=true | bash -s install 9 | ``` 10 | 11 | ## External Monitor Support 12 | 13 | Internally `solar-screen-brightness` uses the [`brightness` crate](https://github.com/stephaneyfx/brightness). 14 | 15 | On Linux, the `brightness` crate interacts with devices found at `/sys/class/backlight`. 16 | 17 | The [ddcci-backlight](https://gitlab.com/ddcci-driver-linux/ddcci-driver-linux) 18 | kernel driver is required to expose external monitors as backlight devices (via DDC/CI). 19 | 20 | ### Installing the Driver 21 | 22 | On Ubuntu-like distributions you should be able to use APT to install: 23 | 24 | ``` 25 | sudo apt install ddcci-dkms 26 | ``` 27 | 28 | On RHEL-family distributions: 29 | 30 | ``` 31 | sudo yum install kernel-devel # You need matching kernel headers installed 32 | git clone https://gitlab.com/ddcci-driver-linux/ddcci-driver-linux.git 33 | cd ddcci-driver-linux 34 | sudo make install 35 | sudo make load 36 | ``` 37 | 38 | If the driver was installed successfully and is working for your monitors, you should now 39 | be able to see the devices listed in both `/sys/bus/ddcci/devices` and `/sys/class/backlight`. 40 | 41 | ### Debugging the Driver 42 | 43 | In one terminal run: `dmesg -wT | grep ddcci` to follow the logs. 44 | 45 | Then reload the driver in debug mode: 46 | ``` 47 | cd ddcci-driver-linux 48 | sudo make unload 49 | modprobe ddcci-backlight dyndbg 50 | ``` 51 | 52 | ### Backlight Permissions 53 | 54 | If you have `systemd` 55 | [version 243 or later](https://github.com/systemd/systemd/blob/877aa0bdcc2900712b02dac90856f181b93c4e40/NEWS#L262), 56 | then the `brightness` crate will attempt to set the device brightness 57 | using the `DBus` `SetBrightness()` call, which manages all the permissions for you. 58 | 59 | However, on older versions which don't have this function, then `brightness` must write directly to the backlight file, 60 | which will require you to set appropriate permissions. You can do this using `udev` rules, for example: 61 | 62 | `/etc/udev/rules.d/backlight.rules` 63 | ``` 64 | RUN+="/bin/bash -c '/bin/chgrp video /sys/class/backlight/*/brightness'" 65 | RUN+="/bin/bash -c '/bin/chmod g+w /sys/class/backlight/*/brightness'" 66 | ``` 67 | 68 | `usermod -a -G video $USER` (requires logging out to take effect) 69 | 70 | ### Known Issues 71 | 72 | - [Monitors connected via a USB-C dock, on Intel devices, require updating to the Linux Kernel 5.10 for DDC/CI to work](https://gitlab.freedesktop.org/drm/intel/-/issues/37). 73 | - [Hot swapping monitors is not yet supported, you need to reload the kernel module](https://gitlab.com/ddcci-driver-linux/ddcci-driver-linux/-/issues/5) 74 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | //! Entry point for CLI driven application 2 | use anyhow::Context; 3 | use clap::Parser; 4 | use solar_screen_brightness::apply::apply_brightness; 5 | use solar_screen_brightness::common::{install_logger, APP_NAME}; 6 | use solar_screen_brightness::config::SsbConfig; 7 | use solar_screen_brightness::controller::BrightnessController; 8 | use solar_screen_brightness::event_watcher::EventWatcher; 9 | use solar_screen_brightness::unique::SsbUniqueInstance; 10 | use std::path::PathBuf; 11 | use std::sync::{mpsc, Arc, RwLock}; 12 | 13 | #[derive(Parser, Debug)] 14 | #[command(author, version, about)] 15 | struct Args { 16 | /// Enable debug logging 17 | #[arg(short, long)] 18 | debug: bool, 19 | /// Update brightness once and exit 20 | #[arg(short, long)] 21 | once: bool, 22 | /// Override the config file path 23 | #[arg(long)] 24 | config: Option, 25 | } 26 | 27 | fn run(args: Args) -> anyhow::Result<()> { 28 | log::info!( 29 | "Starting {} (CLI), version: {}", 30 | APP_NAME, 31 | env!("CARGO_PKG_VERSION") 32 | ); 33 | let config = SsbConfig::load(args.config) 34 | .context("Unable to load config file")? 35 | .context("Config file does not exist")?; 36 | config 37 | .location 38 | .as_ref() 39 | .context("Location is not configured")?; 40 | if args.once { 41 | let result = apply_brightness( 42 | config.brightness_day, 43 | config.brightness_night, 44 | config.transition_mins, 45 | config.location.unwrap(), 46 | config.overrides, 47 | ); 48 | let pretty = serde_json::to_string_pretty(&result).unwrap(); 49 | println!("{}", pretty); 50 | } else { 51 | let (tx, rx) = mpsc::channel(); 52 | let config = Arc::new(RwLock::new(config)); 53 | let controller = BrightnessController::start(config, || {}); 54 | let _event_watcher = EventWatcher::start(&controller, None); 55 | ctrlc::set_handler(move || tx.send(()).unwrap()).expect("Error setting Ctrl-C handler"); 56 | rx.recv().expect("Could not receive from channel."); 57 | } 58 | Ok(()) 59 | } 60 | 61 | fn main() { 62 | let args: Args = Args::parse(); 63 | 64 | // Check this is the only instance running 65 | let _unique_instance = match SsbUniqueInstance::try_acquire() { 66 | Ok(i) => i, 67 | Err(e) => { 68 | eprintln!("{}", e); 69 | std::process::exit(1); 70 | } 71 | }; 72 | 73 | // Setup logging 74 | if let Err(e) = install_logger(args.debug, false) { 75 | eprintln!("Unable to install logger: {:#}", e); 76 | std::process::exit(1); 77 | } 78 | // Run the application logic 79 | if let Err(e) = run(args) { 80 | log::error!("{:#}", e); 81 | std::process::exit(1); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/unique/linux.rs: -------------------------------------------------------------------------------- 1 | use crate::event_watcher::linux::{get_ipc_path, MSG_OPEN_WINDOW}; 2 | use crate::unique::Error; 3 | use nix::errno::Errno; 4 | use nix::fcntl::{open, OFlag}; 5 | use nix::sys::stat::Mode; 6 | use nix::unistd::{close, write}; 7 | use nix::unistd::{mkfifo, unlink}; 8 | use std::os::unix::io::RawFd; 9 | 10 | #[derive(Debug, Error)] 11 | enum PlatformError { 12 | #[error("Failed to call mkfifo: {0}")] 13 | Mkfifo(#[source] Errno), 14 | #[error("Failed to open FIFO for writing: {0}")] 15 | OpenWrite(#[source] Errno), 16 | #[error("Failed to open FIFO for reading: {0}")] 17 | OpenRead(#[source] Errno), 18 | } 19 | 20 | // A handle to a FIFO reader, indicating that this is the unique instance of SSB 21 | pub struct SsbUniqueInstance { 22 | fd: RawFd, 23 | } 24 | 25 | impl SsbUniqueInstance { 26 | pub fn try_acquire() -> Result { 27 | let ipc_path = get_ipc_path(); 28 | // Create the FIFO special file, ignore if it already exists 29 | match mkfifo(&ipc_path, Mode::S_IWUSR | Mode::S_IRUSR) { 30 | Ok(_) => {} 31 | Err(Errno::EEXIST) => {} 32 | Err(e) => return Err(Error::PlatformError(Box::new(PlatformError::Mkfifo(e)))), 33 | } 34 | // Attempt to open the FIFO for writing, this will only succeed if there is an existing 35 | // instance reading on this FIFO. 36 | match open( 37 | &ipc_path, 38 | OFlag::O_WRONLY | OFlag::O_NONBLOCK, 39 | Mode::empty(), 40 | ) { 41 | // Success means that a reading process exists 42 | Ok(fd) => Err(Error::AlreadyRunning(ExistingInstance(fd))), 43 | // ENXIO means that there is no process reading from this FIFO 44 | Err(Errno::ENXIO) => { 45 | // We must create a reader to hold the lock 46 | // We must use non block otherwise it will block until a writer is connected 47 | match open( 48 | &ipc_path, 49 | OFlag::O_RDONLY | OFlag::O_NONBLOCK, 50 | Mode::empty(), 51 | ) { 52 | Ok(fd) => Ok(SsbUniqueInstance { fd }), 53 | Err(e) => Err(Error::PlatformError(Box::new(PlatformError::OpenRead(e)))), 54 | } 55 | } 56 | Err(e) => Err(Error::PlatformError(Box::new(PlatformError::OpenWrite(e)))), 57 | } 58 | } 59 | } 60 | 61 | impl Drop for SsbUniqueInstance { 62 | fn drop(&mut self) { 63 | close(self.fd).unwrap(); 64 | unlink(&get_ipc_path()).ok(); 65 | } 66 | } 67 | 68 | #[derive(Debug)] 69 | pub struct ExistingInstance(RawFd); 70 | 71 | /// Represents an already running instance of Solar Screen Brightness 72 | impl ExistingInstance { 73 | /// Writes the MSG_OPEN_WINDOW down the IPC pipe 74 | pub fn wakeup(&self) { 75 | write(self.0, vec![MSG_OPEN_WINDOW].as_slice()).unwrap(); 76 | } 77 | } 78 | 79 | impl Drop for ExistingInstance { 80 | fn drop(&mut self) { 81 | close(self.0).unwrap(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/tray.rs: -------------------------------------------------------------------------------- 1 | use crate::common::APP_NAME; 2 | use crate::gui::UserEvent; 3 | use egui_winit::winit::event_loop::{EventLoop, EventLoopProxy}; 4 | use std::sync::{Arc, Mutex}; 5 | use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItemBuilder}; 6 | use tray_icon::{ClickType, Icon, TrayIcon, TrayIconBuilder, TrayIconEvent}; 7 | 8 | const MENU_ID_OPEN: &str = "OPEN"; 9 | const MENU_ID_EXIT: &str = "EXIT"; 10 | 11 | pub fn read_icon() -> (Vec, png::OutputInfo) { 12 | let mut decoder = png::Decoder::new(include_bytes!("../assets/icon-256.png").as_slice()); 13 | decoder.set_transformations(png::Transformations::EXPAND); 14 | let mut reader = decoder.read_info().unwrap(); 15 | let mut buf = vec![0u8; reader.output_buffer_size()]; 16 | let info = reader.next_frame(&mut buf).unwrap(); 17 | (buf, info) 18 | } 19 | 20 | #[cfg(target_os = "linux")] 21 | pub fn create(event_loop: &EventLoop) -> std::thread::JoinHandle<()> { 22 | let proxy = event_loop.create_proxy(); 23 | // https://github.com/tauri-apps/tray-icon/blob/817d85579b406ddf83891017edb8c7e290bfaa8e/examples/egui.rs#L13-L15 24 | std::thread::spawn(move || { 25 | gtk::init().unwrap(); 26 | // must not drop tray 27 | let _tray = create_internal(proxy); 28 | gtk::main(); 29 | }) 30 | } 31 | 32 | #[cfg(not(target_os = "linux"))] 33 | pub fn create(event_loop: &EventLoop) -> TrayIcon { 34 | create_internal(event_loop.create_proxy()) 35 | } 36 | 37 | fn create_internal(event_loop: EventLoopProxy) -> TrayIcon { 38 | let (buf, info) = read_icon(); 39 | let icon = Icon::from_rgba(buf, info.width, info.height).unwrap(); 40 | 41 | let menu = Menu::with_items(&[ 42 | &MenuItemBuilder::new() 43 | .text("Open") 44 | .id(MenuId::new(MENU_ID_OPEN)) 45 | .enabled(true) 46 | .build(), 47 | &MenuItemBuilder::new() 48 | .text("Exit") 49 | .id(MenuId::new(MENU_ID_EXIT)) 50 | .enabled(true) 51 | .build(), 52 | ]) 53 | .unwrap(); 54 | 55 | let tray_icon = TrayIconBuilder::new() 56 | .with_tooltip(APP_NAME) 57 | .with_icon(icon) 58 | .with_menu(Box::new(menu)) 59 | .build() 60 | .unwrap(); 61 | 62 | let tray_loop = Arc::new(Mutex::new(event_loop.clone())); 63 | let menu_loop = Arc::new(Mutex::new(event_loop)); 64 | 65 | TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| { 66 | if event.click_type == ClickType::Left { 67 | tray_loop 68 | .lock() 69 | .unwrap() 70 | .send_event(UserEvent::OpenWindow("Tray Button")) 71 | .unwrap(); 72 | } 73 | })); 74 | 75 | MenuEvent::set_event_handler(Some(move |event: MenuEvent| { 76 | let action = match event.id.0.as_str() { 77 | MENU_ID_OPEN => UserEvent::OpenWindow("Tray Button"), 78 | MENU_ID_EXIT => UserEvent::Exit("Tray Button"), 79 | _ => return, 80 | }; 81 | menu_loop.lock().unwrap().send_event(action).unwrap(); 82 | })); 83 | 84 | tray_icon 85 | } 86 | -------------------------------------------------------------------------------- /linux/ubuntu_install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Install Script for Solar Screen Brightness on Ubuntu 4 | # 1. Installs Rust Cargo (via Rustup) if necessary 5 | # 2. Installs any required packages if necessary 6 | # 3. Compiles and installs the latest applications (ssb and ssb-cli) from GitHub 7 | # 4. Creates desktop and autostart entries 8 | 9 | set -e 10 | 11 | DESKTOP_ENTRY="${HOME}/.local/share/applications/solar-screen-brightness.desktop" 12 | AUTOSTART_DIR="${HOME}/.config/autostart" 13 | AUTOSTART_ENTRY="${AUTOSTART_DIR}/solar-screen-brightness.desktop" 14 | SSB_BINARY="${HOME}/.cargo/bin/ssb" 15 | SSB_DATA_DIR="${HOME}/.local/share/solar-screen-brightness" 16 | ICON_PATH="${SSB_DATA_DIR}/icon.png" 17 | SSB_CONFIG_DIR="${HOME}/.config/solar-screen-brightness" 18 | 19 | function install_package_if_not_exists () { 20 | if [ $(dpkg-query -W -f='${Status}' ${1} 2>/dev/null | grep -c "ok installed") -eq 0 ]; then 21 | echo "Attempting to install ${1}" 22 | sudo apt install -y ${1}; 23 | else 24 | echo "Package ${1} is already installed" 25 | fi 26 | } 27 | 28 | function refresh_desktop() { 29 | echo "Updating Desktop" 30 | xdg-desktop-menu forceupdate 31 | } 32 | 33 | function install() { 34 | if ! command -v cargo &> /dev/null; then 35 | echo "Rust Cargo could not be found. Attempting to install Rust..." 36 | curl https://sh.rustup.rs -sSf | sh -s -- -y 37 | source $HOME/.cargo/env 38 | else 39 | echo "Cargo found: $(cargo --version)" 40 | fi 41 | 42 | install_package_if_not_exists "libssl-dev" 43 | install_package_if_not_exists "libudev-dev" 44 | install_package_if_not_exists "libgtk-3-dev" 45 | install_package_if_not_exists "libxdo-dev" 46 | 47 | echo "Installing latest Solar Screen Brightness..." 48 | cargo install --git https://github.com/jacob-pro/solar-screen-brightness 49 | 50 | ICON_URL="https://github.com/jacob-pro/solar-screen-brightness/blob/06d57b9054ae29c5e73e491f5d073a4986f2224a/assets/icon-256.png?raw=true" 51 | 52 | echo "Downloading icon to ${ICON_PATH}" 53 | curl -sS -L --create-dirs --output "${ICON_PATH}" "${ICON_URL}" 54 | 55 | # Quotes https://askubuntu.com/a/1023258 56 | echo "Creating desktop entry ${DESKTOP_ENTRY}" 57 | cat < ${DESKTOP_ENTRY} 58 | [Desktop Entry] 59 | Type=Application 60 | Name=Solar Screen Brightness 61 | Exec="${SSB_BINARY}" 62 | Icon=${ICON_PATH} 63 | Terminal=false 64 | EOT 65 | 66 | echo "Creating autostart entry ${AUTOSTART_ENTRY}" 67 | mkdir -p ${AUTOSTART_DIR} 68 | cat < ${AUTOSTART_ENTRY} 69 | [Desktop Entry] 70 | Type=Application 71 | Name=Solar Screen Brightness 72 | Exec="${SSB_BINARY}" --minimised 73 | Icon=${ICON_PATH} 74 | Terminal=false 75 | EOT 76 | 77 | refresh_desktop 78 | 79 | echo "Successfully installed $(ssb-cli --version)" 80 | } 81 | 82 | function uninstall() { 83 | echo "Removing desktop entry" 84 | rm -f ${DESKTOP_ENTRY} 85 | echo "Removing startup entry" 86 | rm -f ${AUTOSTART_ENTRY} 87 | refresh_desktop 88 | 89 | if command -v ssb &> /dev/null; then 90 | echo "Uninstalling solar-screen-brightness" 91 | cargo uninstall solar-screen-brightness 92 | fi 93 | 94 | echo "Deleting config" 95 | rm -rf ${SSB_CONFIG_DIR} 96 | echo "Deleting data" 97 | rm -rf ${SSB_DATA_DIR} 98 | } 99 | 100 | if [ "$1" = "install" ]; then 101 | install 102 | elif [ "$1" = "uninstall" ]; then 103 | uninstall 104 | else 105 | echo "Usage: ./ubuntu-install.sh [install or uninstall]" 106 | fi 107 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! SSB Config file definition 2 | use crate::common::config_directory; 3 | use anyhow::Context; 4 | use enum_iterator::Sequence; 5 | use serde::{Deserialize, Serialize}; 6 | use std::fs; 7 | use std::io::Write; 8 | use std::path::PathBuf; 9 | use tempfile::NamedTempFile; 10 | use validator::Validate; 11 | 12 | const CONFIG_FILE_NAME: &str = "config.json"; 13 | 14 | #[derive(Debug, Deserialize, Serialize, Validate, Clone, Copy, PartialEq)] 15 | pub struct Location { 16 | #[validate(range(min = -90, max = 90))] 17 | pub latitude: f64, 18 | #[validate(range(min = -180, max = 180))] 19 | pub longitude: f64, 20 | } 21 | 22 | #[derive(Debug, Deserialize, Serialize, Validate, Clone)] 23 | pub struct SsbConfig { 24 | #[validate(range(max = 100))] 25 | pub brightness_day: u32, 26 | #[validate(range(max = 100))] 27 | pub brightness_night: u32, 28 | #[validate(range(max = 360))] 29 | pub transition_mins: u32, 30 | #[validate] 31 | pub location: Option, 32 | #[serde(default)] 33 | #[validate] 34 | pub overrides: Vec, 35 | } 36 | 37 | #[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, Hash, PartialEq, Sequence)] 38 | #[serde(rename_all = "snake_case")] 39 | pub enum MonitorProperty { 40 | DeviceName, 41 | #[cfg(windows)] 42 | DeviceDescription, 43 | #[cfg(windows)] 44 | DeviceKey, 45 | #[cfg(windows)] 46 | DevicePath, 47 | } 48 | 49 | impl MonitorProperty { 50 | pub fn as_str(&self) -> &'static str { 51 | match self { 52 | MonitorProperty::DeviceName => "Name", 53 | #[cfg(windows)] 54 | MonitorProperty::DeviceDescription => "Description", 55 | #[cfg(windows)] 56 | MonitorProperty::DeviceKey => "Key", 57 | #[cfg(windows)] 58 | MonitorProperty::DevicePath => "Path", 59 | } 60 | } 61 | } 62 | 63 | #[derive(Debug, Deserialize, Serialize, Validate, Clone)] 64 | pub struct MonitorOverride { 65 | pub pattern: String, 66 | pub key: MonitorProperty, 67 | #[validate] 68 | pub brightness: Option, 69 | } 70 | 71 | #[derive(Debug, Serialize, Deserialize, Validate, Copy, Clone)] 72 | pub struct BrightnessValues { 73 | #[validate(range(max = 100))] 74 | pub brightness_day: u32, 75 | #[validate(range(max = 100))] 76 | pub brightness_night: u32, 77 | } 78 | 79 | impl SsbConfig { 80 | pub fn load(path_override: Option) -> anyhow::Result> { 81 | let path = path_override.unwrap_or_else(get_default_config_path); 82 | if !path.exists() { 83 | return Ok(None); 84 | } 85 | let contents = fs::read_to_string(&path) 86 | .context(format!("Unable to read file '{}'", path.display()))?; 87 | let config = serde_json::from_str::(&contents).context(format!( 88 | "Unable to deserialize config file '{}'", 89 | path.display() 90 | ))?; 91 | config 92 | .validate() 93 | .context(format!("Invalid config file '{}'", path.display()))?; 94 | Ok(Some(config)) 95 | } 96 | 97 | pub fn save(&self) -> anyhow::Result<()> { 98 | let path = get_default_config_path(); 99 | let serialised = serde_json::to_string_pretty(&self).unwrap(); 100 | let parent = path.parent().expect("config path must have parent"); 101 | let mut temp_file = NamedTempFile::new_in(parent)?; 102 | temp_file.write_all(serialised.as_bytes())?; 103 | temp_file.flush()?; 104 | temp_file.persist(&path)?; 105 | log::debug!("Successfully saved config to {}", path.display()); 106 | Ok(()) 107 | } 108 | } 109 | 110 | pub fn get_default_config_path() -> PathBuf { 111 | config_directory().join(CONFIG_FILE_NAME) 112 | } 113 | 114 | impl Default for SsbConfig { 115 | fn default() -> Self { 116 | SsbConfig { 117 | brightness_day: 100, 118 | brightness_night: 60, 119 | transition_mins: 40, 120 | location: None, 121 | overrides: vec![], 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/gui/status.rs: -------------------------------------------------------------------------------- 1 | use crate::apply::ApplyResults; 2 | use crate::gui::app::{AppState, Page, SPACING}; 3 | use chrono::{Local, TimeZone}; 4 | 5 | pub struct StatusPage; 6 | 7 | impl Page for StatusPage { 8 | fn render(&mut self, ui: &mut egui::Ui, context: &mut AppState) { 9 | let results = context.results.read().unwrap(); 10 | if let Some(results) = results.as_ref() { 11 | display_apply_results(results, ui); 12 | } else { 13 | let config = context.config.read().unwrap(); 14 | if config.location.is_none() { 15 | ui.label("A location must be configured"); 16 | } else { 17 | ui.label("Brightness controller has not yet started"); 18 | } 19 | } 20 | } 21 | } 22 | 23 | fn display_apply_results(results: &ApplyResults, ui: &mut egui::Ui) { 24 | let date_format = "%I:%M %P (%b %d)"; 25 | let sunrise = Local 26 | .timestamp_opt(results.sun.rise, 0) 27 | .unwrap() 28 | .format(date_format) 29 | .to_string(); 30 | let sunset = Local 31 | .timestamp_opt(results.sun.set, 0) 32 | .unwrap() 33 | .format(date_format) 34 | .to_string(); 35 | 36 | egui::Grid::new("sun_times_grid") 37 | .num_columns(2) 38 | .show(ui, |ui| { 39 | if results.sun.visible { 40 | ui.label("Sunrise"); 41 | ui.label(sunrise); 42 | ui.end_row(); 43 | ui.label("Sunset"); 44 | ui.label(sunset); 45 | ui.end_row(); 46 | } else { 47 | ui.label("Sunset"); 48 | ui.label(sunset); 49 | ui.end_row(); 50 | ui.label("Sunrise"); 51 | ui.label(sunrise); 52 | ui.end_row(); 53 | } 54 | }); 55 | 56 | ui.add_space(SPACING); 57 | ui.separator(); 58 | ui.add_space(SPACING); 59 | 60 | if results.monitors.is_empty() { 61 | ui.add(no_devices_found()); 62 | return; 63 | } 64 | 65 | egui::Grid::new("monitors_grid") 66 | .striped(true) 67 | .num_columns(5) 68 | .show(ui, |ui| { 69 | ui.label("Name"); 70 | ui.label("Day") 71 | .on_hover_text("Configured day time brightness"); 72 | ui.label("Night") 73 | .on_hover_text("Configured night time brightness"); 74 | ui.label("Now") 75 | .on_hover_text("The computed brightness percentage for this monitor"); 76 | ui.label("Status"); 77 | ui.label("Next update") 78 | .on_hover_text("Time that the brightness will be changed"); 79 | ui.end_row(); 80 | 81 | results.monitors.iter().for_each(|monitor| { 82 | ui.label(&monitor.properties.device_name); 83 | if let Some(brightness) = &monitor.brightness { 84 | ui.label(format!("{}%", brightness.brightness_day)); 85 | ui.label(format!("{}%", brightness.brightness_night)); 86 | ui.label(format!("{}%", brightness.brightness)); 87 | 88 | match &monitor.error { 89 | None => ui 90 | .label("Ok") 91 | .on_hover_text("Brightness was applied successfully"), 92 | Some(e) => ui 93 | .label(egui::RichText::new("Error").color(egui::Color32::RED)) 94 | .on_hover_text(e), 95 | }; 96 | 97 | match brightness.expiry_time { 98 | None => ui.label("Never"), 99 | Some(expiry_time) => { 100 | let changes_at = Local.timestamp_opt(expiry_time, 0).unwrap(); 101 | ui.label(changes_at.format("%H:%M %P").to_string()) 102 | .on_hover_text(changes_at.format("%b %d").to_string()) 103 | } 104 | }; 105 | } else { 106 | (0..3).for_each(|_| { 107 | ui.label("N/A"); 108 | }); 109 | ui.label("Disabled") 110 | .on_hover_text("Dynamic brightness is disabled due to a monitor override"); 111 | ui.label("Never"); 112 | } 113 | ui.end_row(); 114 | }); 115 | }); 116 | } 117 | 118 | pub fn no_devices_found() -> egui::Label { 119 | egui::Label::new(egui::RichText::new("No devices found").color(egui::Color32::RED)) 120 | } 121 | -------------------------------------------------------------------------------- /src/controller.rs: -------------------------------------------------------------------------------- 1 | use crate::apply::{apply_brightness, ApplyResults}; 2 | use crate::config::SsbConfig; 3 | use human_repr::HumanDuration; 4 | use std::mem::take; 5 | use std::sync::mpsc::RecvTimeoutError; 6 | use std::sync::{mpsc, Arc, RwLock}; 7 | use std::thread; 8 | use std::thread::JoinHandle; 9 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 10 | 11 | pub enum Message { 12 | Shutdown, 13 | Refresh(&'static str), 14 | Disable(&'static str), 15 | Enable(&'static str), 16 | } 17 | 18 | pub struct BrightnessController { 19 | pub sender: mpsc::Sender, 20 | pub last_result: Arc>>, 21 | join_handle: Option>, 22 | } 23 | 24 | impl BrightnessController { 25 | pub fn start( 26 | config: Arc>, 27 | on_update: F, 28 | ) -> BrightnessController { 29 | let (sender, receiver) = mpsc::channel(); 30 | let last_result = Arc::new(RwLock::new(None)); 31 | let cloned = last_result.clone(); 32 | let join_handle = thread::spawn(move || { 33 | run(config, receiver, cloned, on_update); 34 | }); 35 | BrightnessController { 36 | sender, 37 | last_result, 38 | join_handle: Some(join_handle), 39 | } 40 | } 41 | } 42 | 43 | impl Drop for BrightnessController { 44 | fn drop(&mut self) { 45 | self.sender.send(Message::Shutdown).unwrap(); 46 | take(&mut self.join_handle).unwrap().join().unwrap(); 47 | log::debug!("Stopped BrightnessController"); 48 | } 49 | } 50 | 51 | fn run( 52 | config: Arc>, 53 | receiver: mpsc::Receiver, 54 | last_result: Arc>>, 55 | on_update: F, 56 | ) { 57 | log::info!("Starting BrightnessController"); 58 | let mut enabled = true; 59 | 60 | loop { 61 | let timeout = if enabled { 62 | // Apply brightness using latest config 63 | let config = config.read().unwrap().clone(); 64 | let result = apply(config); 65 | let timeout = calculate_timeout(&result); 66 | 67 | // Update last result 68 | *last_result.write().unwrap() = result; 69 | on_update(); 70 | timeout 71 | } else { 72 | log::info!("BrightnessController is disabled, skipping update"); 73 | None 74 | }; 75 | 76 | // Sleep until receiving message or timeout 77 | let rx_result = match timeout { 78 | None => { 79 | log::info!("Brightness Worker sleeping indefinitely"); 80 | receiver.recv().map_err(|e| e.into()) 81 | } 82 | Some(timeout) => { 83 | let duration = timeout 84 | .duration_since(SystemTime::now()) 85 | .unwrap_or_default(); 86 | log::info!( 87 | "BrightnessController sleeping for {}s", 88 | duration.human_duration() 89 | ); 90 | receiver.recv_timeout(duration) 91 | } 92 | }; 93 | 94 | match rx_result { 95 | Ok(Message::Shutdown) => { 96 | log::info!("Stopping BrightnessController"); 97 | break; 98 | } 99 | Ok(Message::Refresh(src)) => { 100 | log::info!("Refreshing due to '{src}'"); 101 | } 102 | Ok(Message::Disable(src)) => { 103 | log::info!("Disabling BrightnessController due to '{src}'"); 104 | enabled = false; 105 | } 106 | Ok(Message::Enable(src)) => { 107 | log::info!("Enabling BrightnessController due to '{src}'"); 108 | enabled = true; 109 | } 110 | Err(RecvTimeoutError::Timeout) => { 111 | log::debug!("Refreshing due to timeout") 112 | } 113 | Err(RecvTimeoutError::Disconnected) => panic!("Unexpected disconnection"), 114 | } 115 | } 116 | } 117 | 118 | // The time at which the brightness should be re-applied 119 | fn calculate_timeout(results: &Option) -> Option { 120 | if let Some(results) = results { 121 | results 122 | .monitors 123 | .iter() 124 | .flat_map(|m| m.brightness.as_ref().map(|b| b.expiry_time)) 125 | .flatten() 126 | .min() 127 | .map(|e| UNIX_EPOCH + Duration::from_secs(e as u64)) 128 | } else { 129 | None 130 | } 131 | } 132 | 133 | // Calculate and apply the brightness 134 | fn apply(config: SsbConfig) -> Option { 135 | if let Some(location) = config.location { 136 | Some(apply_brightness( 137 | config.brightness_day, 138 | config.brightness_night, 139 | config.transition_mins, 140 | location, 141 | config.overrides, 142 | )) 143 | } else { 144 | log::warn!("Skipping apply because no location is configured"); 145 | None 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/event_watcher/linux.rs: -------------------------------------------------------------------------------- 1 | use crate::common::local_data_directory; 2 | use crate::controller::{BrightnessController, Message}; 3 | use crate::gui::UserEvent; 4 | use egui_winit::winit::event_loop::{EventLoop, EventLoopProxy}; 5 | use nix::fcntl::{open, OFlag}; 6 | use nix::poll::{poll, PollFd, PollFlags}; 7 | use nix::sys::stat::Mode; 8 | use nix::unistd::{close, pipe, read, write}; 9 | use std::ffi::CString; 10 | use std::os::unix::prelude::{AsRawFd, RawFd}; 11 | use std::path::PathBuf; 12 | use std::sync::mpsc; 13 | use std::thread::JoinHandle; 14 | use udev::ffi::{udev_device_get_action, udev_monitor_receive_device}; 15 | use udev::{AsRaw, MonitorBuilder}; 16 | 17 | pub const MSG_OPEN_WINDOW: u8 = 1; 18 | const MSG_STOP_WATCHING: u8 = 2; 19 | 20 | pub fn get_ipc_path() -> PathBuf { 21 | local_data_directory().join("ipc") 22 | } 23 | 24 | pub struct EventWatcher { 25 | ddcci_write_end: RawFd, 26 | ddcci_watcher_thread: Option>, 27 | ipc_path: PathBuf, 28 | ipc_watcher_thread: Option>, 29 | } 30 | 31 | /// Detect when DDC/CI monitors are added, tell the BrightnessController to refresh 32 | fn watch_ddcci(read: RawFd, controller: mpsc::Sender) { 33 | if let Err(e) = (|| -> anyhow::Result<()> { 34 | let socket = MonitorBuilder::new()?.match_subsystem("ddcci")?.listen()?; 35 | log::info!("Monitoring for DDC/CI connections"); 36 | loop { 37 | let socket_fd = PollFd::new(socket.as_raw_fd(), PollFlags::POLLIN); 38 | let stop = PollFd::new(read, PollFlags::POLLIN); 39 | let mut pfds = vec![socket_fd, stop]; 40 | log::trace!("Beginning udev monitor connections poll..."); 41 | poll(pfds.as_mut_slice(), -1)?; 42 | if let Some(e) = pfds[1].revents() { 43 | if e.contains(PollFlags::POLLIN) { 44 | close(read).ok(); 45 | break; 46 | } 47 | } 48 | if let Some(e) = pfds[0].revents() { 49 | if e.contains(PollFlags::POLLIN) { 50 | let action = unsafe { 51 | let dev = udev_monitor_receive_device(socket.as_raw()); 52 | let raw = udev_device_get_action(dev); 53 | let cs = CString::from_raw(raw as *mut _); 54 | cs.to_str()?.to_owned() 55 | }; 56 | if action == "add" { 57 | log::info!("Notified of ddcci add event, triggering refresh"); 58 | controller.send(Message::Refresh("udev add")).unwrap(); 59 | } 60 | } 61 | } 62 | } 63 | log::debug!("DDC/CI watcher thread exiting"); 64 | Ok(()) 65 | })() { 66 | log::error!("Error occurred monitoring for DDC/CI connections: {:#}", e); 67 | } 68 | } 69 | 70 | /// Listen to the IPC pipe for the MSG_OPEN_WINDOW 71 | fn watch_ipc_pipe(ipc_path: PathBuf, event_loop: Option>) { 72 | 'outer: loop { 73 | let fd = open(&ipc_path, OFlag::O_RDONLY, Mode::empty()).unwrap(); 74 | let mut buffer = vec![0_u8; 1]; 75 | loop { 76 | let len = read(fd, buffer.as_mut_slice()).unwrap(); 77 | if len == 0 { 78 | break; // Occurs when the writer disconnects, reopen the file and wait again 79 | } 80 | match buffer[0] { 81 | MSG_OPEN_WINDOW => { 82 | if let Some(event_loop) = &event_loop { 83 | event_loop 84 | .send_event(UserEvent::OpenWindow("IPC watcher")) 85 | .unwrap(); 86 | } 87 | } 88 | MSG_STOP_WATCHING => { 89 | close(fd).unwrap(); 90 | break 'outer; 91 | } 92 | _ => {} 93 | } 94 | } 95 | close(fd).unwrap(); 96 | } 97 | log::debug!("IPC watcher thread exiting"); 98 | } 99 | 100 | impl EventWatcher { 101 | pub fn start( 102 | controller: &BrightnessController, 103 | event_loop: Option<&EventLoop>, 104 | ) -> anyhow::Result { 105 | let sender = controller.sender.clone(); 106 | let (read, write) = pipe().unwrap(); 107 | let ddcci_watcher_thread = std::thread::spawn(move || watch_ddcci(read, sender)); 108 | 109 | let ipc_path = get_ipc_path(); 110 | let proxy = event_loop.map(|e| e.create_proxy()); 111 | let ipc_path2 = ipc_path.clone(); 112 | let ipc_watcher_thread = std::thread::spawn(move || watch_ipc_pipe(ipc_path2, proxy)); 113 | 114 | Ok(Self { 115 | ddcci_write_end: write, 116 | ddcci_watcher_thread: Some(ddcci_watcher_thread), 117 | ipc_path, 118 | ipc_watcher_thread: Some(ipc_watcher_thread), 119 | }) 120 | } 121 | } 122 | 123 | impl Drop for EventWatcher { 124 | fn drop(&mut self) { 125 | log::info!("Stopping DDC/CI watcher"); 126 | write(self.ddcci_write_end, &[0]).unwrap(); 127 | close(self.ddcci_write_end).unwrap(); 128 | self.ddcci_watcher_thread.take().unwrap().join().unwrap(); 129 | 130 | log::info!("Stopping IPC watcher"); 131 | let ipc_fd = open(&self.ipc_path, OFlag::O_WRONLY, Mode::empty()).unwrap(); 132 | write(ipc_fd, vec![MSG_STOP_WATCHING].as_slice()).ok(); 133 | close(ipc_fd).unwrap(); 134 | self.ipc_watcher_thread.take().unwrap().join().unwrap(); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/event_watcher/windows.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::{BrightnessController, Message}; 2 | use crate::gui::UserEvent; 3 | use egui_winit::winit::event_loop::{EventLoop, EventLoopProxy}; 4 | use std::sync::mpsc; 5 | use std::sync::mpsc::sync_channel; 6 | use std::thread::JoinHandle; 7 | use win32_utils::error::{check_error, CheckError}; 8 | use win32_utils::window::WindowDataExtension; 9 | use windows::core::{w, PCWSTR}; 10 | use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; 11 | use windows::Win32::System::LibraryLoader::GetModuleHandleW; 12 | use windows::Win32::System::RemoteDesktop::WTSRegisterSessionNotification; 13 | use windows::Win32::UI::WindowsAndMessaging::{ 14 | CreateWindowExW, DefWindowProcA, DispatchMessageW, GetMessageW, PostQuitMessage, 15 | RegisterClassW, RegisterWindowMessageW, SendMessageW, SetWindowLongPtrW, CW_USEDEFAULT, 16 | GWLP_USERDATA, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WM_APP, WM_DISPLAYCHANGE, 17 | WM_WTSSESSION_CHANGE, WNDCLASSW, WTS_SESSION_LOCK, WTS_SESSION_UNLOCK, 18 | }; 19 | 20 | const EXIT_LOOP: u32 = WM_APP + 999; 21 | 22 | pub struct EventWatcher { 23 | thread: Option>, 24 | hwnd: HWND, 25 | } 26 | 27 | impl EventWatcher { 28 | pub fn start( 29 | controller: &BrightnessController, 30 | main_loop: Option<&EventLoop>, 31 | ) -> anyhow::Result { 32 | let brightness_sender = controller.sender.clone(); 33 | let proxy = main_loop.map(|m| m.create_proxy()); 34 | let (tx, rx) = sync_channel(0); 35 | 36 | let thread = std::thread::spawn(move || { 37 | let mut window_data = Box::new(WindowData { 38 | sender: brightness_sender, 39 | open_window_msg_code: register_open_window_message(), 40 | main_loop: proxy, 41 | }); 42 | 43 | unsafe { 44 | // Create Window Class 45 | let instance = GetModuleHandleW(None).unwrap(); 46 | let window_class = WNDCLASSW { 47 | hInstance: instance.into(), 48 | lpszClassName: w!("ssb_event_watcher"), 49 | lpfnWndProc: Some(wndproc), 50 | ..Default::default() 51 | }; 52 | let atom = check_error(|| RegisterClassW(&window_class)).unwrap(); 53 | 54 | // Create window 55 | let hwnd = CreateWindowExW( 56 | WINDOW_EX_STYLE::default(), 57 | PCWSTR(atom as *const u16), 58 | None, 59 | WINDOW_STYLE::default(), 60 | CW_USEDEFAULT, 61 | CW_USEDEFAULT, 62 | CW_USEDEFAULT, 63 | CW_USEDEFAULT, 64 | None, 65 | None, 66 | instance, 67 | None, 68 | ) 69 | .check_error() 70 | .unwrap(); 71 | 72 | // Register Window data 73 | check_error(|| { 74 | SetWindowLongPtrW(hwnd, GWLP_USERDATA, window_data.as_mut() as *mut _ as isize) 75 | }) 76 | .unwrap(); 77 | 78 | tx.send(hwnd).unwrap(); 79 | 80 | // Register for Session Notifications 81 | WTSRegisterSessionNotification(hwnd, 0).unwrap(); 82 | 83 | let mut message = MSG::default(); 84 | while GetMessageW(&mut message, None, 0, 0).into() { 85 | DispatchMessageW(&message); 86 | } 87 | } 88 | log::debug!("EventWatcher thread exiting"); 89 | }); 90 | 91 | let hwnd = rx.recv().unwrap(); 92 | Ok(EventWatcher { 93 | thread: Some(thread), 94 | hwnd, 95 | }) 96 | } 97 | } 98 | 99 | impl Drop for EventWatcher { 100 | fn drop(&mut self) { 101 | log::info!("Stopping EventWatcher"); 102 | unsafe { check_error(|| SendMessageW(self.hwnd, EXIT_LOOP, None, None)).unwrap() }; 103 | self.thread.take().unwrap().join().unwrap(); 104 | } 105 | } 106 | 107 | struct WindowData { 108 | sender: mpsc::Sender, 109 | open_window_msg_code: u32, 110 | main_loop: Option>, 111 | } 112 | 113 | unsafe extern "system" fn wndproc( 114 | window: HWND, 115 | message: u32, 116 | wparam: WPARAM, 117 | lparam: LPARAM, 118 | ) -> LRESULT { 119 | if let Some(window_data) = window.get_user_data::() { 120 | match message { 121 | WM_DISPLAYCHANGE => { 122 | log::info!("Detected possible display change (WM_DISPLAYCHANGE)"); 123 | window_data 124 | .sender 125 | .send(Message::Refresh("WM_DISPLAYCHANGE")) 126 | .unwrap(); 127 | } 128 | EXIT_LOOP => { 129 | log::debug!("Received EXIT_LOOP message"); 130 | PostQuitMessage(0); 131 | } 132 | WM_WTSSESSION_CHANGE => match wparam.0 as u32 { 133 | WTS_SESSION_LOCK => { 134 | log::info!("Detected WTS_SESSION_LOCK"); 135 | window_data 136 | .sender 137 | .send(Message::Disable("WTS_SESSION_LOCK")) 138 | .unwrap(); 139 | } 140 | WTS_SESSION_UNLOCK => { 141 | log::info!("Detected WTS_SESSION_UNLOCK"); 142 | window_data 143 | .sender 144 | .send(Message::Enable("WTS_SESSION_UNLOCK")) 145 | .unwrap(); 146 | } 147 | _ => {} 148 | }, 149 | msg if msg == window_data.open_window_msg_code => { 150 | if let Some(event_loop) = &window_data.main_loop { 151 | log::info!("Opening window due to external message"); 152 | event_loop 153 | .send_event(UserEvent::OpenWindow("Broadcast Message")) 154 | .unwrap(); 155 | } 156 | } 157 | _ => {} 158 | } 159 | } 160 | DefWindowProcA(window, message, wparam, lparam) 161 | } 162 | 163 | pub fn register_open_window_message() -> u32 { 164 | unsafe { 165 | check_error(|| RegisterWindowMessageW(w!("solar-screen-brightness.open_window"))).unwrap() 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/gui/location_settings.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Location, SsbConfig}; 2 | use crate::controller::Message; 3 | use crate::gui::app::{ 4 | save_config, set_red_widget_border, AppState, MessageModal, Modal, Page, SPACING, 5 | }; 6 | use crate::gui::UserEvent; 7 | use egui::{Context, Widget}; 8 | use geocoding::{Forward, GeocodingError, Openstreetmap}; 9 | use validator::Validate; 10 | 11 | pub struct LocationSettingsPage { 12 | latitude: String, 13 | longitude: String, 14 | search_query: String, 15 | } 16 | 17 | impl LocationSettingsPage { 18 | pub fn from_config(config: &SsbConfig) -> Self { 19 | let location = config.location.as_ref(); 20 | Self { 21 | latitude: coord_to_string(location.map(|l| l.latitude).unwrap_or(0.0)), 22 | longitude: coord_to_string(location.map(|l| l.longitude).unwrap_or(0.0)), 23 | search_query: String::default(), 24 | } 25 | } 26 | 27 | fn copy_to_config(&self, config: &mut SsbConfig) { 28 | config.location = Some(Location { 29 | latitude: self.latitude.parse().unwrap(), 30 | longitude: self.longitude.parse().unwrap(), 31 | }); 32 | assert!(config.validate().is_ok()) 33 | } 34 | 35 | fn is_latitude_valid(&self) -> bool { 36 | self.latitude 37 | .parse::() 38 | .is_ok_and(|l| (-90.0..=90.0).contains(&l)) 39 | } 40 | 41 | fn is_longitude_valid(&self) -> bool { 42 | self.longitude 43 | .parse::() 44 | .is_ok_and(|l| (-180.0..=180.0).contains(&l)) 45 | } 46 | } 47 | 48 | impl Page for LocationSettingsPage { 49 | fn render(&mut self, ui: &mut egui::Ui, app_state: &mut AppState) { 50 | ui.with_layout(egui::Layout::left_to_right(egui::Align::LEFT), |ui| { 51 | ui.add( 52 | egui::TextEdit::singleline(&mut self.search_query) 53 | .hint_text("Enter a city or location name"), 54 | ); 55 | if ui 56 | .add_enabled(!self.search_query.is_empty(), egui::Button::new("Search")) 57 | .on_hover_text("Search using OpenStreetMap") 58 | .clicked() 59 | { 60 | on_search(app_state, self.search_query.clone()); 61 | } 62 | }); 63 | 64 | ui.add_space(SPACING); 65 | 66 | let latitude_valid = self.is_latitude_valid(); 67 | let longitude_valid = self.is_longitude_valid(); 68 | 69 | egui::Grid::new("location_settings") 70 | .num_columns(2) 71 | .show(ui, |ui| { 72 | ui.label("Latitude") 73 | .on_hover_text("Latitude (N), between -90° to 90°"); 74 | ui.vertical(|ui| { 75 | if !latitude_valid { 76 | set_red_widget_border(ui); 77 | } 78 | ui.text_edit_singleline(&mut self.latitude); 79 | }); 80 | ui.end_row(); 81 | 82 | ui.label("Longitude") 83 | .on_hover_text("Longitude (E), between -180° to 180°"); 84 | ui.vertical(|ui| { 85 | if !longitude_valid { 86 | set_red_widget_border(ui); 87 | } 88 | ui.text_edit_singleline(&mut self.longitude); 89 | }); 90 | ui.end_row(); 91 | }); 92 | 93 | ui.add_space(SPACING); 94 | 95 | let save_enabled = latitude_valid && longitude_valid; 96 | if ui 97 | .add_enabled(save_enabled, egui::Button::new("Save")) 98 | .clicked() 99 | { 100 | let mut config = app_state.config.write().unwrap(); 101 | self.copy_to_config(&mut config); 102 | app_state 103 | .controller 104 | .send(Message::Refresh("Location change")) 105 | .unwrap(); 106 | save_config(&mut config, &app_state.transitions); 107 | } 108 | } 109 | } 110 | 111 | fn coord_to_string(coord: f64) -> String { 112 | format!("{:.5}", coord) 113 | } 114 | 115 | struct SpinnerModal; 116 | 117 | impl Modal for SpinnerModal { 118 | fn render(&self, ctx: &Context, _: &mut AppState) { 119 | egui::Area::new("SpinnerModal") 120 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 121 | .show(ctx, |ui| { 122 | egui::Spinner::new().size(30.0).ui(ui); 123 | }); 124 | } 125 | } 126 | 127 | fn on_search(app_state: &mut AppState, search_string: String) { 128 | // Show the spinner modal 129 | app_state 130 | .transitions 131 | .queue_state_transition(|app| app.modal = Some(Box::new(SpinnerModal))); 132 | 133 | // Start a background thread 134 | let transitions = app_state.transitions.clone(); 135 | let proxy = app_state.main_loop.clone(); 136 | std::thread::spawn(move || { 137 | let result = Openstreetmap::new() 138 | .forward(&search_string) 139 | .map_err(|x| match x { 140 | GeocodingError::Request(r) => anyhow::Error::from(r), 141 | _ => anyhow::Error::from(x), 142 | }); 143 | match result.map(|e| e.into_iter().next()) { 144 | Ok(None) => transitions.queue_state_transition(move |app| { 145 | app.modal = Some(Box::new(MessageModal { 146 | title: "No results".to_string(), 147 | message: format!("No location could be found for '{}'", search_string), 148 | })) 149 | }), 150 | Ok(Some(p)) => { 151 | transitions.queue_state_transition(move |app| { 152 | app.location_settings_page.latitude = coord_to_string(p.y()); 153 | app.location_settings_page.longitude = coord_to_string(p.x()); 154 | app.modal = None; 155 | }); 156 | } 157 | Err(e) => { 158 | log::error!("Error searching for location: {:?}", e); 159 | transitions.queue_state_transition(move |app| { 160 | app.modal = Some(Box::new(MessageModal { 161 | title: "Error".to_string(), 162 | message: format!("Error occurred searching for location: {}", e), 163 | })) 164 | }); 165 | } 166 | } 167 | 168 | // Refresh the UI 169 | proxy 170 | .send_event(UserEvent::RepaintNow("Location Search Completed")) 171 | .unwrap(); 172 | }); 173 | } 174 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | use clap::Parser; 3 | use egui_winit::winit; 4 | use solar_screen_brightness::common::{install_logger, APP_NAME}; 5 | use solar_screen_brightness::config::SsbConfig; 6 | use solar_screen_brightness::controller::BrightnessController; 7 | use solar_screen_brightness::event_watcher::EventWatcher; 8 | use solar_screen_brightness::gui::app::SsbEguiApp; 9 | use solar_screen_brightness::gui::{NextPaint, UserEvent, WgpuWinitApp}; 10 | use solar_screen_brightness::unique::SsbUniqueInstance; 11 | use solar_screen_brightness::{tray, unique}; 12 | use std::sync::{Arc, RwLock}; 13 | use std::time::Instant; 14 | use winit::event::Event; 15 | use winit::event_loop::EventLoopBuilder; 16 | use winit::platform::run_return::EventLoopExtRunReturn; 17 | 18 | #[derive(Parser, Debug)] 19 | #[command(author, version, about)] 20 | struct Args { 21 | /// Enable debug logging 22 | #[arg(short, long)] 23 | debug: bool, 24 | /// Start minimised 25 | #[arg(short, long)] 26 | minimised: bool, 27 | } 28 | 29 | fn main() { 30 | set_panic_hook(); 31 | 32 | let args: Args = match Args::try_parse() { 33 | Ok(a) => a, 34 | Err(e) => handle_invalid_args(e), 35 | }; 36 | 37 | // Check this is the only instance running 38 | let _unique_instance = match SsbUniqueInstance::try_acquire() { 39 | Ok(i) => i, 40 | Err(unique::Error::PlatformError(e)) => { 41 | panic!("Unexpected platform error: {:#}", e); 42 | } 43 | Err(unique::Error::AlreadyRunning(i)) => { 44 | eprintln!("{} is already running, attempting to wakeup", APP_NAME); 45 | i.wakeup(); 46 | std::process::exit(1); 47 | } 48 | }; 49 | 50 | // Setup logging 51 | if let Err(e) = install_logger(args.debug, true) { 52 | panic!("Unable to install logger: {:#}", e); 53 | } 54 | 55 | log::info!( 56 | "Starting {} (GUI), version: {}", 57 | APP_NAME, 58 | env!("CARGO_PKG_VERSION") 59 | ); 60 | 61 | let config = Arc::new(RwLock::new( 62 | SsbConfig::load(None) 63 | .expect("Unable to load config") 64 | .unwrap_or_default(), 65 | )); 66 | 67 | let mut event_loop = EventLoopBuilder::::with_user_event().build(); 68 | 69 | let controller_proxy = event_loop.create_proxy(); 70 | let controller = BrightnessController::start(config.clone(), move || { 71 | controller_proxy 72 | .send_event(UserEvent::RepaintNow("Brightness Controller Update")) 73 | .unwrap(); 74 | }); 75 | 76 | let ctrlc_proxy = event_loop.create_proxy(); 77 | ctrlc::set_handler(move || ctrlc_proxy.send_event(UserEvent::Exit("ctrl-c")).unwrap()).unwrap(); 78 | 79 | let _event_watcher = EventWatcher::start(&controller, Some(&event_loop)); 80 | let _tray = tray::create(&event_loop); 81 | 82 | let app_proxy = event_loop.create_proxy(); 83 | let mut framework = WgpuWinitApp::new(event_loop.create_proxy(), args.minimised, move || { 84 | SsbEguiApp::new( 85 | app_proxy.clone(), 86 | controller.last_result.clone(), 87 | config.clone(), 88 | controller.sender.clone(), 89 | ) 90 | }); 91 | 92 | let mut next_repaint_time = Some(Instant::now()); 93 | 94 | event_loop.run_return(|event, event_loop, control_flow| { 95 | let event_result = match &event { 96 | // Platform-dependent event handlers to workaround a winit bug 97 | // See: https://github.com/rust-windowing/winit/issues/987 98 | // See: https://github.com/rust-windowing/winit/issues/1619 99 | #[cfg(target_os = "windows")] 100 | Event::RedrawEventsCleared => { 101 | next_repaint_time = None; 102 | framework.paint() 103 | } 104 | #[cfg(not(target_os = "windows"))] 105 | Event::RedrawRequested(_) => { 106 | next_repaint_time = None; 107 | framework.paint() 108 | } 109 | event => framework.on_event(event_loop, event), 110 | }; 111 | 112 | match event_result { 113 | NextPaint::Wait => {} 114 | NextPaint::RepaintNext => { 115 | next_repaint_time = Some(Instant::now()); 116 | } 117 | NextPaint::RepaintAt(repaint_time) => { 118 | next_repaint_time = 119 | Some(next_repaint_time.unwrap_or(repaint_time).min(repaint_time)); 120 | } 121 | NextPaint::Exit => { 122 | control_flow.set_exit(); 123 | return; 124 | } 125 | } 126 | 127 | if let Some(time) = next_repaint_time { 128 | if time <= Instant::now() { 129 | if let Some(window) = framework.window() { 130 | window.request_redraw(); 131 | } 132 | next_repaint_time = None; 133 | control_flow.set_poll(); 134 | } else { 135 | control_flow.set_wait_until(time); 136 | }; 137 | } else { 138 | control_flow.set_wait(); 139 | } 140 | }); 141 | } 142 | 143 | #[cfg(not(windows))] 144 | fn handle_invalid_args(err: clap::Error) -> ! { 145 | err.exit() 146 | } 147 | 148 | #[cfg(windows)] 149 | fn handle_invalid_args(err: clap::Error) -> ! { 150 | use windows::Win32::System::Console::AllocConsole; 151 | let allocated = unsafe { AllocConsole().ok().is_some() }; 152 | err.print().unwrap(); 153 | if allocated { 154 | println!("\nPress any key to close..."); 155 | console::Term::stdout().read_key().unwrap(); 156 | } 157 | std::process::exit(2); 158 | } 159 | 160 | #[cfg(not(windows))] 161 | fn set_panic_hook() { 162 | std::panic::set_hook(Box::new(move |info| { 163 | log::error!("Fatal error: {}", info); 164 | std::process::exit(1); 165 | })); 166 | } 167 | 168 | #[cfg(windows)] 169 | fn set_panic_hook() { 170 | use win32_utils::str::ToWin32Str; 171 | use windows::core::PCWSTR; 172 | use windows::Win32::Foundation::HWND; 173 | use windows::Win32::UI::WindowsAndMessaging::MessageBoxW; 174 | use windows::Win32::UI::WindowsAndMessaging::{MB_ICONSTOP, MB_OK}; 175 | 176 | std::panic::set_hook(Box::new(move |info| unsafe { 177 | log::error!("Fatal error: {}", info); 178 | let title = "Fatal Error".to_wchar(); 179 | let text = format!("{}", info).to_wchar(); 180 | MessageBoxW( 181 | HWND::default(), 182 | PCWSTR(text.as_ptr()), 183 | PCWSTR(title.as_ptr()), 184 | MB_OK | MB_ICONSTOP, 185 | ); 186 | std::process::exit(1); 187 | })); 188 | } 189 | -------------------------------------------------------------------------------- /src/apply.rs: -------------------------------------------------------------------------------- 1 | use crate::calculator::calculate_brightness; 2 | use crate::config::{BrightnessValues, Location, MonitorOverride, MonitorProperty}; 3 | use brightness::blocking::{Brightness, BrightnessDevice}; 4 | use itertools::Itertools; 5 | use serde::Serialize; 6 | use std::collections::HashMap; 7 | use std::time::{SystemTime, UNIX_EPOCH}; 8 | use sunrise_sunset_calculator::SunriseSunsetParameters; 9 | use wildmatch::WildMatch; 10 | 11 | #[derive(Debug, Serialize)] 12 | pub struct ApplyResults { 13 | pub unknown_devices: Vec, 14 | pub monitors: Vec, 15 | pub sun: SunriseSunsetResult, 16 | } 17 | 18 | #[derive(Debug, Serialize)] 19 | pub struct SunriseSunsetResult { 20 | pub set: i64, 21 | pub rise: i64, 22 | pub visible: bool, 23 | } 24 | 25 | impl From for SunriseSunsetResult { 26 | fn from(value: sunrise_sunset_calculator::SunriseSunsetResult) -> Self { 27 | Self { 28 | set: value.set, 29 | rise: value.rise, 30 | visible: value.visible, 31 | } 32 | } 33 | } 34 | 35 | #[derive(Debug, Serialize)] 36 | pub struct MonitorResult { 37 | pub properties: MonitorProperties, 38 | pub brightness: Option, 39 | pub error: Option, 40 | } 41 | 42 | #[derive(Debug, Serialize)] 43 | pub struct MonitorProperties { 44 | pub device_name: String, 45 | #[cfg(windows)] 46 | pub device_description: String, 47 | #[cfg(windows)] 48 | pub device_key: String, 49 | #[cfg(windows)] 50 | pub device_path: String, 51 | } 52 | 53 | #[derive(Debug, Serialize)] 54 | pub struct BrightnessDetails { 55 | pub expiry_time: Option, 56 | pub brightness: u32, 57 | pub brightness_day: u32, 58 | pub brightness_night: u32, 59 | } 60 | 61 | pub struct MonitorOverrideCompiled { 62 | pub pattern: WildMatch, 63 | pub key: MonitorProperty, 64 | pub brightness: Option, 65 | } 66 | 67 | impl From<&MonitorOverride> for MonitorOverrideCompiled { 68 | fn from(value: &MonitorOverride) -> Self { 69 | Self { 70 | pattern: WildMatch::new(&value.pattern), 71 | key: value.key, 72 | brightness: value.brightness, 73 | } 74 | } 75 | } 76 | 77 | /// Find the first override that matches this monitor's properties 78 | fn match_monitor<'o>( 79 | overrides: &'o [MonitorOverrideCompiled], 80 | monitor: &MonitorProperties, 81 | ) -> Option<&'o MonitorOverrideCompiled> { 82 | let map = monitor.to_map(); 83 | for o in overrides { 84 | if let Some(value) = map.get(&o.key) { 85 | if o.pattern.matches(value) { 86 | return Some(o); 87 | } 88 | } 89 | } 90 | None 91 | } 92 | 93 | pub fn apply_brightness( 94 | brightness_day: u32, 95 | brightness_night: u32, 96 | transition_mins: u32, 97 | location: Location, 98 | overrides: Vec, 99 | ) -> ApplyResults { 100 | let overrides = overrides 101 | .iter() 102 | .map(MonitorOverrideCompiled::from) 103 | .collect::>(); 104 | let epoch_time_now = SystemTime::now() 105 | .duration_since(UNIX_EPOCH) 106 | .unwrap() 107 | .as_secs() as i64; 108 | let sun = SunriseSunsetParameters::new(epoch_time_now, location.latitude, location.longitude) 109 | .calculate() 110 | .unwrap(); 111 | log::debug!("Now: {}, Sun: {:?}", epoch_time_now, sun); 112 | 113 | let mut failed_monitors = vec![]; 114 | let mut monitors = vec![]; 115 | brightness::blocking::brightness_devices().for_each(|m| match m { 116 | Ok(v) => monitors.push(v), 117 | Err(e) => failed_monitors.push(e), 118 | }); 119 | log::debug!("Monitors: {:?}, Errors: {:?}", monitors, failed_monitors); 120 | 121 | let monitor_results = monitors 122 | .into_iter() 123 | .map(|m| { 124 | let properties = MonitorProperties::from_device(&m); 125 | let monitor_values = match match_monitor(&overrides, &properties) { 126 | None => Some(BrightnessValues { 127 | brightness_day, 128 | brightness_night, 129 | }), 130 | Some(o) => o.brightness, 131 | }; 132 | 133 | if let Some(BrightnessValues { 134 | brightness_day, 135 | brightness_night, 136 | }) = monitor_values 137 | { 138 | let brightness = calculate_brightness( 139 | brightness_day, 140 | brightness_night, 141 | transition_mins, 142 | &sun, 143 | epoch_time_now, 144 | ); 145 | log::debug!( 146 | "Computed brightness for '{}' = {:?} (day={}) (night={})", 147 | properties.device_name, 148 | brightness, 149 | brightness_day, 150 | brightness_night 151 | ); 152 | 153 | let error = m.set(brightness.brightness).err(); 154 | if let Some(err) = error.as_ref() { 155 | log::error!( 156 | "Failed to set brightness for '{}': {:?}", 157 | properties.device_name, 158 | err 159 | ); 160 | } else { 161 | log::info!( 162 | "Successfully set brightness for '{}' to {}%", 163 | properties.device_name, 164 | brightness.brightness 165 | ); 166 | } 167 | 168 | MonitorResult { 169 | properties, 170 | brightness: Some(BrightnessDetails { 171 | expiry_time: brightness.expiry_time, 172 | brightness: brightness.brightness, 173 | brightness_day, 174 | brightness_night, 175 | }), 176 | error: error.map(|e| e.to_string()), 177 | } 178 | } else { 179 | log::info!( 180 | "Skipping '{}' due to monitor override", 181 | properties.device_name 182 | ); 183 | MonitorResult { 184 | properties, 185 | brightness: None, 186 | error: None, 187 | } 188 | } 189 | }) 190 | .sorted_by_key(|m| m.properties.device_name.clone()) 191 | .collect::>(); 192 | 193 | ApplyResults { 194 | unknown_devices: failed_monitors.into_iter().map(|f| f.to_string()).collect(), 195 | monitors: monitor_results, 196 | sun: sun.into(), 197 | } 198 | } 199 | 200 | impl MonitorProperties { 201 | fn from_device(device: &BrightnessDevice) -> Self { 202 | #[cfg(windows)] 203 | use brightness::blocking::windows::BrightnessExt; 204 | Self { 205 | device_name: device.device_name().unwrap(), 206 | #[cfg(windows)] 207 | device_description: device.device_description().unwrap(), 208 | #[cfg(windows)] 209 | device_key: device.device_registry_key().unwrap(), 210 | #[cfg(windows)] 211 | device_path: device.device_path().unwrap(), 212 | } 213 | } 214 | 215 | pub fn to_map(&self) -> HashMap { 216 | let mut map = HashMap::<_, &str>::new(); 217 | map.insert(MonitorProperty::DeviceName, &self.device_name); 218 | #[cfg(windows)] 219 | { 220 | map.insert(MonitorProperty::DeviceDescription, &self.device_description); 221 | map.insert(MonitorProperty::DeviceKey, &self.device_key); 222 | map.insert(MonitorProperty::DevicePath, &self.device_path); 223 | } 224 | map 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/calculator.rs: -------------------------------------------------------------------------------- 1 | //! Algorithm for computing the brightness percentage for a given time 2 | 3 | use sunrise_sunset_calculator::SunriseSunsetResult; 4 | 5 | #[derive(Debug)] 6 | pub struct BrightnessResult { 7 | /// Unix time that the brightness should be re-calculated 8 | pub expiry_time: Option, 9 | /// Brightness percentage for the current time 10 | pub brightness: u32, 11 | } 12 | 13 | fn sine_curve( 14 | time_now: i64, 15 | transition: u32, 16 | event_time: i64, 17 | decreasing: bool, 18 | low_brightness: u32, 19 | high_brightness: u32, 20 | ) -> BrightnessResult { 21 | // We need to transform the sine function 22 | // Scale the height to the difference between min and max brightness 23 | let y_multiplier = (high_brightness - low_brightness) as f64 / 2.0; 24 | // Shift upwards to the midpoint brightness 25 | let y_offset = y_multiplier + low_brightness as f64; 26 | // Scale half a cycle to be equal to the transition time 27 | let mut x_multiplier = std::f64::consts::PI / transition as f64; 28 | // Flip the curve to make it a decreasing function 29 | if decreasing { 30 | x_multiplier = -x_multiplier; 31 | } 32 | // Shift rightwards to centre on the sunrise/sunset event 33 | let x_offset = (time_now - event_time) as f64; 34 | // Compute brightness 35 | let brightness = (y_multiplier * (x_multiplier * x_offset).sin()) + y_offset; 36 | let brightness = brightness.round() as u32; // round to nearest integer brightness 37 | 38 | // Work out the expiry time; when the brightness will change to the next integer value 39 | let mut next_update_brightness = if decreasing { 40 | brightness - 1 41 | } else { 42 | brightness + 1 43 | }; 44 | if next_update_brightness > high_brightness { 45 | next_update_brightness = high_brightness; 46 | } else if next_update_brightness < low_brightness { 47 | next_update_brightness = low_brightness; 48 | } 49 | let expiry_time = if time_now == event_time { 50 | time_now + 1 // Don't get stuck into an infinite loop when exactly on the boundary 51 | } else { 52 | // Inverse of the sine function at next_update_brightness 53 | let asin = ((next_update_brightness as f64 - y_offset) / y_multiplier).asin(); 54 | let expiry_offset = (asin / x_multiplier).round(); 55 | expiry_offset as i64 + event_time 56 | }; 57 | BrightnessResult { 58 | expiry_time: Some(expiry_time), 59 | brightness, 60 | } 61 | } 62 | 63 | pub fn calculate_brightness( 64 | brightness_day: u32, 65 | brightness_night: u32, 66 | transition_mins: u32, 67 | sun: &SunriseSunsetResult, 68 | time_now: i64, 69 | ) -> BrightnessResult { 70 | // Special case where there is no difference in brightness 71 | if brightness_night == brightness_day { 72 | return BrightnessResult { 73 | expiry_time: None, 74 | brightness: brightness_day, 75 | }; 76 | } 77 | 78 | let low = brightness_day.min(brightness_night); 79 | let high = brightness_day.max(brightness_night); 80 | let transition_secs = transition_mins * 60; //time for transition from low to high 81 | let half_transition_secs = (transition_secs / 2) as i64; 82 | 83 | let (time_a, time_b) = if sun.visible { 84 | // Daytime 85 | ( 86 | sun.rise + half_transition_secs, // When the sun rose this morning + transition 87 | sun.set - half_transition_secs, 88 | ) // Whe the sun sets this evening - transition 89 | } else { 90 | // Nighttime 91 | ( 92 | sun.set + half_transition_secs, // When the sun set at the start of night + transition 93 | sun.rise - half_transition_secs, 94 | ) // When the sun will rise again - transition 95 | }; 96 | 97 | // If nighttime brightness is greater than day (weird!) then we need to flip around. 98 | let backwards = brightness_night > brightness_day; 99 | 100 | if time_now < time_a { 101 | let event = if sun.visible { sun.rise } else { sun.set }; 102 | sine_curve( 103 | time_now, 104 | transition_secs, 105 | event, 106 | !(sun.visible ^ backwards), 107 | low, 108 | high, 109 | ) 110 | } else if time_now >= time_b { 111 | // Must be greater or equal to or it would get stuck in a loop 112 | let event = if sun.visible { sun.set } else { sun.rise }; 113 | sine_curve( 114 | time_now, 115 | transition_secs, 116 | event, 117 | sun.visible ^ backwards, 118 | low, 119 | high, 120 | ) 121 | } else { 122 | // Time is >=A and midpoint); //~74 156 | 157 | //At sunset it should be half way between the day and night brightness 158 | let r = sine_curve(set, t_secs, set, true, low, high); 159 | assert_eq!(midpoint, r.brightness); //60 160 | 161 | //At end of the transition it should equal the night brightness 162 | let transition_end = Utc.ymd(2018, 12, 2).and_hms(16, 30, 0).timestamp(); 163 | let r = sine_curve(transition_end, t_secs, set, true, low, high); 164 | assert_eq!(r.brightness, low); // 40 165 | } 166 | 167 | #[test] 168 | fn test_sunrise_sine_curve() { 169 | let low = 35; 170 | let high = 76; 171 | let t_secs = 40 * 60; //40 minutes 172 | let rise = Utc.ymd(2018, 12, 2).and_hms(8, 0, 0).timestamp(); // Fictional 173 | let midpoint = (low as f64 + ((high - low) as f64 / 2.0)).round() as u32; 174 | 175 | //At start of the transition it should equal the night brightness 176 | let start_of_transition = Utc.ymd(2018, 12, 2).and_hms(7, 40, 0).timestamp(); 177 | let r = sine_curve(start_of_transition, t_secs, rise, false, low, high); 178 | assert_eq!(low, r.brightness); // 35 179 | 180 | //Test part way between transition. It should be greater than night brighness. But less than the midpoint because it is not yet sunrise 181 | let before_sunrise = Utc.ymd(2018, 12, 2).and_hms(7, 50, 0).timestamp(); 182 | let r = sine_curve(before_sunrise, t_secs, rise, false, low, high); 183 | assert!(r.brightness > low && r.brightness < midpoint); //~41 184 | 185 | //At sunrise it should be half way between the day and night brightness 186 | let r = sine_curve(rise, t_secs, rise, false, low, high); 187 | assert_eq!(midpoint, r.brightness); //55.5 is rounded to 56 188 | 189 | //At end of the transition it should equal the daytime brightness 190 | let end_of_transition = Utc.ymd(2018, 12, 2).and_hms(8, 20, 0).timestamp(); 191 | let r = sine_curve(end_of_transition, t_secs, rise, false, low, high); 192 | assert_eq!(high, r.brightness); // 76 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/gui/app.rs: -------------------------------------------------------------------------------- 1 | use crate::apply::ApplyResults; 2 | use crate::config::SsbConfig; 3 | use crate::controller::Message; 4 | use crate::gui::brightness_settings::BrightnessSettingsPage; 5 | use crate::gui::help::HelpPage; 6 | use crate::gui::location_settings::LocationSettingsPage; 7 | use crate::gui::monitor_overrides::MonitorOverridePage; 8 | use crate::gui::status::StatusPage; 9 | use crate::gui::UserEvent; 10 | use egui::{Align, Color32, Layout, ScrollArea}; 11 | use egui_winit::winit::event_loop::EventLoopProxy; 12 | use enum_iterator::Sequence; 13 | use std::collections::VecDeque; 14 | use std::sync::mpsc::Sender; 15 | use std::sync::{Arc, Mutex, RwLock}; 16 | 17 | pub const SPACING: f32 = 10.0; 18 | 19 | pub trait Page { 20 | fn render(&mut self, ui: &mut egui::Ui, app_state: &mut AppState); 21 | } 22 | 23 | pub struct SsbEguiApp { 24 | selected_page: PageId, 25 | brightness_settings_page: BrightnessSettingsPage, 26 | pub location_settings_page: LocationSettingsPage, 27 | help_page: HelpPage, 28 | monitor_override_page: MonitorOverridePage, 29 | context: AppState, 30 | pub modal: Option>, 31 | } 32 | 33 | pub struct AppState { 34 | pub main_loop: EventLoopProxy, 35 | pub config: Arc>, 36 | pub controller: Sender, 37 | pub results: Arc>>, 38 | pub transitions: Transitions, 39 | } 40 | 41 | pub type TransitionFn = Box; 42 | 43 | #[derive(Default, Clone)] 44 | pub struct Transitions(Arc>>); 45 | 46 | impl Transitions { 47 | pub fn queue_state_transition( 48 | &self, 49 | transition: F, 50 | ) { 51 | self.0.lock().unwrap().push_back(Box::new(transition)); 52 | } 53 | } 54 | 55 | #[derive(Copy, Clone, Debug, PartialEq, Sequence)] 56 | enum PageId { 57 | Status, 58 | BrightnessSettings, 59 | LocationSettings, 60 | MonitorOverrides, 61 | Help, 62 | } 63 | 64 | impl PageId { 65 | fn title(self) -> &'static str { 66 | match self { 67 | PageId::Status => "Status", 68 | PageId::BrightnessSettings => "Brightness Settings", 69 | PageId::LocationSettings => "Location Settings", 70 | PageId::Help => "Help", 71 | PageId::MonitorOverrides => "Monitor Overrides", 72 | } 73 | } 74 | 75 | fn icon(self) -> &'static str { 76 | match self { 77 | PageId::Status => "ℹ", 78 | PageId::BrightnessSettings => "🔅", 79 | PageId::LocationSettings => "🌐", 80 | PageId::Help => "❔", 81 | PageId::MonitorOverrides => "💻", 82 | } 83 | } 84 | } 85 | 86 | pub trait Modal { 87 | fn render(&self, ctx: &egui::Context, app_state: &mut AppState); 88 | } 89 | 90 | pub struct ExitModal; 91 | 92 | impl Modal for ExitModal { 93 | fn render(&self, ctx: &egui::Context, app_state: &mut AppState) { 94 | egui::Window::new("Are you sure?") 95 | .collapsible(false) 96 | .resizable(true) 97 | .show(ctx, |ui| { 98 | ui.label( 99 | "Exiting the application will stop the brightness being automatically updated", 100 | ); 101 | ui.with_layout(Layout::left_to_right(Align::LEFT), |ui| { 102 | if ui.button("Exit").clicked() { 103 | app_state 104 | .main_loop 105 | .send_event(UserEvent::Exit("Exit Modal")) 106 | .unwrap(); 107 | } 108 | if ui.button("Cancel").clicked() { 109 | app_state.transitions.queue_state_transition(|app| { 110 | app.modal = None; 111 | }); 112 | }; 113 | }); 114 | }); 115 | } 116 | } 117 | 118 | pub struct MessageModal { 119 | pub title: String, 120 | pub message: String, 121 | } 122 | 123 | impl Modal for MessageModal { 124 | fn render(&self, ctx: &egui::Context, app_state: &mut AppState) { 125 | egui::Window::new(&self.title) 126 | .collapsible(false) 127 | .resizable(true) 128 | .show(ctx, |ui| { 129 | ui.label(&self.message); 130 | if ui.button("Ok").clicked() { 131 | app_state.transitions.queue_state_transition(|app| { 132 | app.modal = None; 133 | }); 134 | }; 135 | }); 136 | } 137 | } 138 | 139 | impl SsbEguiApp { 140 | pub fn new( 141 | main_loop: EventLoopProxy, 142 | results: Arc>>, 143 | config: Arc>, 144 | controller: Sender, 145 | ) -> Self { 146 | let config_read = config.read().unwrap(); 147 | SsbEguiApp { 148 | selected_page: PageId::Status, 149 | brightness_settings_page: BrightnessSettingsPage::from_config(&config_read), 150 | location_settings_page: LocationSettingsPage::from_config(&config_read), 151 | monitor_override_page: MonitorOverridePage::from_config(&config_read), 152 | modal: None, 153 | context: AppState { 154 | main_loop, 155 | config: config.clone(), 156 | controller, 157 | results, 158 | transitions: Default::default(), 159 | }, 160 | help_page: Default::default(), 161 | } 162 | } 163 | 164 | pub fn update(&mut self, ctx: &egui::Context) { 165 | let queue = self.context.transitions.0.clone(); 166 | while let Some(transition) = queue.lock().unwrap().pop_front() { 167 | (transition)(self); 168 | } 169 | 170 | if let Some(modal) = &self.modal { 171 | modal.render(ctx, &mut self.context); 172 | } 173 | 174 | egui::SidePanel::left("MenuPanel") 175 | .resizable(false) 176 | .show(ctx, |ui| { 177 | ui.set_enabled(self.modal.is_none()); 178 | self.render_menu_panel(ui); 179 | }); 180 | 181 | egui::CentralPanel::default().show(ctx, |ui| { 182 | ui.set_enabled(self.modal.is_none()); 183 | self.render_main(ui); 184 | }); 185 | } 186 | 187 | fn render_menu_panel(&mut self, ui: &mut egui::Ui) { 188 | ScrollArea::vertical().show(ui, |ui| { 189 | ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| { 190 | enum_iterator::all::().for_each(|page| { 191 | let text = format!("{} {}", page.icon(), page.title()); 192 | ui.selectable_value(&mut self.selected_page, page, text); 193 | }); 194 | 195 | ui.separator(); 196 | 197 | if ui 198 | .add(egui::Button::new("❌ Close Window").fill(Color32::TRANSPARENT)) 199 | .clicked() 200 | { 201 | self.context 202 | .main_loop 203 | .send_event(UserEvent::CloseWindow("Menu Button")) 204 | .unwrap(); 205 | } 206 | 207 | if ui 208 | .add(egui::Button::new("⏹ Exit Application").fill(Color32::TRANSPARENT)) 209 | .clicked() 210 | { 211 | self.modal = Some(Box::new(ExitModal)); 212 | } 213 | }); 214 | }); 215 | } 216 | 217 | fn render_main(&mut self, ui: &mut egui::Ui) { 218 | ScrollArea::both().show(ui, |ui| { 219 | ui.heading(self.selected_page.title()); 220 | ui.add_space(SPACING); 221 | match self.selected_page { 222 | PageId::Status => StatusPage.render(ui, &mut self.context), 223 | PageId::BrightnessSettings => { 224 | self.brightness_settings_page.render(ui, &mut self.context) 225 | } 226 | PageId::LocationSettings => { 227 | self.location_settings_page.render(ui, &mut self.context) 228 | } 229 | PageId::Help => self.help_page.render(ui, &mut self.context), 230 | PageId::MonitorOverrides => { 231 | self.monitor_override_page.render(ui, &mut self.context) 232 | } 233 | } 234 | }); 235 | } 236 | } 237 | 238 | pub fn set_red_widget_border(ui: &mut egui::Ui) { 239 | ui.style_mut().visuals.widgets.inactive.bg_stroke.color = egui::Color32::RED; 240 | ui.style_mut().visuals.widgets.inactive.bg_stroke.width = 1.0; 241 | ui.style_mut().visuals.widgets.hovered.bg_stroke.color = egui::Color32::RED; 242 | } 243 | 244 | pub fn save_config(config: &mut SsbConfig, transitions: &Transitions) { 245 | if let Err(e) = config.save() { 246 | log::error!("Unable to save config: {:#}", e); 247 | transitions.queue_state_transition(move |app| { 248 | app.modal = Some(Box::new(MessageModal { 249 | title: "Error".to_string(), 250 | message: format!("Unable to save config: {}", e), 251 | })); 252 | }); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/gui/brightness_settings.rs: -------------------------------------------------------------------------------- 1 | use crate::calculator::calculate_brightness; 2 | use crate::config::{Location, SsbConfig}; 3 | use crate::controller::Message; 4 | use crate::gui::app::{save_config, AppState, Page, SPACING}; 5 | use chrono::{Duration, DurationRound, TimeZone}; 6 | use egui::plot::{uniform_grid_spacer, GridInput, GridMark, Line, PlotBounds}; 7 | use egui::widgets::plot::Plot; 8 | use std::mem::take; 9 | use std::time::{Instant, SystemTime, UNIX_EPOCH}; 10 | use sunrise_sunset_calculator::SunriseSunsetParameters; 11 | use validator::Validate; 12 | 13 | pub struct BrightnessSettingsPage { 14 | brightness_day: u32, 15 | brightness_night: u32, 16 | transition_mins: u32, 17 | plot: Option, 18 | } 19 | 20 | struct PlotData { 21 | points: Vec<[f64; 2]>, 22 | generated_at: SystemTime, 23 | brightness_day: u32, 24 | brightness_night: u32, 25 | transition_mins: u32, 26 | location: Location, 27 | } 28 | 29 | impl PlotData { 30 | fn is_stale(&self, config: &SsbConfig) -> bool { 31 | if let Some(location) = config.location { 32 | if location != self.location { 33 | return true; 34 | } 35 | } 36 | if config.brightness_day != self.brightness_day { 37 | return true; 38 | } 39 | if config.brightness_night != self.brightness_night { 40 | return true; 41 | } 42 | if config.transition_mins != self.transition_mins { 43 | return true; 44 | } 45 | let age = SystemTime::now().duration_since(self.generated_at).unwrap(); 46 | if chrono::Duration::from_std(age).unwrap().num_minutes() > 5 { 47 | return true; 48 | } 49 | false 50 | } 51 | } 52 | 53 | impl BrightnessSettingsPage { 54 | pub fn from_config(config: &SsbConfig) -> Self { 55 | Self { 56 | brightness_day: config.brightness_day, 57 | brightness_night: config.brightness_night, 58 | transition_mins: config.transition_mins, 59 | plot: None, 60 | } 61 | } 62 | 63 | fn copy_to_config(&self, config: &mut SsbConfig) { 64 | config.brightness_night = self.brightness_night; 65 | config.brightness_day = self.brightness_day; 66 | config.transition_mins = self.transition_mins; 67 | assert!(config.validate().is_ok()) 68 | } 69 | } 70 | 71 | impl Page for BrightnessSettingsPage { 72 | fn render(&mut self, ui: &mut egui::Ui, app_state: &mut AppState) { 73 | egui::Grid::new("brightness_settings") 74 | .num_columns(2) 75 | .show(ui, |ui| { 76 | 77 | ui.label("Day Brightness"); 78 | ui.add(egui::Slider::new(&mut self.brightness_day, 0u32..=100u32).suffix("%")); 79 | ui.end_row(); 80 | 81 | ui.label("Night Brightness"); 82 | ui.add(egui::Slider::new(&mut self.brightness_night, 0u32..=100u32).suffix("%")); 83 | ui.end_row(); 84 | 85 | ui.label("Transition Minutes").on_hover_text("How long it takes to transition between day and night brightness at sunset/sunrise"); 86 | ui.add(egui::Slider::new(&mut self.transition_mins, 0u32..=360u32).suffix("min")); 87 | ui.end_row(); 88 | 89 | }); 90 | ui.add_space(SPACING); 91 | ui.with_layout(egui::Layout::left_to_right(egui::Align::LEFT), |ui| { 92 | if ui.button("Apply").clicked() { 93 | let mut config = app_state.config.write().unwrap(); 94 | self.copy_to_config(&mut config); 95 | app_state 96 | .controller 97 | .send(Message::Refresh("Brightness change")) 98 | .unwrap(); 99 | } 100 | if ui.button("Save").clicked() { 101 | let mut config = app_state.config.write().unwrap(); 102 | self.copy_to_config(&mut config); 103 | app_state 104 | .controller 105 | .send(Message::Refresh("Brightness change")) 106 | .unwrap(); 107 | save_config(&mut config, &app_state.transitions); 108 | }; 109 | }); 110 | 111 | ui.add_space(SPACING); 112 | self.render_plot(ui, app_state); 113 | } 114 | } 115 | 116 | const LINE_NAME: &str = "Brightness"; 117 | 118 | impl BrightnessSettingsPage { 119 | fn render_plot(&mut self, ui: &mut egui::Ui, app_state: &mut AppState) { 120 | let config = app_state.config.read().unwrap(); 121 | 122 | if let Some(location) = config.location { 123 | self.plot = Some(match take(&mut self.plot) { 124 | None => generate_plot_data( 125 | location, 126 | config.brightness_day, 127 | config.brightness_night, 128 | config.transition_mins, 129 | ), 130 | Some(x) if x.is_stale(&config) => generate_plot_data( 131 | location, 132 | config.brightness_day, 133 | config.brightness_night, 134 | config.transition_mins, 135 | ), 136 | Some(x) => x, 137 | }); 138 | } 139 | 140 | if let Some(plot) = &self.plot { 141 | ui.separator(); 142 | ui.add_space(SPACING); 143 | 144 | let first = plot.points.first().unwrap()[0]; 145 | let last = plot.points.last().unwrap()[0]; 146 | let line = Line::new(plot.points.clone()) 147 | .name(LINE_NAME) 148 | .highlight(true); 149 | 150 | Plot::new("brightness_curve") 151 | .allow_drag(false) 152 | .allow_zoom(false) 153 | .allow_scroll(false) 154 | .y_grid_spacer(uniform_grid_spacer(|_| [100.0, 20.0, 10.0])) 155 | .y_axis_formatter(|val, _| format!("{}%", val)) 156 | .x_grid_spacer(x_grid_spacer) 157 | .label_formatter(|name, point| { 158 | if name == LINE_NAME { 159 | format!("{}\nBrightness {}%", convert_time(point.x), point.y) 160 | } else { 161 | String::new() 162 | } 163 | }) 164 | .x_axis_formatter(|val, _| convert_time(val)) 165 | .show(ui, |plot_ui| { 166 | plot_ui.set_plot_bounds(PlotBounds::from_min_max([first, -5.0], [last, 105.0])); 167 | plot_ui.line(line) 168 | }); 169 | } 170 | } 171 | } 172 | 173 | fn convert_time(time: f64) -> String { 174 | let time = chrono::Local.timestamp_opt(time as i64, 0).unwrap(); 175 | time.format("%I:%M %P").to_string() 176 | } 177 | 178 | const HOURS: i64 = 6; 179 | 180 | // spaces the x-axis hourly 181 | fn x_grid_spacer(input: GridInput) -> Vec { 182 | let min_unix = input.bounds.0 as i64; 183 | let max_unix = input.bounds.1 as i64; 184 | let min_local = chrono::Local.timestamp_opt(min_unix, 0).unwrap(); 185 | let lowest_whole_hour = min_local.duration_trunc(Duration::hours(HOURS)).unwrap(); 186 | 187 | let mut output = Vec::new(); 188 | let hours_unix = HOURS * 3600; 189 | 190 | let mut rounded_unix = lowest_whole_hour.timestamp(); 191 | while rounded_unix < max_unix { 192 | if rounded_unix >= min_unix { 193 | output.push(GridMark { 194 | value: rounded_unix as f64, 195 | step_size: hours_unix as f64, 196 | }); 197 | } 198 | rounded_unix += hours_unix; 199 | } 200 | output 201 | } 202 | 203 | fn generate_plot_data( 204 | location: Location, 205 | brightness_day: u32, 206 | brightness_night: u32, 207 | transition_mins: u32, 208 | ) -> PlotData { 209 | log::debug!("Generating plot..."); 210 | let timer_start = Instant::now(); 211 | 212 | let now = SystemTime::now(); 213 | let graph_start = (now - Duration::hours(2).to_std().unwrap()) 214 | .duration_since(UNIX_EPOCH) 215 | .unwrap() 216 | .as_secs() as i64; 217 | let graph_end = (now + Duration::hours(22).to_std().unwrap()) 218 | .duration_since(UNIX_EPOCH) 219 | .unwrap() 220 | .as_secs() as i64; 221 | 222 | let mut points = Vec::new(); 223 | let mut current = graph_start; 224 | 225 | while current <= graph_end { 226 | let sun = SunriseSunsetParameters::new(current, location.latitude, location.longitude) 227 | .calculate() 228 | .unwrap(); 229 | let brightness = calculate_brightness( 230 | brightness_day, 231 | brightness_night, 232 | transition_mins, 233 | &sun, 234 | current, 235 | ); 236 | let next_time = brightness.expiry_time.unwrap_or(graph_end).min(graph_end); 237 | 238 | // Add some extra points in the "flat" zone to allow cursor to snap to the line 239 | // This is a bit of a hack, assuming if expiry is greater than 30 minutes, 240 | // to be completely accurate we would need to look ahead at the next calculation. 241 | if brightness.expiry_time.unwrap_or(i64::MAX) - current > 1800 { 242 | for second in num::range_step(current, next_time, 240) { 243 | points.push([second as f64, brightness.brightness as f64]); 244 | } 245 | } else { 246 | points.push([current as f64, brightness.brightness as f64]); 247 | } 248 | 249 | if current == graph_end { 250 | break; 251 | } 252 | current = next_time; 253 | } 254 | 255 | log::debug!( 256 | "Plot took {:?} {} points", 257 | timer_start.elapsed(), 258 | points.len() 259 | ); 260 | 261 | PlotData { 262 | points, 263 | generated_at: now, 264 | brightness_day, 265 | brightness_night, 266 | transition_mins, 267 | location, 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/gui/monitor_overrides.rs: -------------------------------------------------------------------------------- 1 | use crate::apply::ApplyResults; 2 | use crate::config::{BrightnessValues, MonitorOverride, MonitorProperty, SsbConfig}; 3 | use crate::controller::Message; 4 | use crate::gui::app::{save_config, set_red_widget_border, AppState, Page, SPACING}; 5 | use crate::gui::status::no_devices_found; 6 | use ellipse::Ellipse; 7 | 8 | const MAX_OVERRIDES: usize = 10; 9 | 10 | pub struct MonitorOverridePage { 11 | overrides: Vec, 12 | } 13 | 14 | struct Override { 15 | key: MonitorProperty, 16 | pattern: String, 17 | disable: bool, 18 | day: u32, 19 | night: u32, 20 | } 21 | 22 | impl Override { 23 | fn is_valid(&self) -> bool { 24 | !self.pattern.is_empty() 25 | } 26 | } 27 | 28 | impl Page for MonitorOverridePage { 29 | fn render(&mut self, ui: &mut egui::Ui, app_state: &mut AppState) { 30 | { 31 | let results = app_state.results.read().unwrap(); 32 | if let Some(results) = results.as_ref() { 33 | self.render_monitors(ui, results); 34 | ui.add_space(SPACING); 35 | ui.separator(); 36 | } 37 | } 38 | ui.add_space(SPACING); 39 | self.render_overrides(ui, app_state); 40 | } 41 | } 42 | 43 | impl MonitorOverridePage { 44 | pub fn from_config(config: &SsbConfig) -> Self { 45 | let overrides = config 46 | .overrides 47 | .iter() 48 | .map(|o| Override { 49 | key: o.key, 50 | pattern: o.pattern.clone(), 51 | disable: o.brightness.is_none(), 52 | day: o.brightness.map(|b| b.brightness_day).unwrap_or(100), 53 | night: o.brightness.map(|b| b.brightness_night).unwrap_or(60), 54 | }) 55 | .collect(); 56 | Self { overrides } 57 | } 58 | 59 | fn copy_to_config(&self, config: &mut SsbConfig) { 60 | config.overrides = self 61 | .overrides 62 | .iter() 63 | .map(|o| MonitorOverride { 64 | pattern: o.pattern.clone(), 65 | key: o.key, 66 | brightness: (!o.disable).then_some(BrightnessValues { 67 | brightness_day: o.day, 68 | brightness_night: o.night, 69 | }), 70 | }) 71 | .collect(); 72 | } 73 | 74 | fn is_valid(&self) -> bool { 75 | self.overrides.iter().all(|o| o.is_valid()) 76 | } 77 | 78 | fn render_monitors(&mut self, ui: &mut egui::Ui, results: &ApplyResults) { 79 | ui.label(egui::RichText::new("Monitor Properties").size(14.0)); 80 | ui.add_space(SPACING); 81 | 82 | if results.monitors.is_empty() { 83 | ui.add(no_devices_found()); 84 | return; 85 | } 86 | 87 | let properties = enum_iterator::all::().collect::>(); 88 | 89 | egui::ScrollArea::horizontal().show(ui, |ui| { 90 | egui::Grid::new("monitors_properties_grid") 91 | .striped(true) 92 | .num_columns(properties.len()) 93 | .show(ui, |ui| { 94 | // Header row 95 | for p in &properties { 96 | ui.label(p.as_str()); 97 | } 98 | ui.end_row(); 99 | // Monitor rows 100 | for monitor in &results.monitors { 101 | let property_map = monitor.properties.to_map(); 102 | for property in &properties { 103 | if let Some(value) = property_map.get(property) { 104 | let value = value.to_string(); 105 | let button = egui::Button::new(value.as_str().truncate_ellipse(24)) 106 | .frame(false); 107 | let hover = |ui: &mut egui::Ui| { 108 | ui.label(format!("\"{}\"", value)); 109 | ui.label("Click to copy 📋"); 110 | }; 111 | if ui.add(button).on_hover_ui(hover).clicked() { 112 | ui.output_mut(|o| o.copied_text = value); 113 | } 114 | } else { 115 | ui.label(""); 116 | } 117 | } 118 | ui.end_row(); 119 | } 120 | }); 121 | }); 122 | } 123 | 124 | fn render_overrides(&mut self, ui: &mut egui::Ui, app_state: &mut AppState) { 125 | ui.label(egui::RichText::new("Overrides").size(14.0)); 126 | ui.add_space(SPACING); 127 | ui.label("Create monitor overrides that match one of the above monitor properties."); 128 | ui.label("The first override that is matched will be applied to the monitor."); 129 | ui.add_space(SPACING); 130 | 131 | let properties = enum_iterator::all::().collect::>(); 132 | 133 | if !self.overrides.is_empty() { 134 | egui::Grid::new("overrides_grid") 135 | .striped(true) 136 | .num_columns(8) 137 | .min_col_width(0.0) 138 | .show(ui, |ui| { 139 | ui.label(""); 140 | ui.label(""); 141 | ui.label("Property"); 142 | ui.label("Pattern") 143 | .on_hover_text("You can use * as a wildcard match"); 144 | ui.label("Disable") 145 | .on_hover_text("Disable automatic brightness"); 146 | ui.label("Day"); 147 | ui.label("Night"); 148 | ui.label(""); 149 | ui.end_row(); 150 | 151 | let last_idx = self.overrides.len() - 1; 152 | for idx in 0..self.overrides.len() { 153 | if ui 154 | .add_enabled(idx != 0, egui::Button::new("⬆")) 155 | .on_hover_text("Move up") 156 | .clicked() 157 | { 158 | self.overrides.swap(idx, idx - 1); 159 | } 160 | if ui 161 | .add_enabled(idx != last_idx, egui::Button::new("⬇")) 162 | .on_hover_text("Move down") 163 | .clicked() 164 | { 165 | self.overrides.swap(idx, idx + 1); 166 | } 167 | let o = self.overrides.get_mut(idx).unwrap(); 168 | egui::ComboBox::from_id_source(format!("override_key {}", idx)) 169 | .selected_text(o.key.as_str()) 170 | .show_ui(ui, |ui| { 171 | for property in &properties { 172 | ui.selectable_value(&mut o.key, *property, property.as_str()); 173 | } 174 | }); 175 | 176 | ui.add_enabled_ui(true, |ui| { 177 | if o.pattern.is_empty() { 178 | set_red_widget_border(ui); 179 | } 180 | ui.add( 181 | egui::TextEdit::singleline(&mut o.pattern) 182 | .min_size(egui::vec2(140.0, 0.0)), 183 | ); 184 | }); 185 | 186 | ui.add(egui::Checkbox::without_text(&mut o.disable)); 187 | 188 | if o.disable { 189 | ui.label("N/A"); 190 | ui.label("N/A"); 191 | } else { 192 | ui.add( 193 | egui::DragValue::new(&mut o.day) 194 | .clamp_range(0u32..=100u32) 195 | .suffix("%"), 196 | ); 197 | ui.add( 198 | egui::DragValue::new(&mut o.night) 199 | .clamp_range(0u32..=100u32) 200 | .suffix("%"), 201 | ); 202 | } 203 | 204 | if ui.button("❌").on_hover_text("Remove override").clicked() { 205 | self.overrides.remove(idx); 206 | return; // Important to avoid invalid index on next iteration 207 | }; 208 | 209 | ui.end_row(); 210 | } 211 | }); 212 | ui.add_space(SPACING); 213 | } 214 | 215 | ui.horizontal(|ui| { 216 | if ui 217 | .add_enabled( 218 | self.overrides.len() < MAX_OVERRIDES, 219 | egui::Button::new("Create new override"), 220 | ) 221 | .clicked() 222 | { 223 | self.overrides.push(Override { 224 | key: MonitorProperty::DeviceName, 225 | pattern: "".to_string(), 226 | disable: false, 227 | day: 100, 228 | night: 60, 229 | }) 230 | } 231 | if ui 232 | .add_enabled(self.is_valid(), egui::Button::new("Save")) 233 | .clicked() 234 | { 235 | let mut config = app_state.config.write().unwrap(); 236 | self.copy_to_config(&mut config); 237 | app_state 238 | .controller 239 | .send(Message::Refresh("Override change")) 240 | .unwrap(); 241 | save_config(&mut config, &app_state.transitions); 242 | } 243 | }); 244 | 245 | ui.add_space(SPACING); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/gui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | mod brightness_settings; 3 | mod help; 4 | mod location_settings; 5 | mod monitor_overrides; 6 | mod status; 7 | 8 | use crate::common::APP_NAME; 9 | use crate::gui::app::SsbEguiApp; 10 | use crate::tray::read_icon; 11 | use egui_wgpu::wgpu::PowerPreference; 12 | use egui_winit::winit; 13 | use egui_winit::winit::event::{Event, WindowEvent}; 14 | use egui_winit::winit::event_loop::{EventLoopProxy, EventLoopWindowTarget}; 15 | use egui_winit::winit::window::Icon; 16 | use std::sync::{Arc, Mutex}; 17 | use std::time::Instant; 18 | 19 | #[derive(Debug)] 20 | pub enum NextPaint { 21 | /// Wait for an event 22 | Wait, 23 | /// Queues a repaint for once the event loop handles its next redraw. Exists 24 | /// so that multiple input events can be handled in one frame. 25 | RepaintNext, 26 | /// Repaint at a particular time 27 | RepaintAt(Instant), 28 | /// Exit the event loop 29 | Exit, 30 | } 31 | 32 | #[derive(Debug)] 33 | pub enum UserEvent { 34 | // When the tray button is clicked - we should open or bring forward the window 35 | OpenWindow(&'static str), 36 | // When the tray exit button is clicked - the application should exit 37 | Exit(&'static str), 38 | // Hide the window if it is open 39 | CloseWindow(&'static str), 40 | // Repaint now 41 | RepaintNow(&'static str), 42 | 43 | RequestRepaint { 44 | when: Instant, 45 | /// What the frame number was when the repaint was _requested_. 46 | frame_nr: u64, 47 | }, 48 | } 49 | 50 | struct WgpuWinitRunning { 51 | painter: egui_wgpu::winit::Painter, 52 | app: SsbEguiApp, 53 | window: winit::window::Window, 54 | follow_system_theme: bool, 55 | egui_ctx: egui::Context, 56 | pending_full_output: egui::FullOutput, 57 | egui_winit: egui_winit::State, 58 | } 59 | 60 | impl Drop for WgpuWinitRunning { 61 | fn drop(&mut self) { 62 | self.painter.destroy(); 63 | } 64 | } 65 | 66 | pub struct WgpuWinitApp { 67 | repaint_proxy: EventLoopProxy, 68 | running: Option, 69 | icon: Icon, 70 | app_factory: F, 71 | start_minimised: bool, 72 | } 73 | 74 | impl SsbEguiApp> WgpuWinitApp { 75 | pub fn new( 76 | event_loop: EventLoopProxy, 77 | start_minimised: bool, 78 | app_factory: F, 79 | ) -> Self { 80 | if start_minimised { 81 | log::info!("Starting minimised"); 82 | } 83 | let (buf, info) = read_icon(); 84 | let icon = Icon::from_rgba(buf, info.width, info.height).unwrap(); 85 | Self { 86 | repaint_proxy: event_loop, 87 | running: None, 88 | icon, 89 | app_factory, 90 | start_minimised, 91 | } 92 | } 93 | 94 | pub fn launch_window(&mut self, event_loop: &EventLoopWindowTarget) -> NextPaint { 95 | let window = winit::window::WindowBuilder::new() 96 | .with_decorations(true) 97 | .with_resizable(true) 98 | .with_transparent(false) 99 | .with_title(APP_NAME) 100 | .with_inner_size(winit::dpi::PhysicalSize { 101 | width: 1024, 102 | height: 768, 103 | }) 104 | .with_window_icon(Some(self.icon.clone())) 105 | .build(event_loop) 106 | .unwrap(); 107 | 108 | window.set_ime_allowed(true); 109 | 110 | let wgpu_config = egui_wgpu::WgpuConfiguration { 111 | power_preference: PowerPreference::LowPower, 112 | ..Default::default() 113 | }; 114 | let mut painter = egui_wgpu::winit::Painter::new(wgpu_config, 1, None, false); 115 | 116 | pollster::block_on(painter.set_window(Some(&window))).unwrap(); 117 | 118 | let mut egui_winit = egui_winit::State::new(event_loop); 119 | 120 | let max_texture_side = painter.max_texture_side().unwrap_or(2048); 121 | egui_winit.set_max_texture_side(max_texture_side); 122 | 123 | let native_pixels_per_point = window.scale_factor() as f32; 124 | egui_winit.set_pixels_per_point(native_pixels_per_point * 1.5); 125 | 126 | let egui_ctx = egui::Context::default(); 127 | 128 | let system_theme = window.theme(); 129 | egui_ctx.set_visuals( 130 | system_theme 131 | .map(visuals_from_winit_theme) 132 | .unwrap_or(egui::Visuals::light()), 133 | ); 134 | 135 | let event_loop_proxy = Arc::new(Mutex::new(self.repaint_proxy.clone())); 136 | egui_ctx.set_request_repaint_callback(move |info| { 137 | let when = Instant::now() + info.after; 138 | let frame_nr = info.current_frame_nr; 139 | event_loop_proxy 140 | .lock() 141 | .unwrap() 142 | .send_event(UserEvent::RequestRepaint { when, frame_nr }) 143 | .ok(); 144 | }); 145 | 146 | self.running = Some(WgpuWinitRunning { 147 | painter, 148 | app: (self.app_factory)(), 149 | window, 150 | follow_system_theme: system_theme.is_some(), 151 | egui_ctx, 152 | pending_full_output: Default::default(), 153 | egui_winit, 154 | }); 155 | 156 | NextPaint::RepaintNext 157 | } 158 | 159 | pub fn frame_nr(&self) -> u64 { 160 | self.running.as_ref().map_or(0, |r| r.egui_ctx.frame_nr()) 161 | } 162 | 163 | pub fn window(&self) -> Option<&winit::window::Window> { 164 | self.running.as_ref().map(|r| &r.window) 165 | } 166 | 167 | pub fn close_window(&mut self) -> NextPaint { 168 | // When the window is closed, we destroy the Window, but leave app running 169 | self.running = None; 170 | NextPaint::Wait 171 | } 172 | 173 | pub fn paint(&mut self) -> NextPaint { 174 | if let Some(running) = &mut self.running { 175 | let raw_input = running.egui_winit.take_egui_input(&running.window); 176 | 177 | // Run user code: 178 | let full_output = running.egui_ctx.run(raw_input, |egui_ctx| { 179 | running.app.update(egui_ctx); 180 | }); 181 | 182 | running.pending_full_output.append(full_output); 183 | let full_output = std::mem::take(&mut running.pending_full_output); 184 | 185 | let egui::FullOutput { 186 | platform_output, 187 | repaint_after, 188 | textures_delta, 189 | shapes, 190 | } = full_output; 191 | 192 | running.egui_winit.handle_platform_output( 193 | &running.window, 194 | &running.egui_ctx, 195 | platform_output, 196 | ); 197 | 198 | let clipped_primitives = { running.egui_ctx.tessellate(shapes) }; 199 | 200 | let clear_color = 201 | egui::Color32::from_rgba_unmultiplied(12, 12, 12, 180).to_normalized_gamma_f32(); 202 | 203 | running.painter.paint_and_update_textures( 204 | running.egui_ctx.pixels_per_point(), 205 | clear_color, 206 | &clipped_primitives, 207 | &textures_delta, 208 | false, 209 | ); 210 | 211 | if repaint_after.is_zero() { 212 | NextPaint::RepaintNext 213 | } else if let Some(repaint_after_instant) = Instant::now().checked_add(repaint_after) { 214 | NextPaint::RepaintAt(repaint_after_instant) 215 | } else { 216 | NextPaint::Wait 217 | } 218 | } else { 219 | NextPaint::Wait 220 | } 221 | } 222 | 223 | pub fn on_event( 224 | &mut self, 225 | event_loop: &EventLoopWindowTarget, 226 | event: &Event<'_, UserEvent>, 227 | ) -> NextPaint { 228 | match event { 229 | Event::Resumed if self.running.is_none() && !self.start_minimised => { 230 | self.launch_window(event_loop) 231 | } 232 | 233 | Event::UserEvent(UserEvent::Exit(src)) => { 234 | log::info!("Received Exit action from '{src}'"); 235 | NextPaint::Exit 236 | } 237 | 238 | Event::UserEvent(UserEvent::CloseWindow(src)) => { 239 | log::info!("Received CloseWindow action from '{src}'"); 240 | self.close_window() 241 | } 242 | 243 | Event::UserEvent(UserEvent::RepaintNow(src)) => { 244 | log::info!("Received RepaintNow action from '{src}'"); 245 | NextPaint::RepaintNext 246 | } 247 | 248 | Event::UserEvent(UserEvent::OpenWindow(src)) => { 249 | log::info!("Received OpenWindow action from '{src}'"); 250 | if let Some(window) = self.window() { 251 | window.set_minimized(false); 252 | window.focus_window(); 253 | NextPaint::Wait 254 | } else { 255 | self.launch_window(event_loop) 256 | } 257 | } 258 | 259 | Event::UserEvent(UserEvent::RequestRepaint { when, frame_nr }) => { 260 | if self.frame_nr() == *frame_nr { 261 | NextPaint::RepaintAt(*when) 262 | } else { 263 | // old request - we've already repainted 264 | NextPaint::Wait 265 | } 266 | } 267 | 268 | Event::WindowEvent { event, .. } => { 269 | if let Some(running) = &mut self.running { 270 | match &event { 271 | WindowEvent::Resized(physical_size) => { 272 | // Resize with 0 width and height is used by winit to signal a minimize event on Windows. 273 | // See: https://github.com/rust-windowing/winit/issues/208 274 | // This solves an issue where the app would panic when minimizing on Windows. 275 | if physical_size.width > 0 && physical_size.height > 0 { 276 | running 277 | .painter 278 | .on_window_resized(physical_size.width, physical_size.height); 279 | } 280 | } 281 | WindowEvent::ScaleFactorChanged { new_inner_size, .. } => { 282 | running 283 | .painter 284 | .on_window_resized(new_inner_size.width, new_inner_size.height); 285 | } 286 | WindowEvent::CloseRequested => { 287 | return self.close_window(); 288 | } 289 | WindowEvent::ThemeChanged(winit_theme) if running.follow_system_theme => { 290 | let visuals = visuals_from_winit_theme(*winit_theme); 291 | running.egui_ctx.set_visuals(visuals); 292 | } 293 | _ => {} 294 | }; 295 | 296 | let event_response = running.egui_winit.on_event(&running.egui_ctx, event); 297 | 298 | if event_response.repaint { 299 | NextPaint::RepaintNext 300 | } else { 301 | NextPaint::Wait 302 | } 303 | } else { 304 | NextPaint::Wait 305 | } 306 | } 307 | 308 | _ => NextPaint::Wait, 309 | } 310 | } 311 | } 312 | 313 | fn visuals_from_winit_theme(theme: winit::window::Theme) -> egui::Visuals { 314 | match theme { 315 | winit::window::Theme::Dark => egui::Visuals::dark(), 316 | winit::window::Theme::Light => egui::Visuals::light(), 317 | } 318 | } 319 | --------------------------------------------------------------------------------