gravel_frontend_fltk/
lib.rs

1//! gravel's default frontend, based on fltk.
2
3mod builder;
4mod config;
5mod scroll;
6mod scrollbar;
7mod structs;
8
9#[cfg_attr(target_os = "linux", path = "native/linux.rs")]
10#[cfg_attr(windows, path = "native/windows.rs")]
11mod native;
12
13use crate::structs::{Event, HitUi, Ui};
14use crate::{config::Config, scroll::Scroll};
15use fltk::{enums::FrameType, prelude::*};
16use gravel_ffi::prelude::*;
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
19struct FltkFrontend {
20	config: Config,
21	ui: Ui,
22	context: BoxDynFrontendContext,
23	result: QueryResult,
24	query_token: Option<u32>,
25	scroll: Scroll,
26	visible: bool,
27	last_hide_time: SystemTime,
28}
29
30#[gravel_frontend("fltk")]
31impl Frontend for FltkFrontend {
32	fn new(context: BoxDynFrontendContext, config: &PluginConfigAdapter<'_>) -> Self {
33		let config = config::get(config);
34		let ui = builder::build(&config);
35		let max_view_size = config.layout.max_hits;
36		let visible = !config.behaviour.start_hidden;
37
38		if visible {
39			native::activate_window(&ui.window);
40		}
41
42		Self {
43			config,
44			ui,
45			context,
46			result: QueryResult::default(),
47			query_token: None,
48			scroll: Scroll::new(0, max_view_size),
49			visible,
50			last_hide_time: UNIX_EPOCH,
51		}
52	}
53
54	fn run(&mut self) -> FrontendExitStatus {
55		self.update_window_position();
56
57		self.run_event_loop()
58	}
59}
60
61impl FltkFrontend {
62	/// Runs the FLTK event loop. Blocks until the app exits.
63	fn run_event_loop(&mut self) -> FrontendExitStatus {
64		loop {
65			if let Err(e) = fltk::app::wait_for(0.001) {
66				// TODO: handle X11 signals?
67				log::error!("fltk wait_for error: {e}");
68			}
69
70			if fltk::app::should_program_quit() {
71				return self.quit(FrontendExitStatus::Exit);
72			}
73
74			if let Some(exit) = self.receive_message() {
75				return self.quit(exit);
76			}
77
78			// don't receive events from the UI while a query is running, effectively buffering those events
79			if self.query_token.is_some() {
80				continue;
81			}
82
83			if let Some(exit) = self.receive_event() {
84				return self.quit(exit);
85			}
86		}
87	}
88
89	fn receive_event(&mut self) -> Option<FrontendExitStatus> {
90		match self.ui.receiver.recv()? {
91			Event::Query => self.query(),
92			Event::Confirm(kind) => self.confirm(kind),
93			Event::CursorUp => self.cursor_up(),
94			Event::CursorDown => self.cursor_down(),
95			Event::PageUp => self.cursor_page_up(),
96			Event::PageDown => self.cursor_page_down(),
97			Event::CursorTop => self.cursor_top(),
98			Event::CursorBottom => self.cursor_bottom(),
99			Event::Cancel | Event::HideWindow => self.hide(),
100			Event::Exit => return Some(FrontendExitStatus::Exit),
101		}
102
103		None
104	}
105
106	fn receive_message(&mut self) -> Option<FrontendExitStatus> {
107		use FrontendMessage as M;
108		match self.context.recv()? {
109			M::QueryResult(token, result) => self.update_result(token, result),
110			M::ShowOrHide => self.show_or_hide(),
111			M::Show => self.show(),
112			M::Hide => self.hide(),
113			M::ShowWithQuery(query) => self.show_with(&query),
114			M::Refresh => self.force_query(),
115			M::Exit => return Some(FrontendExitStatus::Exit),
116			M::Restart => return Some(FrontendExitStatus::Restart),
117			M::ClearCaches => (),
118		}
119
120		None
121	}
122
123	fn quit(&self, exit: FrontendExitStatus) -> FrontendExitStatus {
124		fltk::app::quit();
125		fltk::app::wait();
126		exit
127	}
128
129	fn show_or_hide(&mut self) {
130		if self.visible {
131			self.hide();
132		} else {
133			self.show();
134		}
135	}
136
137	fn hide(&mut self) {
138		if self.config.behaviour.exit_on_hide {
139			self.ui.sender.send(Event::Exit);
140			return;
141		}
142
143		log::trace!("hiding frontend");
144		self.ui.window.hide();
145		self.visible = false;
146		self.last_hide_time = SystemTime::now();
147	}
148
149	fn show(&mut self) {
150		if self.should_ignore_show() {
151			log::trace!("ignoring show request");
152			return;
153		}
154
155		log::trace!("showing frontend");
156
157		// select the entire previous query so it is overwritten when the user starts typing
158		self.input_select_all();
159
160		self.update_window_position();
161		self.ui.window.show();
162		self.visible = true;
163
164		// pull the window into the foreground so it isn't stuck behind other windows
165		native::activate_window(&self.ui.window);
166	}
167
168	/// XGrabKey takes focus from the window when a hotkey is pressed, so
169	/// when autohide is enabled, hiding the window with the hotkey just
170	/// immediately shows it again.
171	/// HACK ignore window show n milliseconds after window has been hidden.
172	fn should_ignore_show(&self) -> bool {
173		let Some(millis) = self.config.behaviour.window_hide_debounce else {
174			return false;
175		};
176
177		let block_duration = Duration::from_millis(millis);
178		let elapsed = SystemTime::now()
179			.duration_since(self.last_hide_time)
180			.unwrap_or(Duration::MAX);
181
182		elapsed <= block_duration
183	}
184
185	/// Shows the window and populates the input with the given query.
186	fn show_with(&mut self, query: &str) {
187		self.show();
188		self.ui.input.set_value(query);
189		self.force_query();
190	}
191
192	fn input_select_all(&mut self) {
193		self.ui.input.set_position(i32::MIN).ok();
194		self.ui.input.set_mark(i32::MAX).ok();
195	}
196
197	/// Queries if the input has changed.
198	fn query(&mut self) {
199		if self.ui.input.changed() {
200			self.force_query();
201		}
202	}
203
204	fn force_query(&mut self) {
205		let token = self.context.query(self.ui.input.value().into_c());
206		self.ui.input.clear_changed();
207
208		self.query_token = Some(token);
209	}
210
211	fn update_result(&mut self, token: u32, result: QueryResult) {
212		if self.query_token != Some(token) {
213			return;
214		}
215
216		self.result = result;
217		self.query_token = None;
218
219		self.update_window_height();
220		self.update_hits();
221	}
222
223	/// Runs the action of the selected hit.
224	fn confirm(&self, kind: ActionKind) {
225		if self.result.hits.is_empty() {
226			return;
227		}
228
229		let cursor = self.scroll.cursor();
230		let hit = &self.result.hits[cursor as usize].hit;
231
232		self.context.run_hit_action(hit, kind);
233	}
234
235	fn cursor_up(&mut self) {
236		self.scroll.cursor_up();
237		self.update_hits();
238	}
239
240	fn cursor_down(&mut self) {
241		self.scroll.cursor_down();
242		self.update_hits();
243	}
244
245	fn cursor_page_up(&mut self) {
246		self.scroll.page_up();
247		self.update_hits();
248	}
249
250	fn cursor_page_down(&mut self) {
251		self.scroll.page_down();
252		self.update_hits();
253	}
254
255	fn cursor_top(&mut self) {
256		self.scroll.top();
257		self.update_hits();
258	}
259
260	fn cursor_bottom(&mut self) {
261		self.scroll.bottom();
262		self.update_hits();
263	}
264
265	/// Writes the hit data to the UI elements.
266	fn update_hits(&mut self) {
267		for (i, hit_ui) in self.ui.hits.iter_mut().enumerate() {
268			let position = self.scroll.scroll() + i as i32;
269			let selected = position == self.scroll.cursor();
270
271			let hit = self.result.hits.get(position as usize);
272			update_hit(hit_ui, hit, selected, self.config.behaviour.show_scores);
273		}
274
275		self.update_scrollbar();
276	}
277
278	/// Writes scroll data to the scrollbar.
279	fn update_scrollbar(&mut self) {
280		// only show the scrollbar if there is something to scroll
281		if self.scroll.view_size() >= self.result.hits.len() as i32 {
282			self.ui.scrollbar.hide();
283		} else {
284			self.ui.scrollbar.show();
285		}
286
287		let pos = self.scroll.scroll() as f32 / self.scroll.length() as f32;
288		let size = self.scroll.view_size() as f32 / self.scroll.length() as f32;
289		self.ui.scrollbar.set_slider_position(pos);
290		self.ui.scrollbar.set_slider_size(size);
291	}
292
293	/// Sets the new size on its [`Scroll`] and updates the window's height.
294	fn update_window_height(&mut self) {
295		self.scroll.set_length(self.result.hits.len() as i32);
296		let height = builder::get_window_height(&self.config, self.scroll.view_size());
297		self.ui.window.set_size(self.config.layout.window_width, height);
298	}
299
300	fn update_window_position(&mut self) {
301		if !self.config.behaviour.auto_center_window {
302			return;
303		}
304
305		let width = self.config.layout.window_width;
306		let max_height = builder::get_window_height(&self.config, self.config.layout.max_hits);
307
308		let (mx, my) = fltk::app::get_mouse();
309		let screen_num = fltk::app::screen_num(mx, my);
310
311		let (sx, sy, sw, sh) = fltk::app::screen_xywh(screen_num);
312
313		let x = sx + (sw - width) / 2;
314		let y = sy + (sh - max_height) / 2;
315
316		log::trace!("setting position: ({x}, {y}) on screen {screen_num} ({sx}, {sy}, {sw}, {sh})");
317
318		self.ui.window.set_screen_num(screen_num);
319		self.ui.window.set_pos(x, y);
320	}
321}
322
323/// Writes the given [`ScoredHit`]'s data to the given [`HitUi`].
324///
325/// `selected` highlights the hit.
326fn update_hit(hit_ui: &mut HitUi, hit: Option<&ScoredHit>, selected: bool, show_score: bool) {
327	let title = hit.map_or("", |h| h.hit.title().as_str());
328	let subtitle = hit.map_or("", |h| h.hit.subtitle().as_str());
329
330	hit_ui.title.set_label(title);
331
332	if show_score {
333		let format = format!("[{}] {}", hit.map_or(0, |h| h.score), subtitle);
334		hit_ui.subtitle.set_label(&format);
335	} else {
336		hit_ui.subtitle.set_label(subtitle);
337	}
338
339	let frame_type = match selected {
340		true => FrameType::FlatBox,
341		false => FrameType::NoBox,
342	};
343
344	hit_ui.group.set_frame(frame_type);
345}