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    pub frontlight: Box<dyn Frontlight>,
46    pub battery: Box<dyn Battery>,
47    pub lightsensor: Box<dyn LightSensor>,
48    pub notification_index: u8,
49    pub kb_rect: Rectangle,
50    pub rng: Xoroshiro128Plus,
51    pub plugged: bool,
52    pub covered: bool,
53    pub shared: bool,
54    pub online: bool,
55}
56
57impl Context {
58    pub fn new(
59        fb: Box<dyn Framebuffer>,
60        library: Library,
61        database: Database,
62        settings: Settings,
63        fonts: Fonts,
64        battery: Box<dyn Battery>,
65        frontlight: Box<dyn Frontlight>,
66        lightsensor: Box<dyn LightSensor>,
67    ) -> Context {
68        let dims = fb.dims();
69        let rotation = CURRENT_DEVICE.transformed_rotation(fb.rotation());
70        let rng = Xoroshiro128Plus::seed_from_u64(Local::now().timestamp_subsec_nanos() as u64);
71        let alarm_manager = match CURRENT_DEVICE.rtc() {
72            Ok(rtc) => Some(AlarmManager::new(rtc.clone())),
73            Err(e) => {
74                tracing::warn!(error = %e, "RTC init failed, alarm manager unavailable");
75                None
76            }
77        };
78        Context {
79            fb,
80            alarm_manager,
81            display: Display { dims, rotation },
82            library,
83            database,
84            settings,
85            fonts,
86            dictionaries: BTreeMap::new(),
87            keyboard_layouts: BTreeMap::new(),
88            input_history: FxHashMap::default(),
89            battery,
90            frontlight,
91            lightsensor,
92            notification_index: 0,
93            kb_rect: Rectangle::default(),
94            rng,
95            plugged: false,
96            covered: false,
97            shared: false,
98            online: false,
99        }
100    }
101
102    pub fn load_keyboard_layouts(&mut self) {
103        let glob = Glob::new("**/*.json").unwrap().compile_matcher();
104
105        #[cfg(test)]
106        let path = Path::new(
107            &env::var("TEST_ROOT_DIR")
108                .expect("TEST_ROOT_DIR must be set for test using keyboard layouts"),
109        )
110        .join(KEYBOARD_LAYOUTS_DIRNAME);
111
112        #[cfg(not(test))]
113        let path = CURRENT_DEVICE.install_path(KEYBOARD_LAYOUTS_DIRNAME);
114
115        for entry in WalkDir::new(path)
116            .min_depth(1)
117            .into_iter()
118            .filter_entry(|e| !e.is_hidden())
119        {
120            if entry.is_err() {
121                continue;
122            }
123            let entry = entry.unwrap();
124            let path = entry.path();
125            if !glob.is_match(path) {
126                continue;
127            }
128            if let Ok(layout) = load_json::<Layout, _>(path)
129                .map_err(|e| error!("Can't load {}: {:#?}.", path.display(), e))
130            {
131                self.keyboard_layouts.insert(layout.name.clone(), layout);
132            }
133        }
134    }
135
136    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
137    pub fn load_dictionaries(&mut self) {
138        self.dictionaries.clear();
139
140        let glob = Glob::new("**/*.index").unwrap().compile_matcher();
141
142        #[cfg(test)]
143        let path = Path::new(
144            env::var("TEST_ROOT_DIR")
145                .expect("Please set TEST_ROOT_DIR for tests that need dictionaries")
146                .as_str(),
147        )
148        .join(DICTIONARIES_DIRNAME);
149
150        #[cfg(not(test))]
151        let path = CURRENT_DEVICE.data_path(DICTIONARIES_DIRNAME);
152
153        for entry in WalkDir::new(path)
154            .min_depth(1)
155            .into_iter()
156            .filter_entry(|e| !e.is_hidden())
157        {
158            if entry.is_err() {
159                continue;
160            }
161            let entry = entry.unwrap();
162            if !glob.is_match(entry.path()) {
163                continue;
164            }
165            let index_path = entry.path().to_path_buf();
166            let mut content_path = index_path.clone();
167            content_path.set_extension("dict.dz");
168            if !content_path.exists() {
169                content_path.set_extension("");
170            }
171
172            let dict_result = match fingerprint_dict_pair(&index_path) {
173                Ok(fp) => load_dictionary_from_db(&content_path, &self.database, fp),
174                Err(e) => {
175                    tracing::warn!(
176                        path = %index_path.display(),
177                        error = %e,
178                        "failed to fingerprint index file, skipping dictionary"
179                    );
180                    continue;
181                }
182            };
183
184            if let Ok(mut dict) = dict_result {
185                let name = dict.short_name().ok().unwrap_or_else(|| {
186                    index_path
187                        .file_stem()
188                        .map(|s| s.to_string_lossy().into_owned())
189                        .unwrap_or_default()
190                });
191                self.dictionaries.insert(name, dict);
192            }
193        }
194    }
195
196    pub fn record_input(&mut self, text: &str, id: ViewId) {
197        if text.is_empty() {
198            return;
199        }
200
201        let history = self.input_history.entry(id).or_insert_with(VecDeque::new);
202
203        if history.front().map(String::as_str) != Some(text) {
204            history.push_front(text.to_string());
205        }
206
207        if history.len() > INPUT_HISTORY_SIZE {
208            history.pop_back();
209        }
210    }
211
212    pub fn set_frontlight(&mut self, enable: bool) {
213        self.settings.frontlight = enable;
214
215        if enable {
216            let levels = self.settings.frontlight_levels;
217            self.frontlight.set_warmth(levels.warmth);
218            self.frontlight.set_intensity(levels.intensity);
219        } else {
220            self.settings.frontlight_levels = self.frontlight.levels();
221            self.frontlight.set_intensity(0.0);
222            self.frontlight.set_warmth(0.0);
223        }
224    }
225}
226
227/// Fingerprints a StarDict dictionary pair by hashing only the `.index` file.
228///
229/// The `.index` and `.dict` files in a StarDict pair are always installed and
230/// replaced together, so hashing the `.index` alone is sufficient to detect
231/// any change to either file.
232fn fingerprint_dict_pair(index_path: &Path) -> io::Result<Fp> {
233    index_path.fingerprint()
234}
235
236#[cfg(test)]
237pub mod test_helpers {
238    use super::*;
239    use crate::battery::FakeBattery;
240    use crate::db::Database;
241    use crate::framebuffer::Pixmap;
242    use crate::frontlight::LightLevels;
243
244    pub fn create_test_context() -> Context {
245        let database = Database::new(":memory:").expect("failed to create in-memory database");
246        database.migrate().expect("failed to run migrations");
247        Context::new(
248            Box::new(Pixmap::new(600, 800, 1)),
249            Library::new(Path::new("/tmp"), &database, "test").unwrap(),
250            database,
251            Settings::default(),
252            Fonts::load_from(
253                Path::new(
254                    &env::var("TEST_ROOT_DIR").expect("TEST_ROOT_DIR must be set for this test."),
255                )
256                .to_path_buf(),
257            )
258            .expect("Failed to load fonts"),
259            Box::new(FakeBattery::new()),
260            Box::new(LightLevels::default()),
261            Box::new(0u16),
262        )
263    }
264}