501 lines
22 KiB
Rust
501 lines
22 KiB
Rust
use galaxyui::fonts::FamilyId;
|
|
use galaxyui::{
|
|
AddSingletonModel, AppContext, AssetProvider, Entity, ModelContext, SingletonEntity,
|
|
};
|
|
use settings::Setting as _;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod macos_app_icon {
|
|
pub use galaxy_core::channel::{Channel, ChannelState};
|
|
pub use objc2::rc::autoreleasepool;
|
|
pub use objc2::{AnyThread, MainThreadMarker};
|
|
pub use objc2_app_kit::{NSApplication, NSImage, NSWorkspace, NSWorkspaceIconCreationOptions};
|
|
pub use objc2_foundation::{ns_string, NSBundle, NSData, NSString};
|
|
|
|
pub use crate::settings::app_icon::{AppIcon, AppIconSettings, AppIconSettingsChangedEvent};
|
|
}
|
|
use anyhow::anyhow;
|
|
pub use galaxy_core::ui::appearance::{Appearance, AppearanceEvent};
|
|
#[cfg(target_os = "macos")]
|
|
use macos_app_icon::*;
|
|
|
|
use crate::settings::{
|
|
active_theme_kind, FontSettings, FontSettingsChangedEvent, MonospaceFontSize, Settings,
|
|
ThemeSettings,
|
|
};
|
|
use crate::themes::theme::{GalaxyTheme, ThemeKind};
|
|
use crate::ASSETS;
|
|
|
|
/// Manages the state of the app-wide Appearance settings, it is responsible
|
|
/// for 1) listening to settings changes and update the underlying Appearance
|
|
/// accordingly 2) hold transient theme states that are used when user is switching
|
|
/// between temporary theme overrides in the theme picker.
|
|
pub struct AppearanceManager {
|
|
// The transient theme is a theme that is set by the user but not saved
|
|
// as a setting. It is used when the user is actively choosing a theme.
|
|
transient_theme: Option<GalaxyTheme>,
|
|
|
|
#[cfg(target_os = "macos")]
|
|
app_icon_at_startup: AppIcon,
|
|
}
|
|
|
|
impl AppearanceManager {
|
|
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
|
ctx.subscribe_to_model(&ThemeSettings::handle(ctx), move |me, _, _event, ctx| {
|
|
me.refresh_theme_state(ctx);
|
|
});
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
ctx.subscribe_to_model(&AppIconSettings::handle(ctx), move |me, _, event, ctx| {
|
|
match event {
|
|
AppIconSettingsChangedEvent::AppIconState { .. } => {
|
|
me.set_app_icon(ctx);
|
|
}
|
|
AppIconSettingsChangedEvent::ShowDockIconState { .. } => {
|
|
me.apply_dock_icon_visibility(ctx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
ctx.subscribe_to_model(
|
|
&FontSettings::handle(ctx),
|
|
move |_, _, event, ctx| match event {
|
|
FontSettingsChangedEvent::MonospaceFontName { .. } => {
|
|
let (font_name, match_fonts) = {
|
|
let settings = FontSettings::as_ref(ctx);
|
|
let font_name = settings.monospace_font_name.value().clone();
|
|
let match_fonts = settings.match_ai_font_to_terminal_font.value();
|
|
(font_name, *match_fonts)
|
|
};
|
|
|
|
if let Some(new_family) = get_or_load_font_family(&font_name, ctx) {
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_monospace_font_family(new_family, ctx);
|
|
if match_fonts {
|
|
appearance.set_ai_font_family(new_family, ctx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
FontSettingsChangedEvent::MonospaceFontSize { .. } => {
|
|
let new_font_size = *FontSettings::as_ref(ctx).monospace_font_size.value();
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_monospace_font_size(new_font_size, ctx)
|
|
});
|
|
}
|
|
|
|
FontSettingsChangedEvent::MonospaceFontWeight { .. } => {
|
|
let new_font_weight = *FontSettings::as_ref(ctx).monospace_font_weight.value();
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_monospace_font_weight(new_font_weight, ctx)
|
|
});
|
|
}
|
|
FontSettingsChangedEvent::LineHeightRatio { .. } => {
|
|
let new_line_height_ratio =
|
|
*FontSettings::as_ref(ctx).line_height_ratio.value();
|
|
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_line_height_ratio(new_line_height_ratio, ctx);
|
|
});
|
|
}
|
|
FontSettingsChangedEvent::AIFontName { .. } => {
|
|
let font_name = FontSettings::as_ref(ctx).ai_font_name.value().clone();
|
|
if let Some(new_family) = get_or_load_font_family(&font_name, ctx) {
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_ai_font_family(new_family, ctx)
|
|
});
|
|
}
|
|
}
|
|
FontSettingsChangedEvent::UIFontName { .. } => {
|
|
let ui_font_name = FontSettings::as_ref(ctx).ui_font_name.value().clone();
|
|
let ui_font_family = load_default_ui_font_family(ctx)
|
|
.expect("unable to load default ui font family");
|
|
let ui_font_family_from_settings = if ui_font_name.is_empty() {
|
|
None
|
|
} else {
|
|
get_or_load_font_family(&ui_font_name, ctx)
|
|
};
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_ui_font_family(
|
|
ui_font_family_from_settings.unwrap_or(ui_font_family),
|
|
ctx,
|
|
)
|
|
});
|
|
}
|
|
FontSettingsChangedEvent::MatchAIFontToTerminalFont { .. } => {
|
|
let settings = FontSettings::as_ref(ctx);
|
|
let match_ai_font_to_terminal_font =
|
|
*settings.match_ai_font_to_terminal_font.value();
|
|
if match_ai_font_to_terminal_font {
|
|
let font_name = settings.monospace_font_name.value().clone();
|
|
|
|
if let Some(new_family) = get_or_load_font_family(&font_name, ctx) {
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_ai_font_family(new_family, ctx)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
);
|
|
|
|
Self {
|
|
transient_theme: None,
|
|
#[cfg(target_os = "macos")]
|
|
app_icon_at_startup: *AppIconSettings::handle(ctx).as_ref(ctx).app_icon.value(),
|
|
}
|
|
}
|
|
|
|
pub fn refresh_theme_state(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let new_theme = if let Some(transient_theme) = self.transient_theme.as_ref() {
|
|
transient_theme.clone()
|
|
} else {
|
|
let theme_kind = active_theme_kind(ThemeSettings::as_ref(ctx), ctx);
|
|
Settings::theme_for_theme_kind(&theme_kind, ctx)
|
|
};
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
emit_theme_background_event(&new_theme);
|
|
|
|
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
|
|
appearance.set_theme(new_theme, ctx);
|
|
})
|
|
}
|
|
|
|
pub fn set_transient_theme(&mut self, theme: ThemeKind, ctx: &mut ModelContext<Self>) {
|
|
self.transient_theme = Some(Settings::theme_for_theme_kind(&theme, ctx));
|
|
self.refresh_theme_state(ctx);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub fn app_icon_at_startup(&self) -> AppIcon {
|
|
self.app_icon_at_startup
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub fn apply_dock_icon_visibility(&self, app: &AppContext) {
|
|
app.set_dock_icon_visible(*AppIconSettings::as_ref(app).show_dock_icon.value());
|
|
}
|
|
|
|
pub fn clear_transient_theme(&mut self, ctx: &mut ModelContext<Self>) {
|
|
self.transient_theme = None;
|
|
self.refresh_theme_state(ctx);
|
|
}
|
|
|
|
/// Updates the state of the app icon, i.e. "dock tile", in-memory. Note that this is in
|
|
/// addition to the update that happens in the docktile plugin.
|
|
///
|
|
/// You can read Apple's limited documentation of the dock tile plugin API here:
|
|
/// https://developer.apple.com/documentation/appkit/nsdocktileplugin?language=objc
|
|
///
|
|
/// Also see the README.md file in app/DockTilePlugin for more information on how best to test
|
|
/// changes to the dock tile plugin.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn set_app_icon(&self, app: &AppContext) {
|
|
let icon = *AppIconSettings::as_ref(app).app_icon.value();
|
|
|
|
// This function is invoked from multiple call sites, including app
|
|
// startup (before the AppKit event loop drains its ambient pool) and
|
|
// settings/autoupdate callbacks whose thread of origin varies. Wrap
|
|
// the body in a local pool so the autoreleased NSStrings (and any
|
|
// other temporaries Cocoa hands back) are released when this returns.
|
|
// `autoreleasepool` drains when the closure returns, covering every
|
|
// exit path (including early returns and panics).
|
|
autoreleasepool(|_| {
|
|
// SAFETY: `set_app_icon` only runs on the main thread, since it
|
|
// requires a `&AppContext`, which is only accessible there.
|
|
let mtm = unsafe { MainThreadMarker::new_unchecked() };
|
|
let ns_app = NSApplication::sharedApplication(mtm);
|
|
let bundle = NSBundle::mainBundle();
|
|
let bundle_path = bundle.bundlePath();
|
|
let is_bundled_app = bundle.bundleIdentifier().is_some();
|
|
let workspace = NSWorkspace::sharedWorkspace();
|
|
|
|
// If the user has selected the default icon, reset to the icon that is statically
|
|
// bundled in the app bundle. The bundled icon gets automatically "filtered" according
|
|
// to the user's "Icon & Widget style" setting in the MacOS appearance settings (added
|
|
// in MacOS Tahoe). We implement custom icons by overriding this at runtime. Those
|
|
// icons do not adapt to the preferred style.
|
|
//
|
|
// Local channel is not bundled, so don't attempt this for that case. This method only
|
|
// works if the dock tile plugin hasn't overridden the default icon already, so skip
|
|
// this method if the app started up with a non-default icon, as setting to "nil" would
|
|
// revert to the icon we started up with. We therefore need to use an in-memory
|
|
// override to display the default icon. This has the drawback of _not_ inheriting the
|
|
// preferred icon style, but that icon style _will_ apply on next app restart.
|
|
if icon == AppIcon::Galaxy
|
|
&& is_bundled_app
|
|
&& ChannelState::channel() != Channel::Local
|
|
&& self.app_icon_at_startup == AppIcon::Galaxy
|
|
{
|
|
log::debug!("User has default icon selected, resetting to bundle default");
|
|
// Reset to nil to use the bundle's default icon.
|
|
// SAFETY: `setApplicationIconImage:` accepts `nil` to restore the bundled icon.
|
|
unsafe { ns_app.setApplicationIconImage(None) };
|
|
workspace.setIcon_forFile_options(
|
|
None,
|
|
&bundle_path,
|
|
NSWorkspaceIconCreationOptions::empty(),
|
|
);
|
|
workspace.noteFileSystemChanged_(&bundle_path);
|
|
return;
|
|
}
|
|
|
|
let icon_name = AppIconSettings::get_base_icon_file_name(icon);
|
|
|
|
log::debug!("Setting app icon in memory to: {icon_name}");
|
|
let image = if let Some(image) = load_app_icon_from_plugin_bundle(&bundle, icon_name) {
|
|
image
|
|
} else {
|
|
let asset_path = format!("bundled/png/{icon_name}.png");
|
|
let Ok(image_bytes) = ASSETS.get(&asset_path) else {
|
|
log::warn!("Failed to get bundled app icon asset: {asset_path}");
|
|
return;
|
|
};
|
|
let data = NSData::with_bytes(image_bytes.as_ref());
|
|
let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else {
|
|
log::warn!("Failed to create image from bundled app icon asset: {asset_path}");
|
|
return;
|
|
};
|
|
image
|
|
};
|
|
|
|
// Override the bundled icon with this new image.
|
|
// SAFETY: `setApplicationIconImage:` accepts a non-nil image.
|
|
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
|
|
workspace.setIcon_forFile_options(
|
|
Some(&image),
|
|
&bundle_path,
|
|
NSWorkspaceIconCreationOptions::empty(),
|
|
);
|
|
workspace.noteFileSystemChanged_(&bundle_path);
|
|
|
|
// `image` is a `Retained<NSImage>` that releases the `+1` retain from
|
|
// `[NSImage alloc]` when it drops at the end of this scope.
|
|
// `setApplicationIconImage:` and `setIcon:forFile:options:` both retain the
|
|
// image, so it remains alive as the active app/dock icon.
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn load_app_icon_from_plugin_bundle(
|
|
bundle: &NSBundle,
|
|
icon_name: &str,
|
|
) -> Option<objc2::rc::Retained<NSImage>> {
|
|
let plugins_path = bundle.builtInPlugInsPath()?;
|
|
for plugin_bundle_name in [
|
|
"GalaxyDockTilePlugin.docktileplugin",
|
|
"WarpDockTilePlugin.docktileplugin",
|
|
] {
|
|
let plugin_name = NSString::from_str(plugin_bundle_name);
|
|
let plugin_path = plugins_path.stringByAppendingPathComponent(&plugin_name);
|
|
let Some(plugin_bundle) = NSBundle::bundleWithPath(&plugin_path) else {
|
|
continue;
|
|
};
|
|
|
|
let image_name = NSString::from_str(icon_name);
|
|
let extension = ns_string!("png");
|
|
let Some(image_path) =
|
|
plugin_bundle.pathForResource_ofType(Some(&image_name), Some(extension))
|
|
else {
|
|
log::warn!("Failed to get image path for icon {icon_name} from {plugin_bundle_name}");
|
|
continue;
|
|
};
|
|
|
|
let Some(image) = NSImage::initWithContentsOfFile(NSImage::alloc(), &image_path) else {
|
|
log::warn!("Failed to create image for icon {icon_name} from {plugin_bundle_name}");
|
|
continue;
|
|
};
|
|
|
|
return Some(image);
|
|
}
|
|
None
|
|
}
|
|
|
|
impl Entity for AppearanceManager {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for AppearanceManager {}
|
|
|
|
fn load_default_monospace_font_family(ctx: &mut AppContext) -> anyhow::Result<FamilyId> {
|
|
galaxyui::fonts::Cache::handle(ctx).update(ctx, |font_cache, _| {
|
|
let default_monospace_font_family = font_cache.load_family_from_bytes(
|
|
"Hack",
|
|
vec![
|
|
ASSETS.get("bundled/fonts/hack/Hack-Italic.ttf")?.to_vec(),
|
|
ASSETS.get("bundled/fonts/hack/Hack-Bold.ttf")?.to_vec(),
|
|
ASSETS.get("bundled/fonts/hack/Hack-Regular.ttf")?.to_vec(),
|
|
ASSETS
|
|
.get("bundled/fonts/hack/Hack-BoldItalic.ttf")?
|
|
.to_vec(),
|
|
],
|
|
)?;
|
|
let default_monospace_font =
|
|
font_cache.select_font(default_monospace_font_family, Default::default());
|
|
font_cache.glyph_typographic_bounds(
|
|
default_monospace_font,
|
|
MonospaceFontSize::default_value(),
|
|
font_cache
|
|
.glyph_for_char(default_monospace_font, 'm', false)
|
|
.ok_or_else(|| anyhow!("monospace font has no 'm' glyph"))?
|
|
.0,
|
|
)?;
|
|
Ok(default_monospace_font_family)
|
|
})
|
|
}
|
|
|
|
fn load_default_ui_font_family(ctx: &mut AppContext) -> anyhow::Result<FamilyId> {
|
|
galaxyui::fonts::Cache::handle(ctx).update(ctx, |font_cache, _| {
|
|
let roboto = font_cache.load_family_from_bytes(
|
|
"Roboto",
|
|
vec![
|
|
ASSETS
|
|
.get("bundled/fonts/roboto/Roboto-Italic.ttf")?
|
|
.to_vec(),
|
|
ASSETS.get("bundled/fonts/roboto/Roboto-Bold.ttf")?.to_vec(),
|
|
ASSETS
|
|
.get("bundled/fonts/roboto/Roboto-Regular.ttf")?
|
|
.to_vec(),
|
|
ASSETS
|
|
.get("bundled/fonts/roboto/Roboto-Medium.ttf")?
|
|
.to_vec(),
|
|
ASSETS
|
|
.get("bundled/fonts/roboto/RobotoFlex-Semibold.ttf")?
|
|
.to_vec(),
|
|
ASSETS
|
|
.get("bundled/fonts/roboto/Roboto-BoldItalic.ttf")?
|
|
.to_vec(),
|
|
],
|
|
);
|
|
|
|
// On Windows, default to use Segoe UI as the UI font. This font is recommended by
|
|
// Windows when rendering any UI text: https://learn.microsoft.com/en-us/windows/win32/uxguide/vis-fonts.
|
|
// This font should be bundled with any modern version of Windows, if we can't load it for
|
|
// any reason we fallback to using our normal bundled font.
|
|
#[cfg(windows)]
|
|
if let Ok(font_family_id) = font_cache.load_system_font("Segoe UI") {
|
|
return Ok(font_family_id);
|
|
}
|
|
|
|
roboto
|
|
})
|
|
}
|
|
|
|
fn load_password_font_family(ctx: &mut AppContext) -> anyhow::Result<FamilyId> {
|
|
galaxyui::fonts::Cache::handle(ctx).update(ctx, |font_cache, _| {
|
|
font_cache.load_family_from_bytes(
|
|
"PasswordCircle",
|
|
vec![ASSETS.get("bundled/fonts/password.ttf")?.to_vec()],
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
/// On wasm we don't support loading fonts, so we just use the default.
|
|
fn get_or_load_font_family(_font_name: &str, _ctx: &mut AppContext) -> Option<FamilyId> {
|
|
None
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
/// If we're running on a native platform (where we support font loading),
|
|
/// make sure we load the user's selected monospace font. We first check
|
|
/// the font cache in case we are using a pre-bundled font like Hack.
|
|
/// Then we fall back to loading a system font.
|
|
fn get_or_load_font_family(font_name: &str, ctx: &mut AppContext) -> Option<FamilyId> {
|
|
galaxyui::fonts::Cache::handle(ctx).update(ctx, |font_cache, _| {
|
|
match font_cache.get_or_load_system_font(font_name) {
|
|
Ok(family) => {
|
|
let font_id =
|
|
font_cache.select_font(family, galaxyui::fonts::Properties::default());
|
|
|
|
// Validate that the font contains the `m` glyph since this is assumed in
|
|
// various parts of the code. We already do this when surfacing fonts in the font
|
|
// selector dropdown, but a user could have edited their settings manually (or
|
|
// there could have been a bug in the font selection logic) and we don't want to
|
|
// crash in that case.
|
|
let glyph_id = font_cache
|
|
.glyph_for_char(font_id, 'm', false /* include_fallback_fonts */);
|
|
if glyph_id.is_none() {
|
|
log::warn!(
|
|
"Failed to load font: {font_name} because it didn't contain the character m"
|
|
);
|
|
return None;
|
|
}
|
|
Some(family)
|
|
}
|
|
Err(err) => {
|
|
log::warn!("Failed to load font: {font_name} due to error {err:?}");
|
|
// Just return the default if we can't load the font so we don't crash.
|
|
None
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn build_appearance(ctx: &mut AppContext) -> Appearance {
|
|
let default_monospace_font_family = load_default_monospace_font_family(ctx)
|
|
.expect("unable to load default monospace font family");
|
|
let monospace_font_name = FontSettings::as_ref(ctx)
|
|
.monospace_font_name
|
|
.value()
|
|
.clone();
|
|
let am_font_name = FontSettings::as_ref(ctx).ai_font_name.value().clone();
|
|
|
|
let monospace_font_family_from_settings = get_or_load_font_family(&monospace_font_name, ctx);
|
|
|
|
let ui_font_family =
|
|
load_default_ui_font_family(ctx).expect("unable to load default ui font family");
|
|
let ui_font_name = FontSettings::as_ref(ctx).ui_font_name.value().clone();
|
|
let ui_font_family_from_settings = if ui_font_name.is_empty() {
|
|
None
|
|
} else {
|
|
get_or_load_font_family(&ui_font_name, ctx)
|
|
};
|
|
|
|
let am_font_family_from_settings = get_or_load_font_family(&am_font_name, ctx);
|
|
|
|
let password_font_family =
|
|
load_password_font_family(ctx).expect("unable to load password font family");
|
|
|
|
let monospace_font_size = *FontSettings::as_ref(ctx).monospace_font_size.value();
|
|
|
|
let monospace_font_weight = *FontSettings::as_ref(ctx).monospace_font_weight.value();
|
|
|
|
let line_height_ratio = *FontSettings::as_ref(ctx).line_height_ratio.value();
|
|
|
|
let theme_kind = active_theme_kind(ThemeSettings::as_ref(ctx), ctx);
|
|
let theme = Settings::theme_for_theme_kind(&theme_kind, ctx);
|
|
#[cfg(target_family = "wasm")]
|
|
emit_theme_background_event(&theme);
|
|
|
|
Appearance::new(
|
|
theme,
|
|
monospace_font_family_from_settings.unwrap_or(default_monospace_font_family),
|
|
monospace_font_size,
|
|
monospace_font_weight,
|
|
ui_font_family_from_settings.unwrap_or(ui_font_family),
|
|
line_height_ratio,
|
|
am_font_family_from_settings.unwrap_or(default_monospace_font_family),
|
|
password_font_family,
|
|
)
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
fn emit_theme_background_event(theme: &GalaxyTheme) {
|
|
let bg = theme.background().into_solid();
|
|
let color = format!("#{:02x}{:02x}{:02x}", bg.r, bg.g, bg.b);
|
|
crate::platform::wasm::emit_event(crate::platform::wasm::GalaxyEvent::ThemeBackgroundChanged {
|
|
color,
|
|
});
|
|
}
|
|
|
|
pub fn register(app: &mut impl AddSingletonModel) {
|
|
app.add_singleton_model(|ctx| build_appearance(ctx));
|
|
app.add_singleton_model(AppearanceManager::new);
|
|
}
|