reddish_shift/
lib.rs

1/*  redshift.rs -- Main program
2    This file is part of <https://github.com/mahor1221/reddish-shift>.
3    Copyright (C) 2024 Mahor Foruzesh <mahor1221@gmail.com>
4    Ported from Redshift <https://github.com/jonls/redshift>.
5    Copyright (c) 2009-2017  Jon Lund Steffensen <jonlst@gmail.com>
6
7    This program is free software: you can redistribute it and/or modify
8    it under the terms of the GNU General Public License as published by
9    the Free Software Foundation, either version 3 of the License, or
10    (at your option) any later version.
11
12    This program is distributed in the hope that it will be useful,
13    but WITHOUT ANY WARRANTY; without even the implied warranty of
14    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15    GNU General Public License for more details.
16
17    You should have received a copy of the GNU General Public License
18    along with this program.  If not, see <https://www.gnu.org/licenses/>.
19*/
20
21// TODO: add tldr page: https://github.com/tldr-pages/tldr
22// TODO: add setting screen brightness, a percentage of the current brightness
23//       see: https://github.com/qualiaa/redshift-hooks
24// TODO: ? benchmark: https://github.com/nvzqz/divan
25// TODO: Fix large fade steps
26// TODO: ? Box large errors
27// TODO: move coproduct.rs to a fork of frunk after Error got stabled in core
28//       see: https://github.com/rust-lang/rust/issues/103765
29// TODO: add unit tests
30// TODO: check if another instance is running
31// TODO: add test for man page
32// TODO: fix all document warnings
33
34mod calc_colorramp;
35mod calc_solar;
36mod cli;
37mod config;
38mod coproduct;
39mod error;
40
41#[cfg(unix_without_macos)]
42mod gamma_drm;
43#[cfg(unix_without_macos)]
44mod gamma_randr;
45#[cfg(unix_without_macos)]
46mod gamma_vidmode;
47
48#[cfg(windows)]
49mod gamma_win32gdi;
50
51mod gamma_dummy;
52mod location_manual;
53mod types;
54mod types_display;
55mod types_parse;
56mod utils;
57
58#[cfg(windows)]
59use crate::gamma_win32gdi::Win32Gdi;
60#[cfg(unix_without_macos)]
61use crate::{gamma_drm::Drm, gamma_randr::Randr, gamma_vidmode::Vidmode};
62pub use cli::cli_args_command;
63use error::ReddishError;
64use gamma_dummy::Dummy;
65use itertools::Itertools;
66use location_manual::Manual;
67use types::Location;
68
69use crate::{
70    cli::ClapColorChoiceExt,
71    config::{Config, ConfigBuilder, FADE_STEPS},
72    error::{AdjusterError, ProviderError},
73    types::{ColorSettings, Elevation, Mode, Period, PeriodInfo},
74    types_display::{BODY, HEADER},
75};
76use anstream::AutoStream;
77use chrono::{DateTime, SubsecRound, TimeDelta};
78use std::{
79    fmt::Debug,
80    io,
81    sync::mpsc::{self, Receiver, RecvTimeoutError},
82};
83use tracing::{error, info, Level};
84use tracing_subscriber::fmt::writer::MakeWriterExt;
85
86pub fn main() {
87    (|| -> Result<(), ReddishError> {
88        let c = ConfigBuilder::new(|verbosity, color| {
89            let choice = color.to_choice();
90            let stdout = move || AutoStream::new(io::stdout(), choice).lock();
91            let stderr = move || AutoStream::new(io::stderr(), choice).lock();
92            let stdio = stderr.with_max_level(Level::WARN).or_else(stdout);
93
94            tracing_subscriber::fmt()
95                .with_writer(stdio)
96                .with_max_level(verbosity.level_filter())
97                .without_time()
98                .with_level(false)
99                .with_target(false)
100                .init();
101        })?
102        .build()?;
103
104        let (tx, rx) = mpsc::channel();
105        ctrlc::set_handler(move || {
106            #[allow(clippy::expect_used)]
107            tx.send(()).expect("Could not send signal on channel")
108        })
109        .or_else(|e| match c.mode {
110            Mode::Oneshot | Mode::Set | Mode::Reset | Mode::Print => Ok(()),
111            Mode::Daemon => Err(e),
112        })?;
113
114        run(&c, &rx)
115    })()
116    .unwrap_or_else(|e| error!("{e}"))
117}
118
119fn run(c: &Config, sig: &Receiver<()>) -> Result<(), ReddishError> {
120    match c.mode {
121        Mode::Daemon => {
122            info!("{c}\n{HEADER}Current{HEADER:#}:");
123            DaemonMode::new(c, sig).run_loop()?;
124            c.method.restore()?;
125        }
126        Mode::Oneshot => {
127            // Use period and transition progress to set color temperature
128            let (p, i) = Period::from(&c.scheme, &c.location, c.time)?;
129            let interp = c.night.interpolate_with(&c.day, p.into());
130            info!("{c}\n{HEADER}Current{HEADER:#}:\n{p}\n{i}\n{interp}");
131            c.method.set(c.reset_ramps, &interp)?;
132        }
133        Mode::Set => {
134            // for this command, color settings are stored in the day field
135            c.method.set(c.reset_ramps, &c.day)?;
136        }
137        Mode::Reset => {
138            c.method.set(true, &ColorSettings::default())?;
139        }
140        Mode::Print => run_print_mode(c)?,
141    }
142
143    Ok(())
144}
145
146fn run_print_mode(c: &Config) -> Result<(), ReddishError> {
147    let now = (c.time)();
148    let delta = now.to_utc() - DateTime::UNIX_EPOCH;
149    let loc = c.location.get()?;
150    let mut buf = (0..24).map(|h| {
151        let d = TimeDelta::hours(h);
152        let time = (now + d).time().trunc_subsecs(0);
153        let elev = Elevation::new((delta + d).num_seconds() as f64, loc);
154        format!("{BODY}{time}{BODY:#}: {:6.2}°", *elev)
155    });
156    Ok(info!("{}", buf.join("\n")))
157}
158
159#[derive(Debug)]
160struct DaemonMode<'a, 'b> {
161    cfg: &'a Config,
162    sig: &'b Receiver<()>,
163
164    signal: Signal,
165    fade: FadeStatus,
166
167    period: Period,
168    info: PeriodInfo,
169    interp: ColorSettings,
170
171    // Save previous parameters so we can avoid printing status updates if the
172    // values did not change
173    prev_period: Option<Period>,
174    prev_info: Option<PeriodInfo>,
175    prev_interp: Option<ColorSettings>,
176}
177
178impl<'a, 'b> DaemonMode<'a, 'b> {
179    fn new(cfg: &'a Config, sig: &'b Receiver<()>) -> Self {
180        Self {
181            cfg,
182            sig,
183            signal: Default::default(),
184            fade: Default::default(),
185            period: Default::default(),
186            info: Default::default(),
187            interp: Default::default(),
188            prev_period: Default::default(),
189            prev_info: Default::default(),
190            prev_interp: Default::default(),
191        }
192    }
193
194    /// This is the main loop of the daemon mode which keeps track of the
195    /// current time and continuously updates the screen to the appropriate
196    /// color temperature
197    fn run_loop(&mut self) -> Result<(), ReddishError> {
198        let c = self.cfg;
199        loop {
200            (self.period, self.info) =
201                Period::from(&c.scheme, &c.location, c.time)?;
202
203            let target = match self.signal {
204                Signal::None => {
205                    c.night.interpolate_with(&c.day, self.period.into())
206                }
207                Signal::Interrupt => ColorSettings::default(),
208            };
209
210            (self.interp, self.fade) = self.next_interpolate(target);
211
212            self.log();
213
214            // // Activate hooks if period changed
215            // if period != prev_period {
216            //     hooks_signal_period_change(prev_period, period);
217            // }
218
219            c.method.set(c.reset_ramps, &self.interp)?;
220
221            self.prev_period = Some(self.period);
222            self.prev_info = Some(self.info.clone());
223            self.prev_interp = Some(self.interp.clone());
224
225            // sleep for a duration then continue the loop
226            // or wake up and restore the default colors slowly on first ctrl-c
227            // or break the loop on the second ctrl-c immediately
228            let sleep_duration = match (self.signal, self.fade) {
229                (Signal::None, FadeStatus::Completed) => c.sleep_duration,
230                (_, FadeStatus::Ungoing { .. }) => c.sleep_duration_short,
231                (Signal::Interrupt, FadeStatus::Completed) => break Ok(()),
232            };
233
234            match self.sig.recv_timeout(sleep_duration) {
235                Err(RecvTimeoutError::Timeout) => {}
236                Err(e) => Err(e)?,
237                Ok(()) => match self.signal {
238                    Signal::None => self.signal = Signal::Interrupt,
239                    Signal::Interrupt => break Ok(()),
240                },
241            }
242        }
243    }
244
245    fn next_interpolate(
246        &self,
247        target: ColorSettings,
248    ) -> (ColorSettings, FadeStatus) {
249        use FadeStatus::*;
250        let target_is_very_different = self.interp.is_very_diff_from(&target);
251        match (&self.fade, target_is_very_different, self.cfg.disable_fade) {
252            (_, _, true) | (Completed | Ungoing { .. }, false, false) => {
253                (target, Completed)
254            }
255
256            (Completed, true, false) => {
257                let next = Self::interpolate(&self.interp, &target, 0);
258                (next, Ungoing { step: 0 })
259            }
260
261            (Ungoing { step }, true, false) => {
262                if *step < FADE_STEPS {
263                    let step = *step + 1;
264                    let next = Self::interpolate(&self.interp, &target, step);
265                    (next, Ungoing { step })
266                } else {
267                    (target, Completed)
268                }
269            }
270        }
271    }
272
273    fn interpolate(
274        start: &ColorSettings,
275        end: &ColorSettings,
276        step: u8,
277    ) -> ColorSettings {
278        let frac = step as f64 / FADE_STEPS as f64;
279        let alpha = Self::ease_fade(frac)
280            .clamp(0.0, 1.0)
281            .try_into()
282            .unwrap_or_else(|_| unreachable!());
283        start.interpolate_with(end, alpha)
284    }
285
286    /// Easing function for fade
287    /// See https://github.com/mietek/ease-tween
288    fn ease_fade(t: f64) -> f64 {
289        if t <= 0.0 {
290            0.0
291        } else if t >= 1.0 {
292            1.0
293        } else {
294            1.0042954579734844
295                * (-6.404173895841566 * (-7.290824133098134 * t).exp()).exp()
296        }
297    }
298}
299
300trait Provider {
301    fn get(&self) -> Result<Location, ProviderError>;
302}
303
304trait Adjuster {
305    /// Restore the adjustment to the state before the Adjuster object was created
306    fn restore(&self) -> Result<(), AdjusterError>;
307    /// Set a specific temperature
308    fn set(
309        &self,
310        reset_ramps: bool,
311        cs: &ColorSettings,
312    ) -> Result<(), AdjusterError>;
313}
314
315#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
316enum Signal {
317    #[default]
318    None,
319    Interrupt,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323enum FadeStatus {
324    Completed,
325    Ungoing { step: u8 },
326}
327
328impl Default for FadeStatus {
329    fn default() -> Self {
330        Self::Completed
331    }
332}
333
334//
335
336#[derive(Debug, PartialEq)]
337pub enum LocationProvider {
338    Manual(Manual),
339    Geoclue2(Geoclue2),
340}
341
342#[derive(Debug)]
343pub enum AdjustmentMethod {
344    Dummy(Dummy),
345    #[cfg(unix_without_macos)]
346    Randr(Randr),
347    #[cfg(unix_without_macos)]
348    Drm(Drm),
349    #[cfg(unix_without_macos)]
350    Vidmode(Vidmode),
351    #[cfg(windows)]
352    Win32Gdi(Win32Gdi),
353}
354
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct Geoclue2;
357
358impl Provider for Geoclue2 {
359    // Listen and handle location updates
360    // fn fd() -> c_int;
361
362    fn get(&self) -> Result<Location, ProviderError> {
363        // Redshift: "Waiting for current location to become available..."
364        Err(ProviderError)
365    }
366}
367
368impl Provider for LocationProvider {
369    fn get(&self) -> Result<Location, ProviderError> {
370        match self {
371            Self::Manual(t) => t.get(),
372            Self::Geoclue2(t) => t.get(),
373        }
374    }
375}
376
377impl Adjuster for AdjustmentMethod {
378    fn restore(&self) -> Result<(), AdjusterError> {
379        match self {
380            Self::Dummy(t) => t.restore(),
381            #[cfg(unix_without_macos)]
382            Self::Randr(t) => t.restore(),
383            #[cfg(unix_without_macos)]
384            Self::Drm(t) => t.restore(),
385            #[cfg(unix_without_macos)]
386            Self::Vidmode(t) => t.restore(),
387            #[cfg(windows)]
388            Self::Win32Gdi(t) => t.restore(),
389        }
390    }
391
392    fn set(
393        &self,
394        reset_ramps: bool,
395        cs: &ColorSettings,
396    ) -> Result<(), AdjusterError> {
397        match self {
398            Self::Dummy(t) => t.set(reset_ramps, cs),
399            #[cfg(unix_without_macos)]
400            Self::Randr(t) => t.set(reset_ramps, cs),
401            #[cfg(unix_without_macos)]
402            Self::Drm(t) => t.set(reset_ramps, cs),
403            #[cfg(unix_without_macos)]
404            Self::Vidmode(t) => t.set(reset_ramps, cs),
405            #[cfg(windows)]
406            Self::Win32Gdi(t) => t.set(reset_ramps, cs),
407            // #[cfg(macos)]
408            // Self::Quartz(t) => {
409            //     // Redshift: In Quartz (macOS) the gamma adjustments will
410            //     // automatically revert when the process exits Therefore,
411            //     // we have to loop until CTRL-C is received
412            //     if strcmp(options.method.name, "quartz") == 0 {
413            //         println!("Press ctrl-c to stop...");
414            //         pause();
415            //     }
416            // }
417        }
418    }
419}