1use gpui::{
10 Animation, AnimationExt, App, Bounds, Context, Global, Hsla, Pixels, SharedString, TextRun,
11 Window, WindowAppearance, WindowBounds, prelude::*, px,
12};
13
14pub use gpui::FontWeight;
15
16use std::borrow::Cow;
17use std::path::Path;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22pub mod fonts;
23pub mod locales;
24
25pub use fonts::*;
26pub use locales::*;
27
28static NEXT_UNIQUE_ID: AtomicU64 = AtomicU64::new(1);
29
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct FontConfig {
42 pub ui_families: Vec<SharedString>,
46 pub code_families: Vec<SharedString>,
50 pub ui_weight: Option<FontWeight>,
54 pub code_weight: Option<FontWeight>,
57}
58
59impl FontConfig {
60 pub fn system() -> Self {
62 Self::default()
63 }
64
65 pub fn with_ui_families(
67 mut self,
68 families: impl IntoIterator<Item = impl Into<SharedString>>,
69 ) -> Self {
70 self.ui_families = families.into_iter().map(Into::into).collect();
71 self
72 }
73
74 pub fn with_code_families(
76 mut self,
77 families: impl IntoIterator<Item = impl Into<SharedString>>,
78 ) -> Self {
79 self.code_families = families.into_iter().map(Into::into).collect();
80 self
81 }
82
83 pub fn with_ui_weight(mut self, weight: FontWeight) -> Self {
85 self.ui_weight = Some(weight);
86 self
87 }
88
89 pub fn with_code_weight(mut self, weight: FontWeight) -> Self {
91 self.code_weight = Some(weight);
92 self
93 }
94
95 pub fn ui_families(&self) -> &[SharedString] {
97 &self.ui_families
98 }
99
100 pub fn code_families(&self) -> &[SharedString] {
102 &self.code_families
103 }
104
105 pub fn ui_weight(&self) -> Option<FontWeight> {
107 self.ui_weight
108 }
109
110 pub fn code_weight(&self) -> Option<FontWeight> {
112 self.code_weight
113 }
114}
115
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
118pub struct Options {
119 pub theme_mode: ThemeMode,
121 pub fonts: FontConfig,
123 pub locales: LocalesConfig,
125}
126
127impl Options {
128 pub fn system() -> Self {
130 Self::default()
131 }
132
133 pub fn with_theme_mode(mut self, mode: ThemeMode) -> Self {
135 self.theme_mode = mode;
136 self
137 }
138
139 pub fn with_fonts(mut self, fonts: FontConfig) -> Self {
141 self.fonts = fonts;
142 self
143 }
144
145 pub fn with_locale(mut self, locale: impl Into<LocaleId>) -> Self {
147 self.locales = self.locales.with_locale(locale);
148 self
149 }
150
151 pub fn with_fallback_locale(mut self, locale: impl Into<LocaleId>) -> Self {
153 self.locales = self.locales.with_fallback_locale(locale);
154 self
155 }
156
157 pub fn with_locales(mut self, locales: LocalesConfig) -> Self {
159 self.locales = locales;
160 self
161 }
162
163 pub fn with_locales_resources(mut self, resources: LocalesMap) -> Self {
165 self.locales = self.locales.with_resources(resources);
166 self
167 }
168
169 pub fn try_with_locales_dir(mut self, dir: impl AsRef<Path>) -> Result<Self, LocalesLoadError> {
171 self.locales = self.locales.try_with_locales_dir(dir)?;
172 Ok(self)
173 }
174
175 pub fn with_translator(mut self, translator: impl Translator + 'static) -> Self {
177 self.locales = self.locales.with_translator(translator);
178 self
179 }
180
181 pub fn with_shared_translator(mut self, translator: Arc<dyn Translator>) -> Self {
183 self.locales = self.locales.with_shared_translator(translator);
184 self
185 }
186}
187
188pub fn load_custom_fonts(
196 cx: &mut App,
197 fonts: impl IntoIterator<Item = Cow<'static, [u8]>>,
198) -> gpui::Result<()> {
199 cx.text_system().add_fonts(fonts.into_iter().collect())
200}
201
202pub fn next_unique_id() -> u64 {
204 NEXT_UNIQUE_ID.fetch_add(1, Ordering::Relaxed)
205}
206
207pub fn unique_id(prefix: &str) -> gpui::SharedString {
214 format!("{}-{}", prefix, next_unique_id()).into()
215}
216
217pub fn stable_unique_id(
223 key: impl Into<gpui::SharedString>,
224 prefix: &str,
225 window: &mut Window,
226 cx: &mut App,
227) -> gpui::SharedString {
228 let prefix = prefix.to_string();
229 let key = gpui::ElementId::from(key.into());
230 window
231 .use_keyed_state(key, cx, move |_, _| unique_id(&prefix))
232 .read(cx)
233 .clone()
234}
235
236pub mod popper;
238
239pub use popper::*;
240
241pub use liora_theme::Theme;
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
244pub enum ThemeMode {
246 #[default]
247 System,
249 Light,
251 Dark,
253}
254
255impl ThemeMode {
256 pub fn label(self) -> &'static str {
258 match self {
259 Self::System => "System",
260 Self::Light => "Light",
261 Self::Dark => "Dark",
262 }
263 }
264
265 pub fn value(self) -> &'static str {
267 match self {
268 Self::System => "system",
269 Self::Light => "light",
270 Self::Dark => "dark",
271 }
272 }
273
274 pub fn from_value(value: &str) -> Option<Self> {
276 match value {
277 "system" => Some(Self::System),
278 "light" => Some(Self::Light),
279 "dark" => Some(Self::Dark),
280 _ => None,
281 }
282 }
283
284 pub fn resolve(self, appearance: WindowAppearance) -> Theme {
286 match self {
287 Self::System => theme_for_window_appearance(appearance),
288 Self::Light => Theme::light(),
289 Self::Dark => Theme::dark(),
290 }
291 }
292
293 pub fn from_theme(theme: &Theme) -> Self {
295 match theme.name.as_str() {
296 "dark" => Self::Dark,
297 _ => Self::Light,
298 }
299 }
300}
301
302pub fn startup_maximized_window_bounds(
309 cx: &App,
310 fallback_size: gpui::Size<Pixels>,
311) -> WindowBounds {
312 let bounds = cx
313 .primary_display()
314 .map(|display| display.bounds())
315 .unwrap_or(Bounds {
316 origin: gpui::Point::default(),
317 size: fallback_size,
318 });
319 WindowBounds::Maximized(bounds)
320}
321
322#[derive(Clone, Debug)]
331pub struct LinuxDesktopIdentity<'a> {
332 pub app_id: &'a str,
334 pub desktop_entry: Cow<'a, str>,
336 pub png_icons: &'a [LinuxDesktopPngIcon<'a>],
338 pub svg_icon: &'a [u8],
340}
341
342#[derive(Clone, Copy, Debug)]
344pub struct LinuxDesktopPngIcon<'a> {
345 pub size: u16,
347 pub bytes: &'a [u8],
349}
350
351pub fn linux_desktop_entry(
358 name: &str,
359 generic_name: &str,
360 comment: &str,
361 executable: &std::path::Path,
362 icon: &str,
363 categories: &str,
364 keywords: &str,
365) -> String {
366 format!(
367 "[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",
368 desktop_exec_value(executable),
369 )
370}
371
372pub fn linux_desktop_png_icon_path(app_id: &str, size: u16) -> Option<std::path::PathBuf> {
378 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
379 {
380 linux_xdg_data_home().ok().map(|data_home| {
381 data_home
382 .join("icons")
383 .join("hicolor")
384 .join(format!("{size}x{size}"))
385 .join("apps")
386 .join(format!("{app_id}.png"))
387 })
388 }
389
390 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
391 {
392 let _ = (app_id, size);
393 None
394 }
395}
396
397fn desktop_exec_value(executable: &std::path::Path) -> String {
398 let value = executable.to_string_lossy();
399 if value
400 .chars()
401 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
402 {
403 value.into_owned()
404 } else {
405 format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\""))
406 }
407}
408
409pub fn ensure_linux_desktop_identity(identity: LinuxDesktopIdentity<'_>) -> std::io::Result<()> {
418 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
419 {
420 ensure_linux_desktop_identity_impl(linux_xdg_data_home()?, identity, true)
421 }
422
423 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
424 {
425 let _ = identity;
426 Ok(())
427 }
428}
429
430#[cfg(any(target_os = "linux", target_os = "freebsd"))]
431fn ensure_linux_desktop_identity_impl(
432 data_home: std::path::PathBuf,
433 identity: LinuxDesktopIdentity<'_>,
434 refresh_cache: bool,
435) -> std::io::Result<()> {
436 let mut changed = write_if_changed(
437 &data_home
438 .join("applications")
439 .join(format!("{}.desktop", identity.app_id)),
440 identity.desktop_entry.as_bytes(),
441 )?;
442 for icon in identity.png_icons {
443 changed |= write_if_changed(
444 &data_home
445 .join("icons")
446 .join("hicolor")
447 .join(format!("{}x{}", icon.size, icon.size))
448 .join("apps")
449 .join(format!("{}.png", identity.app_id)),
450 icon.bytes,
451 )?;
452 }
453 changed |= write_if_changed(
454 &data_home
455 .join("icons")
456 .join("hicolor")
457 .join("scalable")
458 .join("apps")
459 .join(format!("{}.svg", identity.app_id)),
460 identity.svg_icon,
461 )?;
462 changed |= ensure_hicolor_index_theme(&data_home)?;
463 if changed && refresh_cache {
464 refresh_linux_desktop_identity_cache(&data_home);
465 }
466 Ok(())
467}
468
469#[cfg(any(target_os = "linux", target_os = "freebsd"))]
470fn refresh_linux_desktop_identity_cache(data_home: &std::path::Path) {
471 let apps_dir = data_home.join("applications");
472 let _ = std::fs::remove_file(
473 data_home
474 .join("icons")
475 .join("hicolor")
476 .join(".icon-theme.cache"),
477 );
478 let _ = std::process::Command::new("update-desktop-database")
479 .arg(&apps_dir)
480 .status();
481 for command in ["kbuildsycoca6", "kbuildsycoca5"] {
482 let _ = std::process::Command::new(command)
483 .arg("--noincremental")
484 .status();
485 }
486}
487
488#[cfg(any(target_os = "linux", target_os = "freebsd"))]
489fn ensure_hicolor_index_theme(data_home: &std::path::Path) -> std::io::Result<bool> {
490 write_if_changed(
491 &data_home.join("icons").join("hicolor").join("index.theme"),
492 HICOLOR_INDEX_THEME.as_bytes(),
493 )
494}
495
496#[cfg(any(target_os = "linux", target_os = "freebsd"))]
497const 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";
498
499#[cfg(any(target_os = "linux", target_os = "freebsd"))]
500fn linux_xdg_data_home() -> std::io::Result<std::path::PathBuf> {
501 if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") {
502 return Ok(std::path::PathBuf::from(data_home));
503 }
504 std::env::var_os("HOME")
505 .map(std::path::PathBuf::from)
506 .map(|home| home.join(".local").join("share"))
507 .ok_or_else(|| {
508 std::io::Error::new(
509 std::io::ErrorKind::NotFound,
510 "neither XDG_DATA_HOME nor HOME is set",
511 )
512 })
513}
514
515#[cfg(any(target_os = "linux", target_os = "freebsd"))]
516fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<bool> {
517 if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
518 return Ok(false);
519 }
520 if let Some(parent) = path.parent() {
521 std::fs::create_dir_all(parent)?;
522 }
523 std::fs::write(path, bytes)?;
524 Ok(true)
525}
526
527fn startup_system_appearance(cx: &App) -> WindowAppearance {
528 platform_system_appearance().unwrap_or_else(|| cx.window_appearance())
529}
530
531fn current_system_appearance(window: &Window, _cx: &App) -> WindowAppearance {
532 platform_system_appearance().unwrap_or_else(|| window.appearance())
533}
534
535#[cfg(any(target_os = "linux", target_os = "freebsd"))]
536fn platform_system_appearance() -> Option<WindowAppearance> {
537 gtk_theme_env_appearance()
538 .or_else(gtk_settings_appearance)
539 .or_else(gsettings_color_scheme_appearance)
540}
541
542#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
543fn platform_system_appearance() -> Option<WindowAppearance> {
544 None
545}
546
547#[cfg(any(target_os = "linux", target_os = "freebsd"))]
548fn gtk_theme_env_appearance() -> Option<WindowAppearance> {
549 std::env::var("GTK_THEME")
550 .ok()
551 .and_then(|theme| appearance_from_theme_name(&theme))
552}
553
554#[cfg(any(target_os = "linux", target_os = "freebsd"))]
555fn gtk_settings_appearance() -> Option<WindowAppearance> {
556 ["gtk-4.0", "gtk-3.0"]
557 .into_iter()
558 .filter_map(|version| {
559 std::env::var_os("HOME").map(|home| {
560 std::path::PathBuf::from(home)
561 .join(".config")
562 .join(version)
563 .join("settings.ini")
564 })
565 })
566 .filter_map(|path| std::fs::read_to_string(path).ok())
567 .find_map(|settings| appearance_from_gtk_settings(&settings))
568}
569
570#[cfg(any(target_os = "linux", target_os = "freebsd"))]
571fn gsettings_color_scheme_appearance() -> Option<WindowAppearance> {
572 let output = std::process::Command::new("gsettings")
573 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
574 .output()
575 .ok()?;
576 if !output.status.success() {
577 return None;
578 }
579 let value = String::from_utf8_lossy(&output.stdout);
580 appearance_from_color_scheme(&value)
581}
582
583#[cfg(any(target_os = "linux", target_os = "freebsd"))]
584fn appearance_from_color_scheme(value: &str) -> Option<WindowAppearance> {
585 let value = value
586 .trim()
587 .trim_matches('\'')
588 .trim_matches('"')
589 .to_ascii_lowercase();
590 if value.contains("prefer-dark") {
591 Some(WindowAppearance::Dark)
592 } else if value.contains("prefer-light") || value == "default" {
593 Some(WindowAppearance::Light)
594 } else {
595 None
596 }
597}
598
599#[cfg(any(target_os = "linux", target_os = "freebsd"))]
600fn appearance_from_gtk_settings(settings: &str) -> Option<WindowAppearance> {
601 for line in settings.lines() {
602 let line = line.trim();
603 if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
604 continue;
605 }
606 let Some((key, value)) = line.split_once('=') else {
607 continue;
608 };
609 let key = key.trim();
610 let value = value.trim();
611 if key == "gtk-application-prefer-dark-theme" {
612 return match value.to_ascii_lowercase().as_str() {
613 "true" | "1" => Some(WindowAppearance::Dark),
614 "false" | "0" => Some(WindowAppearance::Light),
615 _ => None,
616 };
617 }
618 if key == "gtk-theme-name"
619 && let Some(appearance) = appearance_from_theme_name(value)
620 {
621 return Some(appearance);
622 }
623 }
624 None
625}
626
627#[cfg(any(target_os = "linux", target_os = "freebsd"))]
628fn appearance_from_theme_name(theme: &str) -> Option<WindowAppearance> {
629 let theme = theme.to_ascii_lowercase();
630 if theme.contains("dark") {
631 Some(WindowAppearance::Dark)
632 } else if theme.contains("light") {
633 Some(WindowAppearance::Light)
634 } else {
635 None
636 }
637}
638
639pub fn theme_for_window_appearance(appearance: WindowAppearance) -> Theme {
641 match appearance {
642 WindowAppearance::Light | WindowAppearance::VibrantLight => Theme::light(),
643 WindowAppearance::Dark | WindowAppearance::VibrantDark => Theme::dark(),
644 }
645}
646
647pub struct Config {
649 pub theme: Theme,
651 pub theme_mode: ThemeMode,
653 pub fonts: FontConfig,
655 pub locales: LocalesConfig,
657 pub z_index_base: u32,
659}
660
661impl Global for Config {}
662
663impl Config {
664 pub fn set_theme_mode(&mut self, mode: ThemeMode, appearance: WindowAppearance) {
666 self.theme_mode = mode;
667 self.theme = mode.resolve(appearance);
668 }
669
670 pub fn set_font_config(&mut self, fonts: FontConfig) {
672 self.fonts = fonts;
673 }
674
675 pub fn set_locales_config(&mut self, locales: LocalesConfig) {
677 self.locales = locales;
678 }
679
680 pub fn sync_system_theme(&mut self, appearance: WindowAppearance) -> bool {
682 if self.theme_mode != ThemeMode::System {
683 return false;
684 }
685 let theme = ThemeMode::System.resolve(appearance);
686 let changed = self.theme.name != theme.name;
687 self.theme = theme;
688 changed
689 }
690}
691
692pub fn init_liora(cx: &mut App, theme: Theme) {
694 let theme_mode = ThemeMode::from_theme(&theme);
695 set_core_config(
696 cx,
697 Config {
698 theme,
699 theme_mode,
700 fonts: FontConfig::default(),
701 locales: LocalesConfig::default(),
702 z_index_base: 1000,
703 },
704 );
705}
706
707fn set_core_config(cx: &mut App, config: Config) {
708 cx.set_global(config);
709 cx.set_global(crate::popper::ZIndexStack::default());
710 cx.set_global(crate::popper::ActiveTooltip(Vec::new()));
711 cx.set_global(crate::popper::ActivePopover(Vec::new()));
712 cx.set_global(crate::popper::ActiveModal(Vec::new()));
713 cx.set_global(crate::popper::ActiveDrawer(Vec::new()));
714}
715
716pub fn init_liora_with_mode(cx: &mut App, mode: ThemeMode) {
718 init_liora_with_options(cx, Options::default().with_theme_mode(mode));
719}
720
721pub fn init_liora_with_options(cx: &mut App, options: Options) {
723 let appearance = startup_system_appearance(cx);
724 set_core_config(
725 cx,
726 Config {
727 theme: options.theme_mode.resolve(appearance),
728 theme_mode: options.theme_mode,
729 fonts: options.fonts,
730 locales: options.locales,
731 z_index_base: 1000,
732 },
733 );
734}
735
736pub fn set_font_config(cx: &mut App, fonts: FontConfig) {
738 cx.global_mut::<Config>().set_font_config(fonts);
739}
740
741pub fn resolve_font_family_from_available(
748 candidates: &[SharedString],
749 available: &[String],
750 default: Option<SharedString>,
751) -> Option<SharedString> {
752 if candidates.is_empty() {
753 return default;
754 }
755
756 candidates
757 .iter()
758 .find(|candidate| available.iter().any(|family| family == candidate.as_ref()))
759 .cloned()
760 .or_else(|| candidates.last().cloned())
761}
762
763pub fn ui_font_family(cx: &App) -> Option<SharedString> {
765 let config = cx.global::<Config>();
766 let available = cx.text_system().all_font_names();
767 resolve_font_family_from_available(&config.fonts.ui_families, &available, None)
768}
769
770pub fn ui_font_weight(cx: &App) -> Option<FontWeight> {
772 cx.global::<Config>().fonts.ui_weight
773}
774
775pub fn code_font_family(cx: &App) -> SharedString {
777 let config = cx.global::<Config>();
778 let available = cx.text_system().all_font_names();
779 resolve_font_family_from_available(
780 &config.fonts.code_families,
781 &available,
782 Some("Monospace".into()),
783 )
784 .unwrap_or_else(|| "Monospace".into())
785}
786
787pub fn code_font_weight(cx: &App) -> Option<FontWeight> {
789 cx.global::<Config>().fonts.code_weight
790}
791
792pub fn apply_theme_mode(window: &mut Window, cx: &mut App, mode: ThemeMode) {
794 let appearance = current_system_appearance(window, cx);
795 cx.global_mut::<Config>().set_theme_mode(mode, appearance);
796 window.refresh();
797}
798
799pub fn sync_system_theme(window: &mut Window, cx: &mut App) {
801 let appearance = current_system_appearance(window, cx);
802 if cx.global_mut::<Config>().sync_system_theme(appearance) {
803 window.refresh();
804 }
805}
806
807pub fn attach_system_theme_observer(window: &mut Window, cx: &mut App) {
815 sync_system_theme(window, cx);
816 window.observe_window_appearance(sync_system_theme).detach();
817}
818
819pub fn render_active_popover_in_window(_window: &mut gpui::Window, cx: &mut App) {
821 for entry in cx.global::<crate::popper::ActivePopover>().0.clone() {
822 push_portal(
823 move |_window, _cx| entry.view.clone().into_any_element(),
824 cx,
825 );
826 }
827}
828
829pub fn render_active_modal_in_window(_window: &mut gpui::Window, cx: &mut App) {
831 for entry in cx.global::<crate::popper::ActiveModal>().0.clone() {
832 push_portal(
833 move |_window, _cx| entry.view.clone().into_any_element(),
834 cx,
835 );
836 }
837}
838
839pub fn render_active_drawer_in_window(_window: &mut gpui::Window, cx: &mut App) {
841 for entry in cx.global::<crate::popper::ActiveDrawer>().0.clone() {
842 push_portal(
843 move |_window, _cx| entry.view.clone().into_any_element(),
844 cx,
845 );
846 }
847}
848
849pub fn tooltip_content_lines(content: &SharedString) -> Vec<SharedString> {
856 let lines: Vec<SharedString> = content
857 .split('\n')
858 .map(|line| line.trim_end_matches('\r'))
859 .map(SharedString::from)
860 .collect();
861
862 if lines.is_empty() {
863 vec![SharedString::default()]
864 } else {
865 lines
866 }
867}
868
869pub fn render_active_tooltip_in_window(window: &mut gpui::Window, cx: &mut App) {
871 let mouse_pos = window.mouse_position();
872 cx.global_mut::<crate::popper::ActiveTooltip>()
873 .0
874 .retain(|data| data.anchor_bounds.contains(&mouse_pos));
875
876 let active = cx.global::<crate::popper::ActiveTooltip>().0.clone();
877 for (tooltip_index, data) in active.into_iter().enumerate() {
878 let theme = cx.global::<Config>().theme.clone();
879
880 let font_size = px(theme.font_size.sm);
883 let text_style = window.text_style();
884 let lines = tooltip_content_lines(&data.content);
885 let max_line_width = lines
886 .iter()
887 .map(|line| {
888 let run = TextRun {
889 len: line.len(),
890 font: text_style.font(),
891 color: theme.neutral.card,
892 background_color: None,
893 underline: None,
894 strikethrough: None,
895 };
896 window
897 .text_system()
898 .shape_line(line.clone(), font_size, &[run], None)
899 .width
900 })
901 .fold(px(0.0), |max_width, width| max_width.max(width));
902
903 let padding_h = px(12.0);
904 let padding_v = px(6.0);
905 let line_height = window.line_height();
906 let content_size = gpui::Size {
907 width: max_line_width + padding_h * 2.0,
908 height: line_height * lines.len() + padding_v * 2.0,
909 };
910
911 push_passive_portal(
912 move |window, _cx| {
913 let viewport = Bounds {
914 origin: gpui::Point::default(),
915 size: window.viewport_size(),
916 };
917
918 let popper = Popper {
919 anchor_bounds: data.anchor_bounds,
920 placement: data.placement,
921 offset: data.offset,
922 };
923
924 let (pos, _final_placement) =
925 popper.calculate_position_with_flip(content_size, viewport);
926
927 gpui::div()
928 .absolute()
929 .cursor_default()
930 .top(pos.y)
931 .left(pos.x)
932 .w(content_size.width)
933 .h(content_size.height)
934 .bg(theme.neutral.text_1)
935 .text_color(theme.neutral.card)
936 .px(padding_h)
937 .py(padding_v)
938 .flex()
939 .flex_col()
940 .items_start()
941 .justify_center()
942 .gap(px(2.0))
943 .rounded(px(theme.radius.sm))
944 .shadow_lg()
945 .text_size(font_size)
946 .children(lines.iter().cloned().map(|line| {
947 gpui::div()
948 .whitespace_nowrap()
949 .child(line)
950 .into_any_element()
951 }))
952 .with_animation(
953 ("liora-tooltip-motion", tooltip_index),
954 Animation::new(Duration::from_millis(220))
955 .with_easing(gpui::ease_out_quint()),
956 |tooltip, delta| tooltip.opacity(delta),
957 )
958 .into_any_element()
959 },
960 cx,
961 );
962 }
963}
964
965#[cfg(test)]
966mod tooltip_text_tests {
967 use super::*;
968
969 #[test]
970 fn tooltip_renderer_never_shapes_multiline_content_as_one_line() {
971 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
972
973 assert!(
974 !source.contains(".shape_line(data.content.clone()"),
975 "GPUI shape_line panics when the text argument contains newlines"
976 );
977 assert!(
978 source.contains("tooltip_content_lines(&data.content)")
979 || source.contains("tooltip_content_lines("),
980 "tooltip rendering should split multiline content before measuring and rendering"
981 );
982 }
983
984 #[test]
985 fn tooltip_content_lines_preserves_multiline_tooltips_without_newline_lines() {
986 let lines = tooltip_content_lines(&SharedString::from("Mon\nO 100 H 110\nL 96 C 108"));
987
988 assert_eq!(lines, vec!["Mon", "O 100 H 110", "L 96 C 108"]);
989 assert!(lines.iter().all(|line| !line.contains('\n')));
990 }
991
992 #[test]
993 fn tooltip_lines_are_rendered_as_single_visual_lines() {
994 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
995
996 assert!(
997 source.contains(".whitespace_nowrap()"),
998 "tooltip line elements must opt out of default wrapping so short labels do not fold inside the overlay"
999 );
1000 }
1001}
1002
1003#[cfg(test)]
1004mod theme_mode_tests {
1005 use super::*;
1006
1007 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1008 #[test]
1009 fn linux_startup_appearance_parses_synchronous_dark_preferences() {
1010 assert_eq!(
1011 appearance_from_color_scheme("'prefer-dark'"),
1012 Some(WindowAppearance::Dark)
1013 );
1014 assert_eq!(
1015 appearance_from_color_scheme("prefer-light"),
1016 Some(WindowAppearance::Light)
1017 );
1018 assert_eq!(
1019 appearance_from_gtk_settings(
1020 "[Settings]\ngtk-application-prefer-dark-theme=true\ngtk-theme-name=Breeze\n"
1021 ),
1022 Some(WindowAppearance::Dark)
1023 );
1024 assert_eq!(
1025 appearance_from_theme_name("Adwaita-dark"),
1026 Some(WindowAppearance::Dark)
1027 );
1028 }
1029
1030 #[test]
1031 fn theme_mode_values_and_labels_are_stable() {
1032 assert_eq!(ThemeMode::System.value(), "system");
1033 assert_eq!(ThemeMode::Light.label(), "Light");
1034 assert_eq!(ThemeMode::from_value("dark"), Some(ThemeMode::Dark));
1035 assert_eq!(ThemeMode::from_theme(&Theme::dark()), ThemeMode::Dark);
1036 assert_eq!(ThemeMode::from_theme(&Theme::light()), ThemeMode::Light);
1037 assert_eq!(ThemeMode::from_value("unknown"), None);
1038 }
1039
1040 #[test]
1041 fn system_theme_resolves_from_window_appearance() {
1042 assert_eq!(
1043 ThemeMode::System.resolve(WindowAppearance::Light).name,
1044 Theme::light().name
1045 );
1046 assert_eq!(
1047 ThemeMode::System
1048 .resolve(WindowAppearance::VibrantDark)
1049 .name,
1050 Theme::dark().name
1051 );
1052 }
1053
1054 #[test]
1055 fn config_syncs_only_in_system_mode() {
1056 let mut config = Config {
1057 theme: Theme::light(),
1058 theme_mode: ThemeMode::Light,
1059 fonts: FontConfig::default(),
1060 locales: LocalesConfig::default(),
1061 z_index_base: 1000,
1062 };
1063 assert!(!config.sync_system_theme(WindowAppearance::Dark));
1064 assert_eq!(config.theme.name, "light");
1065
1066 config.set_theme_mode(ThemeMode::System, WindowAppearance::Dark);
1067 assert_eq!(config.theme.name, "dark");
1068 assert!(!config.sync_system_theme(WindowAppearance::VibrantDark));
1069 assert!(config.sync_system_theme(WindowAppearance::Light));
1070 assert_eq!(config.theme.name, "light");
1071 }
1072
1073 #[test]
1074 fn font_config_defaults_to_system_fonts_and_keeps_custom_families_explicit() {
1075 let default = FontConfig::default();
1076 assert!(default.ui_families.is_empty());
1077 assert!(default.code_families.is_empty());
1078 assert_eq!(default.ui_weight(), None);
1079 assert_eq!(default.code_weight(), None);
1080
1081 let custom = FontConfig::system()
1082 .with_ui_families(["Inter", "Segoe UI"])
1083 .with_code_families(["JetBrains Mono", "Monospace"])
1084 .with_ui_weight(FontWeight::MEDIUM)
1085 .with_code_weight(FontWeight::NORMAL);
1086 assert_eq!(
1087 custom.ui_families(),
1088 &[SharedString::from("Inter"), SharedString::from("Segoe UI")]
1089 );
1090 assert_eq!(
1091 custom.code_families(),
1092 &[
1093 SharedString::from("JetBrains Mono"),
1094 SharedString::from("Monospace")
1095 ]
1096 );
1097 assert_eq!(custom.ui_weight(), Some(FontWeight::MEDIUM));
1098 assert_eq!(custom.code_weight(), Some(FontWeight::NORMAL));
1099 let options = Options::system()
1100 .with_theme_mode(ThemeMode::Dark)
1101 .with_fonts(custom.clone());
1102 assert_eq!(options.theme_mode, ThemeMode::Dark);
1103 assert_eq!(options.fonts, custom);
1104 assert_eq!(options.locales.locale.as_str(), "en-US");
1105 }
1106
1107 #[test]
1108 fn font_config_accepts_ordered_fallback_family_lists() {
1109 let custom = FontConfig::system()
1110 .with_ui_families(["MiSans", "Segoe UI", "Arial"])
1111 .with_code_families(["JetBrains Mono", "SF Mono", "Monospace"])
1112 .with_ui_weight(FontWeight::MEDIUM);
1113
1114 assert_eq!(
1115 custom.ui_families(),
1116 &[
1117 SharedString::from("MiSans"),
1118 SharedString::from("Segoe UI"),
1119 SharedString::from("Arial")
1120 ]
1121 );
1122 assert_eq!(
1123 custom.code_families(),
1124 &[
1125 SharedString::from("JetBrains Mono"),
1126 SharedString::from("SF Mono"),
1127 SharedString::from("Monospace")
1128 ]
1129 );
1130 assert_eq!(custom.ui_weight(), Some(FontWeight::MEDIUM));
1131 }
1132
1133 #[test]
1134 fn font_family_resolution_uses_first_available_then_final_fallback() {
1135 let candidates = [
1136 SharedString::from("MiSans"),
1137 SharedString::from("Segoe UI"),
1138 SharedString::from("Arial"),
1139 ];
1140 let available = [String::from("Segoe UI")];
1141
1142 assert_eq!(
1143 resolve_font_family_from_available(&candidates, &available, None).as_deref(),
1144 Some("Segoe UI")
1145 );
1146 assert_eq!(
1147 resolve_font_family_from_available(&candidates, &[], None).as_deref(),
1148 Some("Arial")
1149 );
1150 assert_eq!(
1151 resolve_font_family_from_available(&[], &available, Some("Monospace".into()))
1152 .as_deref(),
1153 Some("Monospace")
1154 );
1155 }
1156
1157 #[test]
1158 fn system_theme_observer_syncs_immediately_and_stays_attached() {
1159 let source = include_str!("lib.rs");
1160 let start = source
1161 .find("pub fn attach_system_theme_observer")
1162 .expect("system theme observer helper should exist");
1163 let body = &source[start
1164 ..source[start..]
1165 .find("pub fn render_active_popover_in_window")
1166 .expect("next function should follow observer helper")
1167 + start];
1168
1169 let sync_call = "sync_system_theme(window, cx);".to_string();
1170 let observe_call = "observe_window_appearance".to_string();
1171 let sync_index = body
1172 .find(&sync_call)
1173 .expect("observer helper should sync the current window appearance immediately");
1174 let observe_index = body
1175 .find(&observe_call)
1176 .expect("observer helper should observe later appearance changes");
1177 assert!(sync_index < observe_index);
1178 assert!(body.contains(".detach();"));
1179 }
1180}
1181
1182#[cfg(test)]
1183mod motion_tests {
1184 #[test]
1185 fn tooltip_rendering_uses_gpui_motion() {
1186 let source = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap();
1187
1188 assert!(source.contains("tooltip-motion"));
1189 assert!(source.contains("with_animation("));
1190 }
1191}
1192
1193pub fn liora_theme<'a, V>(cx: &'a Context<'a, V>) -> &'a Theme {
1195 &cx.global::<Config>().theme
1196}
1197
1198pub trait ContextExt {
1200 fn liora(&self) -> &Theme;
1202}
1203
1204impl<'a, V> ContextExt for Context<'a, V> {
1205 fn liora(&self) -> &Theme {
1206 liora_theme(self)
1207 }
1208}
1209
1210pub trait ElementExt {
1212 fn liora(self, cx: &mut App) -> Self;
1214}
1215
1216impl ElementExt for gpui::Div {
1217 fn liora(self, _cx: &mut App) -> Self {
1218 self
1219 }
1220}
1221
1222pub fn z_index_popup<V>(cx: &Context<'_, V>) -> u32 {
1224 cx.global::<Config>().z_index_base + 100
1225}
1226
1227pub fn z_index_modal<V>(cx: &Context<'_, V>) -> u32 {
1229 cx.global::<Config>().z_index_base + 200
1230}
1231
1232pub fn z_index_notification<V>(cx: &Context<'_, V>) -> u32 {
1234 cx.global::<Config>().z_index_base + 300
1235}
1236
1237pub fn z_index_tooltip<V>(cx: &Context<'_, V>) -> u32 {
1239 cx.global::<Config>().z_index_base + 400
1240}
1241
1242pub fn hex_color(hex: u32) -> Hsla {
1244 gpui::rgb(hex).into()
1245}
1246
1247#[cfg(test)]
1248mod unique_id_tests {
1249 use super::*;
1250
1251 #[test]
1252 fn generated_ids_are_prefixed_and_unique() {
1253 let first = unique_id("component");
1254 let second = unique_id("component");
1255
1256 assert!(first.as_ref().starts_with("component-"));
1257 assert!(second.as_ref().starts_with("component-"));
1258 assert_ne!(first, second);
1259 }
1260}
1261
1262#[cfg(test)]
1263mod desktop_identity_tests {
1264 use super::*;
1265
1266 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1267 #[test]
1268 fn linux_desktop_identity_installs_desktop_entry_and_hicolor_icons() {
1269 let temp_root = std::env::temp_dir().join(format!(
1270 "liora-desktop-identity-test-{}",
1271 std::process::id()
1272 ));
1273 let _ = std::fs::remove_dir_all(&temp_root);
1274 std::fs::create_dir_all(&temp_root).expect("test temp root should be creatable");
1275
1276 ensure_linux_desktop_identity_impl(
1277 temp_root.clone(),
1278 LinuxDesktopIdentity {
1279 app_id: "liora-test",
1280 desktop_entry: Cow::Borrowed(
1281 "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n",
1282 ),
1283 png_icons: &[LinuxDesktopPngIcon {
1284 size: 48,
1285 bytes: b"png",
1286 }],
1287 svg_icon: b"svg",
1288 },
1289 false,
1290 )
1291 .expect("identity registration should write into the supplied XDG data home");
1292
1293 assert_eq!(
1294 std::fs::read_to_string(temp_root.join("applications/liora-test.desktop"))
1295 .expect("desktop entry should be installed"),
1296 "[Desktop Entry]\nType=Application\nName=Liora Test\nIcon=liora-test\n"
1297 );
1298 assert_eq!(
1299 std::fs::read(temp_root.join("icons/hicolor/48x48/apps/liora-test.png"))
1300 .expect("PNG hicolor icon should be installed"),
1301 b"png"
1302 );
1303 assert_eq!(
1304 std::fs::read(temp_root.join("icons/hicolor/scalable/apps/liora-test.svg"))
1305 .expect("SVG hicolor icon should be installed"),
1306 b"svg"
1307 );
1308 assert!(
1309 std::fs::read_to_string(temp_root.join("icons/hicolor/index.theme"))
1310 .expect("hicolor index theme should be installed")
1311 .contains("Directories=16x16/apps")
1312 );
1313
1314 let _ = std::fs::remove_dir_all(temp_root);
1315 }
1316}