Skip to main content

cadmus_core/
context.rs

1use crate::battery::Battery;
2use crate::db::Database;
3use crate::device::CURRENT_DEVICE;
4use crate::dictionary::{Dictionary, load_dictionary_from_db};
5use crate::font::Fonts;
6use crate::framebuffer::{Display, Framebuffer};
7use crate::frontlight::Frontlight;
8use crate::geom::Rectangle;
9use crate::helpers::{Fingerprint, Fp, IsHidden, load_json};
10use crate::library::Library;
11use crate::lightsensor::LightSensor;
12use crate::rtc::AlarmManager;
13use crate::settings::Settings;
14use crate::view::ViewId;
15use crate::view::keyboard::Layout;
16use chrono::Local;
17use fxhash::FxHashMap;
18use globset::Glob;
19use rand_core::SeedableRng;
20use rand_xoshiro::Xoroshiro128Plus;
21use std::collections::{BTreeMap, VecDeque};
22#[cfg(test)]
23use std::env;
24use std::io;
25use std::path::Path;
26use tracing::error;
27
28use walkdir::WalkDir;
29
30const KEYBOARD_LAYOUTS_DIRNAME: &str = "keyboard-layouts";
31pub(crate) const DICTIONARIES_DIRNAME: &str = "dictionaries";
32const INPUT_HISTORY_SIZE: usize = 32;
33
34pub struct Context {
35    pub fb: Box<dyn Framebuffer>,
36    pub alarm_manager: Option<AlarmManager>,
37    pub display: Display,
38    pub settings: Settings,
39    pub library: Library,
40    pub database: Database,
41    pub fonts: Fonts,
42    pub dictionaries: BTreeMap<String, Dictionary>,
43    pub keyboard_layouts: BTreeMap<String, Layout>,
44    pub input_history: FxHashMap<ViewId, VecDeque<String>>,
45    // TODO(OGKevin): this shall be on the device struct, instead of on context
46    pub frontlight: Box<dyn Frontlight>,
47    pub battery: Box<dyn Battery>,
48    pub lightsensor: Box<dyn LightSensor>,
49    pub notification_index: u8,
50    pub kb_rect: Rectangle,
51    pub rng: Xoroshiro128Plus,
52    pub plugged: bool,
53    pub covered: bool,
54    pub shared: bool,
55    pub online: bool,
56}
57
58impl Context {
59    pub fn new(
60        fb: Box<dyn Framebuffer>,
61        library: Library,
62        database: Database,
63        settings: Settings,
64        fonts: Fonts,
65        battery: Box<dyn Battery>,
66        frontlight: Box<dyn Frontlight>,
67        lightsensor: Box<dyn LightSensor>,
68    ) -> Context {
69        let dims = fb.dims();
70        let rotation = CURRENT_DEVICE.transformed_rotation(fb.rotation());
71        let rng = Xoroshiro128Plus::seed_from_u64(Local::now().timestamp_subsec_nanos() as u64);
72        let alarm_manager = match CURRENT_DEVICE.rtc() {
73            Ok(rtc) => Some(AlarmManager::new(rtc.clone())),
74            Err(e) => {
75                tracing::warn!(error = %e, "RTC init failed, alarm manager unavailable");
76                None
77            }
78        };
79        Context {
80            fb,
81            alarm_manager,
82            display: Display { dims, rotation },
83            library,
84            database,
85            settings,
86            fonts,
87            dictionaries: BTreeMap::new(),
88            keyboard_layouts: BTreeMap::new(),
89            input_history: FxHashMap::default(),
90            battery,
91            frontlight,
92            lightsensor,
93            notification_index: 0,
94            kb_rect: Rectangle::default(),
95            rng,
96            plugged: false,
97            covered: false,
98            shared: false,
99            online: false,
100        }
101    }
102
103    pub fn load_keyboard_layouts(&mut self) {
104        let glob = Glob::new("**/*.json").unwrap().compile_matcher();
105
106        #[cfg(test)]
107        let path = Path::new(
108            &env::var("TEST_ROOT_DIR")
109                .expect("TEST_ROOT_DIR must be set for test using keyboard layouts"),
110        )
111        .join(KEYBOARD_LAYOUTS_DIRNAME);
112
113        #[cfg(not(test))]
114        let path = CURRENT_DEVICE.install_path(KEYBOARD_LAYOUTS_DIRNAME);
115
116        for entry in WalkDir::new(path)
117            .min_depth(1)
118            .into_iter()
119            .filter_entry(|e| !e.is_hidden())
120        {
121            if entry.is_err() {
122                continue;
123            }
124            let entry = entry.unwrap();
125            let path = entry.path();
126            if !glob.is_match(path) {
127                continue;
128            }
129            if let Ok(layout) = load_json::<Layout, _>(path)
130                .map_err(|e| error!("Can't load {}: {:#?}.", path.display(), e))
131            {
132                self.keyboard_layouts.insert(layout.name.clone(), layout);
133            }
134        }
135    }
136
137    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
138    pub fn load_dictionaries(&mut self) {
139        self.dictionaries.clear();
140
141        let glob = Glob::new("**/*.index").unwrap().compile_matcher();
142
143        #[cfg(test)]
144        let path = Path::new(
145            env::var("TEST_ROOT_DIR")
146                .expect("Please set TEST_ROOT_DIR for tests that need dictionaries")
147                .as_str(),
148        )
149        .join(DICTIONARIES_DIRNAME);
150
151        #[cfg(not(test))]
152        let path = CURRENT_DEVICE.data_path(DICTIONARIES_DIRNAME);
153
154        for entry in WalkDir::new(path)
155            .min_depth(1)
156            .into_iter()
157            .filter_entry(|e| !e.is_hidden())
158        {
159            if entry.is_err() {
160                continue;
161            }
162            let entry = entry.unwrap();
163            if !glob.is_match(entry.path()) {
164                continue;
165            }
166            let index_path = entry.path().to_path_buf();
167            let mut content_path = index_path.clone();
168            content_path.set_extension("dict.dz");
169            if !content_path.exists() {
170                content_path.set_extension("");
171            }
172
173            let dict_result = match fingerprint_dict_pair(&index_path) {
174                Ok(fp) => load_dictionary_from_db(&content_path, &self.database, fp),
175                Err(e) => {
176                    tracing::warn!(
177                        path = %index_path.display(),
178                        error = %e,
179                        "failed to fingerprint index file, skipping dictionary"
180                    );
181                    continue;
182                }
183            };
184
185            if let Ok(mut dict) = dict_result {
186                let name = dict.short_name().ok().unwrap_or_else(|| {
187                    index_path
188                        .file_stem()
189                        .map(|s| s.to_string_lossy().into_owned())
190                        .unwrap_or_default()
191                });
192                self.dictionaries.insert(name, dict);
193            }
194        }
195    }
196
197    pub fn record_input(&mut self, text: &str, id: ViewId) {
198        if text.is_empty() {
199            return;
200        }
201
202        let history = self.input_history.entry(id).or_insert_with(VecDeque::new);
203
204        if history.front().map(String::as_str) != Some(text) {
205            history.push_front(text.to_string());
206        }
207
208        if history.len() > INPUT_HISTORY_SIZE {
209            history.pop_back();
210        }
211    }
212
213    /// Enables or disables the device frontlight and keeps the persisted
214    /// frontlight settings in sync.
215    ///
216    /// When automatic frontlight is enabled and coordinates are available,
217    /// turning the light on recomputes the effective levels for the current
218    /// time before applying them.
219    pub fn set_frontlight(&mut self, enable: bool) {
220        self.settings.frontlight = enable;
221
222        if enable {
223            let levels = if self.settings.auto_frontlight {
224                if let Some(coords) = crate::settings::resolve_coordinates(&self.settings) {
225                    let night_brightness = self
226                        .settings
227                        .auto_frontlight_night_brightness
228                        .unwrap_or_default();
229                    crate::frontlight::auto::compute_auto_frontlight_levels(
230                        Local::now(),
231                        coords,
232                        night_brightness,
233                        self.settings.frontlight_levels.intensity,
234                    )
235                } else {
236                    self.settings.frontlight_levels
237                }
238            } else {
239                self.settings.frontlight_levels
240            };
241            if let Err(error) = self.frontlight.set_warmth(levels.warmth) {
242                tracing::error!(error = %error, "failed to set frontlight warmth");
243            }
244            if let Err(error) = self.frontlight.set_intensity(levels.intensity) {
245                tracing::error!(error = %error, "failed to set frontlight intensity");
246            }
247            self.settings.frontlight_levels = levels;
248        } else {
249            self.settings.frontlight_levels = self.frontlight.levels();
250            if let Err(error) = self.frontlight.turn_off() {
251                tracing::error!(error = %error, "failed to turn off frontlight");
252            }
253        }
254    }
255}
256
257/// Fingerprints a StarDict dictionary pair by hashing only the `.index` file.
258///
259/// The `.index` and `.dict` files in a StarDict pair are always installed and
260/// replaced together, so hashing the `.index` alone is sufficient to detect
261/// any change to either file.
262fn fingerprint_dict_pair(index_path: &Path) -> io::Result<Fp> {
263    index_path.fingerprint()
264}
265
266#[cfg(test)]
267pub mod test_helpers {
268    use super::*;
269    use crate::battery::FakeBattery;
270    use crate::db::Database;
271    use crate::framebuffer::Pixmap;
272    use crate::frontlight::LightLevels;
273
274    pub fn create_test_context() -> Context {
275        let mut database = Database::new(":memory:").expect("failed to create in-memory database");
276        database.init(0).expect("failed to run migrations");
277        Context::new(
278            Box::new(Pixmap::new(600, 800, 1)),
279            Library::new(Path::new("/tmp"), &database, "test").unwrap(),
280            database,
281            Settings::default(),
282            Fonts::load_from(
283                Path::new(
284                    &env::var("TEST_ROOT_DIR").expect("TEST_ROOT_DIR must be set for this test."),
285                )
286                .to_path_buf(),
287            )
288            .expect("Failed to load fonts"),
289            Box::new(FakeBattery::new()),
290            Box::new(LightLevels::default()),
291            Box::new(0u16),
292        )
293    }
294}