1use gpui::{
10 Animation, AnimationExt, App, Bounds, Context, Global, Hsla, Pixels, SharedString, TextRun,
11 Window, WindowAppearance, WindowBounds, prelude::*, px,
12};
13
14use std::borrow::Cow;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18pub mod fonts;
19
20pub use fonts::*;
21
22static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct FontConfig {
34 pub ui_families: Vec<SharedString>,
38 pub code_families: Vec<SharedString>,
42}
43
44impl FontConfig {
45 pub fn system() -> Self {
47 Self::default()
48 }
49
50 pub fn with_ui_families(
52 mut self,
53 families: impl IntoIterator<Item = impl Into<SharedString>>,
54 ) -> Self {
55 self.ui_families = families.into_iter().map(Into::into).collect();
56 self
57 }
58
59 pub fn with_code_families(
61 mut self,
62 families: impl IntoIterator<Item = impl Into<SharedString>>,
63 ) -> Self {
64 self.code_families = families.into_iter().map(Into::into).collect();
65 self
66 }
67
68 pub fn ui_families(&self) -> &[SharedString] {
70 &self.ui_families
71 }
72
73 pub fn code_families(&self) -> &[SharedString] {
75 &self.code_families
76 }
77}
78
79#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub struct LioraOptions {
82 pub theme_mode: ThemeMode,
84 pub fonts: FontConfig,
86}
87
88impl LioraOptions {
89 pub fn system() -> Self {
91 Self::default()
92 }
93
94 pub fn with_theme_mode(mut self, mode: ThemeMode) -> Self {
96 self.theme_mode = mode;
97 self
98 }
99
100 pub fn with_fonts(mut self, fonts: FontConfig) -> Self {
102 self.fonts = fonts;
103 self
104 }
105}
106
107pub fn load_custom_fonts(
115 cx: &mut App,
116 fonts: impl IntoIterator<Item = Cow<'static, [u8]>>,
117) -> gpui::Result<()> {
118 cx.text_system().add_fonts(fonts.into_iter().collect())
119}
120
121pub fn next_unique_id() -> u64 {
123 NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
124}
125
126pub fn unique_id(prefix: &str) -> gpui::SharedString {
133 format!("{}-{}", prefix, next_unique_id()).into()
134}
135
136pub fn stable_unique_id(
142 key: impl Into<gpui::SharedString>,
143 prefix: &str,
144 window: &mut Window,
145 cx: &mut App,
146) -> gpui::SharedString {
147 let prefix = prefix.to_string();
148 let key = gpui::ElementId::from(key.into());
149 window
150 .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
151 .read(cx)
152 .clone()
153}
154
155pub mod popper;
157
158pub use popper::*;
159
160pub use liora_theme::Theme;
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum ThemeMode {
165 #[default]
166 System,
168 Light,
170 Dark,
172}
173
174impl ThemeMode {
175 pub fn label(self) -> &'static str {
177 match self {
178 Self::System => "System",
179 Self::Light => "Light",
180 Self::Dark => "Dark",
181 }
182 }
183
184 pub fn value(self) -> &'static str {
186 match self {
187 Self::System => "system",
188 Self::Light => "light",
189 Self::Dark => "dark",
190 }
191 }
192
193 pub fn from_value(value: &str) -> Option<Self> {
195 match value {
196 "system" => Some(Self::System),
197 "light" => Some(Self::Light),
198 "dark" => Some(Self::Dark),
199 _ => None,
200 }
201 }
202
203 pub fn resolve(self, appearance: WindowAppearance) -> Theme {
205 match self {
206 Self::System => theme_for_window_appearance(appearance),
207 Self::Light => Theme::light(),
208 Self::Dark => Theme::dark(),
209 }
210 }
211
212 pub fn from_theme(theme: &Theme) -> Self {
214 match theme.name.as_str() {
215 "dark" => Self::Dark,
216 _ => Self::Light,
217 }
218 }
219}
220
221pub fn startup_maximized_window_bounds(
228 cx: &App,
229 fallback_size: gpui::Size<Pixels>,
230) -> WindowBounds {
231 let bounds = cx
232 .primary_display()
233 .map(|display| display.bounds())
234 .unwrap_or(Bounds {
235 origin: gpui::Point::default(),
236 size: fallback_size,
237 });
238 WindowBounds::Maximized(bounds)
239}
240
241#[derive(Clone, Debug)]
250pub struct LinuxDesktopIdentity<'a> {
251 pub app_id: &'a str,
253 pub desktop_entry: Cow<'a, str>,
255 pub png_icons: &'a [LinuxDesktopPngIcon<'a>],
257 pub svg_icon: &'a [u8],
259}
260
261#[derive(Clone, Copy, Debug)]
263pub struct LinuxDesktopPngIcon<'a> {
264 pub size: u16,
266 pub bytes: &'a [u8],
268}
269
270pub fn linux_desktop_entry(
277 name: &str,
278 generic_name: &str,
279 comment: &str,
280 executable: &std::path::Path,
281 icon: &str,
282 categories: &str,
283 keywords: &str,
284) -> String {
285 format!(
286 "[Desktop Entry]\nVersion=1.0\nType=Application\nName={name}\nGenericName={generic_name}\nComment={comment}\nExec={}\nIcon={icon}\nCategories={categories}\nKeywords={keywords}\nStartupNotify=true\nTerminal=false\n",
287 desktop_exec_value(executable),
288 )
289}
290
291pub fn linux_desktop_png_icon_path(app_id: &str, size: u16) -> Option<std::path::PathBuf> {
297 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
298 {
299 linux_xdg_data_home().ok().map(|data_home| {
300 data_home
301 .join("icons")
302 .join("hicolor")
303 .join(format!("{size}x{size}"))
304 .join("apps")
305 .join(format!("{app_id}.png"))
306 })
307 }
308
309 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
310 {
311 let _ = (app_id, size);
312 None
313 }
314}
315
316fn desktop_exec_value(executable: &std::path::Path) -> String {
317 let value = executable.to_string_lossy();
318 if value
319 .chars()
320 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
321 {
322 value.into_owned()
323 } else {
324 format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\""))
325 }
326}
327
328pub fn ensure_linux_desktop_identity(identity: LinuxDesktopIdentity<'_>) -> std::io::Result<()> {
337 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
338 {
339 ensure_linux_desktop_identity_impl(linux_xdg_data_home()?, identity, true)
340 }
341
342 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
343 {
344 let _ = identity;
345 Ok(())
346 }
347}
348
349#[cfg(any(target_os = "linux", target_os = "freebsd"))]
350fn ensure_linux_desktop_identity_impl(
351 data_home: std::path::PathBuf,
352 identity: LinuxDesktopIdentity<'_>,
353 refresh_cache: bool,
354) -> std::io::Result<()> {
355 let mut changed = write_if_changed(
356 &data_home
357 .join("applications")
358 .join(format!("{}.desktop", identity.app_id)),
359 identity.desktop_entry.as_bytes(),
360 )?;
361 for icon in identity.png_icons {
362 changed |= write_if_changed(
363 &data_home
364 .join("icons")
365 .join("hicolor")
366 .join(format!("{}x{}", icon.size, icon.size))
367 .join("apps")
368 .join(format!("{}.png", identity.app_id)),
369 icon.bytes,
370 )?;
371 }
372 changed |= write_if_changed(
373 &data_home
374 .join("icons")
375 .join("hicolor")
376 .join("scalable")
377 .join("apps")
378 .join(format!("{}.svg", identity.app_id)),
379 identity.svg_icon,
380 )?;
381 changed |= ensure_hicolor_index_theme(&data_home)?;
382 if changed && refresh_cache {
383 refresh_linux_desktop_identity_cache(&data_home);
384 }
385 Ok(())
386}
387
388#[cfg(any(target_os = "linux", target_os = "freebsd"))]
389fn refresh_linux_desktop_identity_cache(data_home: &std::path::Path) {
390 let apps_dir = data_home.join("applications");
391 let _ = std::fs::remove_file(
392 data_home
393 .join("icons")
394 .join("hicolor")
395 .join(".icon-theme.cache"),
396 );
397 let _ = std::process::Command::new("update-desktop-database")
398 .arg(&apps_dir)
399 .status();
400 for command in ["kbuildsycoca6", "kbuildsycoca5"] {
401 let _ = std::process::Command::new(command)
402 .arg("--noincremental")
403 .status();
404 }
405}
406
407#[cfg(any(target_os = "linux", target_os = "freebsd"))]
408fn ensure_hicolor_index_theme(data_home: &std::path::Path) -> std::io::Result<bool> {
409 write_if_changed(
410 &data_home.join("icons").join("hicolor").join("index.theme"),
411 HICOLOR_INDEX_THEME.as_bytes(),
412 )
413}
414
415#[cfg(any(target_os = "linux", target_os = "freebsd"))]
416const HICOLOR_INDEX_THEME: &str = "[Icon Theme]\nName=Hicolor\nComment=Fallback icon theme\nHidden=true\nDirectories=16x16/apps,24x24/apps,32x32/apps,48x48/apps,64x64/apps,128x128/apps,256x256/apps,512x512/apps,scalable/apps\n\n[16x16/apps]\nSize=16\nContext=Applications\nType=Threshold\n\n[24x24/apps]\nSize=24\nContext=Applications\nType=Threshold\n\n[32x32/apps]\nSize=32\nContext=Applications\nType=Threshold\n\n[48x48/apps]\nSize=48\nContext=Applications\nType=Threshold\n\n[64x64/apps]\nSize=64\nContext=Applications\nType=Threshold\n\n[128x128/apps]\nSize=128\nContext=Applications\nType=Threshold\n\n[256x256/apps]\nSize=256\nContext=Applications\nType=Threshold\n\n[512x512/apps]\nSize=512\nContext=Applications\nType=Threshold\n\n[scalable/apps]\nSize=512\nMinSize=16\nMaxSize=512\nContext=Applications\nType=Scalable\n";
417
418#[cfg(any(target_os = "linux", target_os = "freebsd"))]
419fn linux_xdg_data_home() -> std::io::Result<std::path::PathBuf> {
420 if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") {
421 return Ok(std::path::PathBuf::from(data_home));
422 }
423 std::env::var_os("HOME")
424 .map(std::path::PathBuf::from)
425 .map(|home| home.join(".local").join("share"))
426 .ok_or_else(|| {
427 std::io::Error::new(
428 std::io::ErrorKind::NotFound,
429 "neither XDG_DATA_HOME nor HOME is set",
430 )
431 })
432}
433
434#[cfg(any(target_os = "linux", target_os = "freebsd"))]
435fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<bool> {
436 if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
437 return Ok(false);
438 }
439 if let Some(parent) = path.parent() {
440 std::fs::create_dir_all(parent)?;
441 }
442 std::fs::write(path, bytes)?;
443 Ok(true)
444}
445
446fn startup_system_appearance(cx: &App) -> WindowAppearance {
447 platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
448}
449
450fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
451 platform_system_appearance().unwrap_or_else(|| window.appearance())
452}
453
454#[cfg(any(target_os = "linux", target_os = "freebsd"))]
455fn platform_system_appearance() -> Option<WindowAppearance> {
456 gtk_theme_env_appearance()
457 .or_else(gtk_settings_appearance)
458 .or_else(gsettings_color_scheme_appearance)
459}
460
461#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
462fn platform_system_appearance() -> Option<WindowAppearance> {
463 None
464}
465
466#[cfg(any(target_os = "linux", target_os = "freebsd"))]
467fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
468 std::env::var("GTK_THEME")
469 .ok()
470 .and_then(|theme| appearance_from_theme_name(&theme))
471}
472
473#[cfg(any(target_os = "linux", target_os = "freebsd"))]
474fn gtk_settings_appearance() -> Option<WindowAppearance> {
475 ["gtk-4.0", "gtk-3.0"]
476 .into_iter()
477 .filter_map(|version| {
478 std::env::var_os("HOME").map(|home| {
479 std::path::PathBuf::from(home)
480 .join(".config")
481 .join(version)
482 .join("settings.ini")
483 })
484 })
485 .filter_map(|path| std::fs::read_to_string(path).ok())
486 .find_map(|settings| appearance_from_gtk_settings(&settings))
487}
488
489#[cfg(any(target_os = "linux", target_os = "freebsd"))]
490fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
491 let output = std::process::Command::new("gsettings")
492 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
493 .output()
494 .ok()?;
495 if !output.status.success() {
496 return None;
497 }
498 let value = String::from_utf8_lossy(&output.stdout);
499 appearance_from_color_scheme(&value)
500}
501
502#[cfg(any(target_os = "linux", target_os = "freebsd"))]
503fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
504 let value = value
505 .trim()
506 .trim_matches('\'')
507 .trim_matches('"')
508 .to_ascii_lowercase();
509 if value.contains("prefer-dark") {
510 Some(WindowAppearance::Dark)
511 } else if value.contains("prefer-light") || value == "default" {
512 Some(WindowAppearance::Light)
513 } else {
514 None
515 }
516}
517
518#[cfg(any(target_os = "linux", target_os = "freebsd"))]
519fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
520 for line in settings.lines() {
521 let line = line.trim();
522 if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
523 continue;
524 }
525 let Some((key, value)) = line.split_once('=') else {
526 continue;
527 };
528 let key = key.trim();
529 let value = value.trim();
530 if key == "gtk-application-prefer-dark-theme" {
531 return match value.to_ascii_lowercase().as_str() {
532 "true" | "1" => Some(WindowAppearance::Dark),
533 "false" | "0" => Some(WindowAppearance::Light),
534 _ => None,
535 };
536 }
537 if key == "gtk-theme-name"
538 && let Some(appearance) = appearance_from_theme_name(value)
539 {
540 return Some(appearance);
541 }
542 }
543 None
544}
545
546#[cfg(any(target_os = "linux", target_os = "freebsd"))]
547fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
548 let theme = theme.to_ascii_lowercase();
549 if theme.contains("dark") {
550 Some(WindowAppearance::Dark)
551 } else if theme.contains("light") {
552 Some(WindowAppearance::Light)
553 } else {
554 None
555 }
556}
557
558pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
560 match appearance {
561 WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
562 WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
563 }
564}
565
566pub struct Config {
568 pub theme: Theme,
570 pub theme_mode: ThemeMode,
572 pub fonts: FontConfig,
574 pub z_index_base: u32,
576}
577
578impl Global for Config {}
579
580impl Config {
581 pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
583 self.theme_mode = mode;
584 self.theme = mode.resolve(appearance);
585 }
586
587 pub fn set_font_config(&mut self, fonts: FontConfig) {
589 self.fonts = fonts;
590 }
591
592 pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
594 if self.theme_mode != ThemeMode::System {
595 return false;
596 }
597 let theme = ThemeMode::System.resolve(appearance);
598 let changed = self.theme.name != theme.name;
599 self.theme = theme;
600 changed
601 }
602}
603
604pub fn init_liora(cx: &mut App, theme: Theme) {
606 let theme_mode = ThemeMode::from_theme(&theme);
607 set_core_config(
608 cx,
609 Config {
610 theme,
611 theme_mode,
612 fonts: FontConfig::default(),
613 z_index_base: 1000,
614 },
615 );
616}
617
618fn set_core_config(cx: &mut App, config: Config) {
619 cx.set_global(config);
620 cx.set_global(crate::popper::ZIndexStack::default());
621 cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
622 cx.set_global(crate::popper::ActivePopover(Vec::new()));
623 cx.set_global(crate::popper::ActiveModal(Vec::new()));
624 cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
625}
626
627pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
629 init_liora_with_options(cx, LioraOptions::default().with_theme_mode(mode));
630}
631
632pub fn init_liora_with_options(cx: &mut App, options: LioraOptions) {
634 let appearance = startup_system_appearance(cx);
635 set_core_config(
636 cx,
637 Config {
638 theme: options.theme_mode.resolve(appearance),
639 theme_mode: options.theme_mode,
640 fonts: options.fonts,
641 z_index_base: 1000,
642 },
643 );
644}
645
646pub fn set_font_config(cx: &mut App, fonts: FontConfig) {
648 cx.global_mut::<Config>().set_font_config(fonts);
649}
650
651pub fn resolve_font_family_from_available(
658 candidates: &[SharedString],
659 available: &[String],
660 default: Option<SharedString>,
661) -> Option<SharedString> {
662 if candidates.is_empty() {
663 return default;
664 }
665
666 candidates
667 .iter()
668 .find(|candidate| available.iter().any(|family| family == candidate.as_ref()))
669 .cloned()
670 .or_else(|| candidates.last().cloned())
671}
672
673pub fn ui_font_family(cx: &App) -> Option<SharedString> {
675 let config = cx.global::<Config>();
676 let available = cx.text_system().all_font_names();
677 resolve_font_family_from_available(&config.fonts.ui_families, &available, None)
678}
679
680pub fn code_font_family(cx: &App) -> SharedString {
682 let config = cx.global::<Config>();
683 let available = cx.text_system().all_font_names();
684 resolve_font_family_from_available(
685 &config.fonts.code_families,
686 &available,
687 Some("Monospace".into()),
688 )
689 .unwrap_or_else(|| "Monospace".into())
690}
691
692pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
694 let appearance = current_system_appearance(window, cx);
695 cx.global_mut::<Config>().set_theme_mode(mode, appearance);
696 window.refresh();
697}
698
699pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
701 let appearance = current_system_appearance(window, cx);
702 if cx.global_mut::<Config>().sync_system_theme(appearance) {
703 window.refresh();
704 }
705}
706
707pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
715 sync_system_theme(window, cx);
716 window
717 .observe_window_appearance(|window, cx| sync_system_theme(window, cx))
718 .detach();
719}
720
721pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
723 for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
724 push_portal(
725 move |_window, _cx| entry.view.clone().into_any_element(),
726 cx,
727 );
728 }
729}
730
731pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
733 for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
734 push_portal(
735 move |_window, _cx| entry.view.clone().into_any_element(),
736 cx,
737 );
738 }
739}
740
741pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
743 for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
744 push_portal(
745 move |_window, _cx| entry.view.clone().into_any_element(),
746 cx,
747 );
748 }
749}
750
751pub fn tooltip_content_lines(content: &SharedString) -> Vec<SharedString> {
758 let lines: Vec<SharedString> = content
759 .split('\n')
760 .map(|line| line.trim_end_matches('\r'))
761 .map(SharedString::from)
762 .collect();
763
764 if lines.is_empty() {
765 vec![SharedString::default()]
766 } else {
767 lines
768 }
769}
770
771pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
773 let mouse_pos = window.mouse_position();
774 cx.global_mut::<crate::popper::ActiveTooltip>()
775 .0
776 .retain(|data| data.anchor_bounds.contains(&mouse_pos));
777
778 let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
779 for (tooltip_index, data) in active.into_iter().enumerate() {
780 let theme = cx.global::<Config>().theme.clone();
781
782 let font_size = px(theme.font_size.sm);
785 let text_style = window.text_style();
786 let lines = tooltip_content_lines(&data.content);
787 let max_line_width = lines
788 .iter()
789 .map(|line| {
790 let run = TextRun {
791 len: line.len(),
792 font: text_style.font(),
793 color: theme.neutral.card,
794 background_color: None,
795 underline: None,
796 strikethrough: None,
797 };
798 window
799 .text_system()
800 .shape_line(line.clone(), font_size, &[run], None)
801 .width
802 })
803 .fold(px(0.0), |max_width, width| max_width.max(width));
804
805 let padding_h = px(12.0);
806 let padding_v = px(6.0);
807 let line_height = window.line_height();
808 let content_size = gpui::Size {
809 width: max_line_width + padding_h * 2.0,
810 height: line_height * lines.len() + padding_v * 2.0,
811 };
812
813 push_passive_portal(
814 move |window, _cx| {
815 let viewport = Bounds {
816 origin: gpui::Point::default(),
817 size: window.viewport_size(),
818 };
819
820 let popper = Popper {
821 anchor_bounds: data.anchor_bounds,
822 placement: data.placement,
823 offset: data.offset,
824 };
825
826 let (pos, _final_placement) =
827 popper.calculate_position_with_flip(content_size, viewport);
828
829 gpui::div()
830 .absolute()
831 .cursor_default()
832 .top(pos.y)
833 .left(pos.x)
834 .w(content_size.width)
835 .h(content_size.height)
836 .bg(theme.neutral.text_1)
837 .text_color(theme.neutral.card)
838 .px(padding_h)
839 .py(padding_v)
840 .flex()
841 .flex_col()
842 .items_start()
843 .justify_center()
844 .gap(px(2.0))
845 .rounded(px(theme.radius.sm))
846 .shadow_lg()
847 .text_size(font_size)
848 .children(lines.iter().cloned())
849 .with_animation(
850 ("liora-tooltip-motion", tooltip_index),
851 Animation::new(Duration::from_millis(220))
852 .with_easing(gpui::ease_out_quint()),
853 |tooltip, delta| tooltip.opacity(delta),
854 )
855 .into_any_element()
856 },
857 cx,
858 );
859 }
860}
861
862#[cfg(test)]
863mod tooltip_text_tests {
864 use super::*;
865
866 #[test]
867 fn tooltip_renderer_never_shapes_multiline_content_as_one_line() {
868 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
869
870 assert!(
871 !source.contains(".shape_line(data.content.clone()"),
872 "GPUI shape_line panics when the text argument contains newlines"
873 );
874 assert!(
875 source.contains("tooltip_content_lines(&data.content)")
876 || source.contains("tooltip_content_lines("),
877 "tooltip rendering should split multiline content before measuring and rendering"
878 );
879 }
880
881 #[test]
882 fn tooltip_content_lines_preserves_multiline_tooltips_without_newline_lines() {
883 let lines = tooltip_content_lines(&SharedString::from("Mon\nO 100 H 110\nL 96 C 108"));
884
885 assert_eq!(lines, vec!["Mon", "O 100 H 110", "L 96 C 108"]);
886 assert!(lines.iter().all(|line| !line.contains('\n')));
887 }
888}
889
890#[cfg(test)]
891mod theme_mode_tests {
892 use super::*;
893
894 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
895 #[test]
896 fn linux_startup_appearance_parses_synchronous_dark_preferences() {
897 assert_eq!(
898 appearance_from_color_scheme("'prefer-dark'"),
899 Some(WindowAppearance::Dark)
900 );
901 assert_eq!(
902 appearance_from_color_scheme("prefer-light"),
903 Some(WindowAppearance::Light)
904 );
905 assert_eq!(
906 appearance_from_gtk_settings(
907 "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
908 ),
909 Some(WindowAppearance::Dark)
910 );
911 assert_eq!(
912 appearance_from_theme_name("Adwaita-dark"),
913 Some(WindowAppearance::Dark)
914 );
915 }
916
917 #[test]
918 fn theme_mode_values_and_labels_are_stable() {
919 assert_eq!(ThemeMode::System.value(), "system");
920 assert_eq!(ThemeMode::Light.label(), "Light");
921 assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
922 assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
923 assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
924 assert_eq!(ThemeMode::from_value("unknown"), None);
925 }
926
927 #[test]
928 fn system_theme_resolves_from_window_appearance() {
929 assert_eq!(
930 ThemeMode::System.resolve(WindowAppearance::Light).name,
931 Theme::light().name
932 );
933 assert_eq!(
934 ThemeMode::System
935 .resolve(WindowAppearance::VibrantDark)
936 .name,
937 Theme::dark().name
938 );
939 }
940
941 #[test]
942 fn config_syncs_only_in_system_mode() {
943 let mut config = Config {
944 theme: Theme::light(),
945 theme_mode: ThemeMode::Light,
946 fonts: FontConfig::default(),
947 z_index_base: 1000,
948 };
949 assert!(!config.sync_system_theme(WindowAppearance::Dark));
950 assert_eq!(config.theme.name, "light");
951
952 config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
953 assert_eq!(config.theme.name, "dark");
954 assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
955 assert!(config.sync_system_theme(WindowAppearance::Light));
956 assert_eq!(config.theme.name, "light");
957 }
958
959 #[test]
960 fn font_config_defaults_to_system_fonts_and_keeps_custom_families_explicit() {
961 let default = FontConfig::default();
962 assert!(default.ui_families.is_empty());
963 assert!(default.code_families.is_empty());
964
965 let custom = FontConfig::system()
966 .with_ui_families(["Inter", "Segoe UI"])
967 .with_code_families(["JetBrains Mono", "Monospace"]);
968 assert_eq!(
969 custom.ui_families(),
970 &[SharedString::from("Inter"), SharedString::from("Segoe UI")]
971 );
972 assert_eq!(
973 custom.code_families(),
974 &[
975 SharedString::from("JetBrains Mono"),
976 SharedString::from("Monospace")
977 ]
978 );
979
980 let options = LioraOptions::system()
981 .with_theme_mode(ThemeMode::Dark)
982 .with_fonts(custom.clone());
983 assert_eq!(options.theme_mode, ThemeMode::Dark);
984 assert_eq!(options.fonts, custom);
985 }
986
987 #[test]
988 fn font_config_accepts_ordered_fallback_family_lists() {
989 let custom = FontConfig::system()
990 .with_ui_families(["PingFang SC", "Segoe UI", "Arial"])
991 .with_code_families(["JetBrains Mono", "SF Mono", "Monospace"]);
992
993 assert_eq!(
994 custom.ui_families(),
995 &[
996 SharedString::from("PingFang SC"),
997 SharedString::from("Segoe UI"),
998 SharedString::from("Arial")
999 ]
1000 );
1001 assert_eq!(
1002 custom.code_families(),
1003 &[
1004 SharedString::from("JetBrains Mono"),
1005 SharedString::from("SF Mono"),
1006 SharedString::from("Monospace")
1007 ]
1008 );
1009 }
1010
1011 #[test]
1012 fn font_family_resolution_uses_first_available_then_final_fallback() {
1013 let candidates = [
1014 SharedString::from("PingFang SC"),
1015 SharedString::from("Segoe UI"),
1016 SharedString::from("Arial"),
1017 ];
1018 let available = [String::from("Segoe UI")];
1019
1020 assert_eq!(
1021 resolve_font_family_from_available(&candidates, &available, None).as_deref(),
1022 Some("Segoe UI")
1023 );
1024 assert_eq!(
1025 resolve_font_family_from_available(&candidates, &[], None).as_deref(),
1026 Some("Arial")
1027 );
1028 assert_eq!(
1029 resolve_font_family_from_available(&[], &available, Some("Monospace".into()))
1030 .as_deref(),
1031 Some("Monospace")
1032 );
1033 }
1034
1035 #[test]
1036 fn system_theme_observer_syncs_immediately_and_stays_attached() {
1037 let source = include_str!("lib.rs");
1038 let start = source
1039 .find("pub fn attach_system_theme_observer")
1040 .expect("system theme observer helper should exist");
1041 let body = &source[start
1042 ..source[start..]
1043 .find("pub fn render_active_popover_in_window")
1044 .expect("next function should follow observer helper")
1045 + start];
1046
1047 let sync_call = format!("{}(window, cx);", "sync_system_theme");
1048 let observe_call = format!("{}", "observe_window_appearance");
1049 let sync_index = body
1050 .find(&sync_call)
1051 .expect("observer helper should sync the current window appearance immediately");
1052 let observe_index = body
1053 .find(&observe_call)
1054 .expect("observer helper should observe later appearance changes");
1055 assert!(sync_index < observe_index);
1056 assert!(body.contains(".detach();"));
1057 }
1058}
1059
1060#[cfg(test)]
1061mod motion_tests {
1062 #[test]
1063 fn tooltip_rendering_uses_gpui_motion() {
1064 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
1065
1066 assert!(source.contains("tooltip-motion"));
1067 assert!(source.contains("with_animation("));
1068 }
1069}
1070
1071pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
1073 &cx.global::<Config>().theme
1074}
1075
1076pub trait ContextExt {
1078 fn liora(&self) -> &Theme;
1080}
1081
1082impl<'a, V> ContextExt for Context<'a, V> {
1083 fn liora(&self) -> &Theme {
1084 liora_theme(self)
1085 }
1086}
1087
1088pub trait ElementExt {
1090 fn liora(self, cx: &mut App) -> Self;
1092}
1093
1094impl ElementExt for gpui::Div {
1095 fn liora(self, _cx: &mut App) -> Self {
1096 self
1097 }
1098}
1099
1100pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
1102 cx.global::<Config>().z_index_base + 100
1103}
1104
1105pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
1107 cx.global::<Config>().z_index_base + 200
1108}
1109
1110pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
1112 cx.global::<Config>().z_index_base + 300
1113}
1114
1115pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
1117 cx.global::<Config>().z_index_base + 400
1118}
1119
1120pub fn hex_color(hex: u32) -> Hsla {
1122 gpui::rgb(hex).into()
1123}
1124
1125#[cfg(test)]
1126mod unique_id_tests {
1127 use super::*;
1128
1129 #[test]
1130 fn generated_ids_are_prefixed_and_unique() {
1131 let first = unique_id("component");
1132 let second = unique_id("component");
1133
1134 assert!(first.as_ref().starts_with("component-"));
1135 assert!(second.as_ref().starts_with("component-"));
1136 assert_ne!(first, second);
1137 }
1138}
1139
1140#[cfg(test)]
1141mod desktop_identity_tests {
1142 use super::*;
1143
1144 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1145 #[test]
1146 fn linux_desktop_identity_installs_desktop_entry_and_hicolor_icons() {
1147 let temp_root = std::env::temp_dir().join(format!(
1148 "liora-desktop-identity-test-{}",
1149 std::process::id()
1150 ));
1151 let _ = std::fs::remove_dir_all(&temp_root);
1152 std::fs::create_dir_all(&temp_root).expect("test temp root should be creatable");
1153
1154 ensure_linux_desktop_identity_impl(
1155 temp_root.clone(),
1156 LinuxDesktopIdentity {
1157 app_id: "liora-test",
1158 desktop_entry: Cow::Borrowed(
1159 "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n",
1160 ),
1161 png_icons: &[LinuxDesktopPngIcon {
1162 size: 48,
1163 bytes: b"png",
1164 }],
1165 svg_icon: b"svg",
1166 },
1167 false,
1168 )
1169 .expect("identity registration should write into the supplied XDG data home");
1170
1171 assert_eq!(
1172 std::fs::read_to_string(temp_root.join("applications/liora-test.desktop"))
1173 .expect("desktop entry should be installed"),
1174 "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n"
1175 );
1176 assert_eq!(
1177 std::fs::read(temp_root.join("icons/hicolor/48x48/apps/liora-test.png"))
1178 .expect("PNG hicolor icon should be installed"),
1179 b"png"
1180 );
1181 assert_eq!(
1182 std::fs::read(temp_root.join("icons/hicolor/scalable/apps/liora-test.svg"))
1183 .expect("SVG hicolor icon should be installed"),
1184 b"svg"
1185 );
1186 assert!(
1187 std::fs::read_to_string(temp_root.join("icons/hicolor/index.theme"))
1188 .expect("hicolor index theme should be installed")
1189 .contains("Directories=16x16/apps")
1190 );
1191
1192 let _ = std::fs::remove_dir_all(temp_root);
1193 }
1194}