1use gpui::{
2 Animation, AnimationExt, App, Bounds, Context, Global, Hsla, Pixels, TextRun, Window,
3 WindowAppearance, WindowBounds, prelude::*, px,
4};
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
10
11pub fn next_unique_id() -> u64 {
13 NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
14}
15
16pub fn unique_id(prefix: &str) -> gpui::SharedString {
23 format!("{}-{}", prefix, next_unique_id()).into()
24}
25
26pub fn stable_unique_id(
32 key: impl Into<gpui::ElementId>,
33 prefix: &str,
34 window: &mut Window,
35 cx: &mut App,
36) -> gpui::SharedString {
37 let prefix = prefix.to_string();
38 window
39 .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
40 .read(cx)
41 .clone()
42}
43
44pub mod popper;
45
46pub use popper::*;
47
48pub use liora_theme::Theme;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ThemeMode {
52 #[default]
53 System,
54 Light,
55 Dark,
56}
57
58impl ThemeMode {
59 pub fn label(self) -> &'static str {
60 match self {
61 Self::System => "System",
62 Self::Light => "Light",
63 Self::Dark => "Dark",
64 }
65 }
66
67 pub fn value(self) -> &'static str {
68 match self {
69 Self::System => "system",
70 Self::Light => "light",
71 Self::Dark => "dark",
72 }
73 }
74
75 pub fn from_value(value: &str) -> Option<Self> {
76 match value {
77 "system" => Some(Self::System),
78 "light" => Some(Self::Light),
79 "dark" => Some(Self::Dark),
80 _ => None,
81 }
82 }
83
84 pub fn resolve(self, appearance: WindowAppearance) -> Theme {
85 match self {
86 Self::System => theme_for_window_appearance(appearance),
87 Self::Light => Theme::light(),
88 Self::Dark => Theme::dark(),
89 }
90 }
91
92 pub fn from_theme(theme: &Theme) -> Self {
93 match theme.name.as_str() {
94 "dark" => Self::Dark,
95 _ => Self::Light,
96 }
97 }
98}
99
100pub fn startup_maximized_window_bounds(
109 cx: &App,
110 fallback_size: gpui::Size<Pixels>,
111) -> WindowBounds {
112 let bounds = cx
113 .primary_display()
114 .map(|display| display.bounds())
115 .unwrap_or(Bounds {
116 origin: gpui::Point::default(),
117 size: fallback_size,
118 });
119 WindowBounds::Maximized(bounds)
120}
121
122fn startup_system_appearance(cx: &App) -> WindowAppearance {
123 platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
124}
125
126fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
127 platform_system_appearance().unwrap_or_else(|| window.appearance())
128}
129
130#[cfg(any(target_os = "linux", target_os = "freebsd"))]
131fn platform_system_appearance() -> Option<WindowAppearance> {
132 gtk_theme_env_appearance()
133 .or_else(gtk_settings_appearance)
134 .or_else(gsettings_color_scheme_appearance)
135}
136
137#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
138fn platform_system_appearance() -> Option<WindowAppearance> {
139 None
140}
141
142#[cfg(any(target_os = "linux", target_os = "freebsd"))]
143fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
144 std::env::var("GTK_THEME")
145 .ok()
146 .and_then(|theme| appearance_from_theme_name(&theme))
147}
148
149#[cfg(any(target_os = "linux", target_os = "freebsd"))]
150fn gtk_settings_appearance() -> Option<WindowAppearance> {
151 ["gtk-4.0", "gtk-3.0"]
152 .into_iter()
153 .filter_map(|version| {
154 std::env::var_os("HOME").map(|home| {
155 std::path::PathBuf::from(home)
156 .join(".config")
157 .join(version)
158 .join("settings.ini")
159 })
160 })
161 .filter_map(|path| std::fs::read_to_string(path).ok())
162 .find_map(|settings| appearance_from_gtk_settings(&settings))
163}
164
165#[cfg(any(target_os = "linux", target_os = "freebsd"))]
166fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
167 let output = std::process::Command::new("gsettings")
168 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
169 .output()
170 .ok()?;
171 if !output.status.success() {
172 return None;
173 }
174 let value = String::from_utf8_lossy(&output.stdout);
175 appearance_from_color_scheme(&value)
176}
177
178#[cfg(any(target_os = "linux", target_os = "freebsd"))]
179fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
180 let value = value
181 .trim()
182 .trim_matches('\'')
183 .trim_matches('"')
184 .to_ascii_lowercase();
185 if value.contains("prefer-dark") {
186 Some(WindowAppearance::Dark)
187 } else if value.contains("prefer-light") || value == "default" {
188 Some(WindowAppearance::Light)
189 } else {
190 None
191 }
192}
193
194#[cfg(any(target_os = "linux", target_os = "freebsd"))]
195fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
196 for line in settings.lines() {
197 let line = line.trim();
198 if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
199 continue;
200 }
201 let Some((key, value)) = line.split_once('=') else {
202 continue;
203 };
204 let key = key.trim();
205 let value = value.trim();
206 if key == "gtk-application-prefer-dark-theme" {
207 return match value.to_ascii_lowercase().as_str() {
208 "true" | "1" => Some(WindowAppearance::Dark),
209 "false" | "0" => Some(WindowAppearance::Light),
210 _ => None,
211 };
212 }
213 if key == "gtk-theme-name"
214 && let Some(appearance) = appearance_from_theme_name(value)
215 {
216 return Some(appearance);
217 }
218 }
219 None
220}
221
222#[cfg(any(target_os = "linux", target_os = "freebsd"))]
223fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
224 let theme = theme.to_ascii_lowercase();
225 if theme.contains("dark") {
226 Some(WindowAppearance::Dark)
227 } else if theme.contains("light") {
228 Some(WindowAppearance::Light)
229 } else {
230 None
231 }
232}
233
234pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
235 match appearance {
236 WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
237 WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
238 }
239}
240
241pub struct Config {
242 pub theme: Theme,
243 pub theme_mode: ThemeMode,
244 pub z_index_base: u32,
245}
246
247impl Global for Config {}
248
249impl Config {
250 pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
251 self.theme_mode = mode;
252 self.theme = mode.resolve(appearance);
253 }
254
255 pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
256 if self.theme_mode != ThemeMode::System {
257 return false;
258 }
259 let theme = ThemeMode::System.resolve(appearance);
260 let changed = self.theme.name != theme.name;
261 self.theme = theme;
262 changed
263 }
264}
265
266pub fn init_liora(cx: &mut App, theme: Theme) {
267 let theme_mode = ThemeMode::from_theme(&theme);
268 cx.set_global(Config {
269 theme,
270 theme_mode,
271 z_index_base: 1000,
272 });
273 cx.set_global(crate::popper::ZIndexStack::default());
274 cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
275 cx.set_global(crate::popper::ActivePopover(Vec::new()));
276 cx.set_global(crate::popper::ActiveModal(Vec::new()));
277 cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
278}
279
280pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
281 let appearance = startup_system_appearance(cx);
282 cx.set_global(Config {
283 theme: mode.resolve(appearance),
284 theme_mode: mode,
285 z_index_base: 1000,
286 });
287 cx.set_global(crate::popper::ZIndexStack::default());
288 cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
289 cx.set_global(crate::popper::ActivePopover(Vec::new()));
290 cx.set_global(crate::popper::ActiveModal(Vec::new()));
291 cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
292}
293
294pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
295 let appearance = current_system_appearance(window, cx);
296 cx.global_mut::<Config>().set_theme_mode(mode, appearance);
297 window.refresh();
298}
299
300pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
301 let appearance = current_system_appearance(window, cx);
302 if cx.global_mut::<Config>().sync_system_theme(appearance) {
303 window.refresh();
304 }
305}
306
307pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
315 sync_system_theme(window, cx);
316 window
317 .observe_window_appearance(|window, cx| sync_system_theme(window, cx))
318 .detach();
319}
320
321pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
322 for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
323 push_portal(
324 move |_window, _cx| entry.view.clone().into_any_element(),
325 cx,
326 );
327 }
328}
329
330pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
331 for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
332 push_portal(
333 move |_window, _cx| entry.view.clone().into_any_element(),
334 cx,
335 );
336 }
337}
338
339pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
340 for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
341 push_portal(
342 move |_window, _cx| entry.view.clone().into_any_element(),
343 cx,
344 );
345 }
346}
347
348pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
349 let mouse_pos = window.mouse_position();
350 cx.global_mut::<crate::popper::ActiveTooltip>()
351 .0
352 .retain(|data| data.anchor_bounds.contains(&mouse_pos));
353
354 let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
355 for (tooltip_index, data) in active.into_iter().enumerate() {
356 let theme = cx.global::<Config>().theme.clone();
357
358 let font_size = px(theme.font_size.sm);
360 let text_style = window.text_style();
361 let run = TextRun {
362 len: data.content.len(),
363 font: text_style.font(),
364 color: theme.neutral.card,
365 background_color: None,
366 underline: None,
367 strikethrough: None,
368 };
369 let shaped_line =
370 window
371 .text_system()
372 .shape_line(data.content.clone(), font_size, &[run], None);
373
374 let padding_h = px(12.0);
375 let padding_v = px(4.0);
376 let line_height = window.line_height();
377 let content_size = gpui::Size {
378 width: shaped_line.width + padding_h * 2.0,
379 height: line_height + padding_v * 2.0,
380 };
381
382 push_passive_portal(
383 move |window, _cx| {
384 let viewport = Bounds {
385 origin: gpui::Point::default(),
386 size: window.viewport_size(),
387 };
388
389 let popper = Popper {
390 anchor_bounds: data.anchor_bounds,
391 placement: data.placement,
392 offset: data.offset,
393 };
394
395 let (pos, _final_placement) =
396 popper.calculate_position_with_flip(content_size, viewport);
397
398 gpui::div()
399 .absolute()
400 .cursor_default()
401 .top(pos.y)
402 .left(pos.x)
403 .w(content_size.width)
404 .h(content_size.height)
405 .bg(theme.neutral.text_1)
406 .text_color(theme.neutral.card)
407 .px(padding_h)
408 .flex()
409 .items_center()
410 .justify_center()
411 .rounded(px(theme.radius.sm))
412 .shadow_lg()
413 .text_size(font_size)
414 .child(data.content.clone())
415 .with_animation(
416 ("liora-tooltip-motion", tooltip_index),
417 Animation::new(Duration::from_millis(220))
418 .with_easing(gpui::ease_out_quint()),
419 |tooltip, delta| tooltip.opacity(delta),
420 )
421 .into_any_element()
422 },
423 cx,
424 );
425 }
426}
427
428#[cfg(test)]
429mod theme_mode_tests {
430 use super::*;
431
432 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
433 #[test]
434 fn linux_startup_appearance_parses_synchronous_dark_preferences() {
435 assert_eq!(
436 appearance_from_color_scheme("'prefer-dark'"),
437 Some(WindowAppearance::Dark)
438 );
439 assert_eq!(
440 appearance_from_color_scheme("prefer-light"),
441 Some(WindowAppearance::Light)
442 );
443 assert_eq!(
444 appearance_from_gtk_settings(
445 "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
446 ),
447 Some(WindowAppearance::Dark)
448 );
449 assert_eq!(
450 appearance_from_theme_name("Adwaita-dark"),
451 Some(WindowAppearance::Dark)
452 );
453 }
454
455 #[test]
456 fn theme_mode_values_and_labels_are_stable() {
457 assert_eq!(ThemeMode::System.value(), "system");
458 assert_eq!(ThemeMode::Light.label(), "Light");
459 assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
460 assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
461 assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
462 assert_eq!(ThemeMode::from_value("unknown"), None);
463 }
464
465 #[test]
466 fn system_theme_resolves_from_window_appearance() {
467 assert_eq!(
468 ThemeMode::System.resolve(WindowAppearance::Light).name,
469 Theme::light().name
470 );
471 assert_eq!(
472 ThemeMode::System
473 .resolve(WindowAppearance::VibrantDark)
474 .name,
475 Theme::dark().name
476 );
477 }
478
479 #[test]
480 fn config_syncs_only_in_system_mode() {
481 let mut config = Config {
482 theme: Theme::light(),
483 theme_mode: ThemeMode::Light,
484 z_index_base: 1000,
485 };
486 assert!(!config.sync_system_theme(WindowAppearance::Dark));
487 assert_eq!(config.theme.name, "light");
488
489 config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
490 assert_eq!(config.theme.name, "dark");
491 assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
492 assert!(config.sync_system_theme(WindowAppearance::Light));
493 assert_eq!(config.theme.name, "light");
494 }
495
496 #[test]
497 fn system_theme_observer_syncs_immediately_and_stays_attached() {
498 let source = include_str!("lib.rs");
499 let start = source
500 .find("pub fn attach_system_theme_observer")
501 .expect("system theme observer helper should exist");
502 let body = &source[start
503 ..source[start..]
504 .find("pub fn render_active_popover_in_window")
505 .expect("next function should follow observer helper")
506 + start];
507
508 let sync_call = format!("{}(window, cx);", "sync_system_theme");
509 let observe_call = format!("{}", "observe_window_appearance");
510 let sync_index = body
511 .find(&sync_call)
512 .expect("observer helper should sync the current window appearance immediately");
513 let observe_index = body
514 .find(&observe_call)
515 .expect("observer helper should observe later appearance changes");
516 assert!(sync_index < observe_index);
517 assert!(body.contains(".detach();"));
518 }
519}
520
521#[cfg(test)]
522mod motion_tests {
523 #[test]
524 fn tooltip_rendering_uses_gpui_motion() {
525 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
526
527 assert!(source.contains("tooltip-motion"));
528 assert!(source.contains("with_animation("));
529 }
530}
531
532pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
533 &cx.global::<Config>().theme
534}
535
536pub trait ContextExt {
537 fn liora(&self) -> &Theme;
538}
539
540impl<'a, V> ContextExt for Context<'a, V> {
541 fn liora(&self) -> &Theme {
542 liora_theme(self)
543 }
544}
545
546pub trait ElementExt {
547 fn liora(self, cx: &mut App) -> Self;
548}
549
550impl ElementExt for gpui::Div {
551 fn liora(self, _cx: &mut App) -> Self {
552 self
553 }
554}
555
556pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
557 cx.global::<Config>().z_index_base + 100
558}
559
560pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
561 cx.global::<Config>().z_index_base + 200
562}
563
564pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
565 cx.global::<Config>().z_index_base + 300
566}
567
568pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
569 cx.global::<Config>().z_index_base + 400
570}
571
572pub fn hex_color(hex: u32) -> Hsla {
573 gpui::rgb(hex).into()
574}
575
576#[cfg(test)]
577mod unique_id_tests {
578 use super::*;
579
580 #[test]
581 fn generated_ids_are_prefixed_and_unique() {
582 let first = unique_id("component");
583 let second = unique_id("component");
584
585 assert!(first.as_ref().starts_with("component-"));
586 assert!(second.as_ref().starts_with("component-"));
587 assert_ne!(first, second);
588 }
589}