Ghostty's stylesheets (style.css, style-dark.css, style-hc*.css) were never loaded. AdwApplication loads them from `resource-base-path` once, during startup, and only if the resources exist at that moment. Zig's gresource is registered by a glib-compile-resources C constructor before main; the Rust build registered it lazily on first template instantiation (windows, dialogs), i.e. after startup. Visible effect: every rule in style.css was missing, e.g. the clipboard confirmation dialog (`clipboard-read = ask`) rendered its preview without the rounded inset and overlapping the "Remember choice" row. Registering in Application::new restores parity; the Adwaita "style-dark.css is deprecated" warnings Zig logs now appear too, which confirms the files are picked up. Test Plan: - clipboard-read = ask, run `printf '\e]52;c;?\a'`: dialog is pixel-identical to Ghostty 1.3.1 (nested weston capture)
1988 lines
71 KiB
Rust
1988 lines
71 KiB
Rust
//! `GhosttyApplication` (ZIG/src/apprt/gtk/class/application.zig): the
|
|
//! primary entrypoint of the GTK runtime. Wraps an `AdwApplication`, owns the
|
|
//! config, windowing-protocol state, CSS providers and global shortcuts, and
|
|
//! implements the apprt action switch ([`Application::perform_action`]).
|
|
//!
|
|
//! Public API used by the other classes:
|
|
//! * [`Application::default`] -> the running application
|
|
//! * [`Application::core`] / [`Application::rt`] -> `Rc<dyn CoreApp>` / `Rc<dyn RtApp>`
|
|
//! * [`Application::get_config`] -> `class::config::Config` object (property `config`)
|
|
//! * [`Application::core_config`] -> `Arc<ghostty_config::config::Config>`
|
|
//! * [`Application::winproto`] / [`Application::winproto_mut`]
|
|
//! * [`Application::saved_language`], [`Application::wakeup`], [`Application::quit`]
|
|
|
|
#![allow(
|
|
clippy::needless_pass_by_value,
|
|
clippy::unused_self,
|
|
clippy::type_complexity,
|
|
clippy::should_implement_trait
|
|
)]
|
|
|
|
use std::{
|
|
cell::{Cell, OnceCell, Ref, RefCell, RefMut},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
};
|
|
|
|
use adw::{prelude::*, subclass::prelude::*};
|
|
use ghostty_apprt::{
|
|
action::{self, Action, Target},
|
|
runtime_api::{AppMessage, CoreApp, RtApp, SurfaceId},
|
|
structs::ColorScheme,
|
|
};
|
|
use ghostty_config::{
|
|
cli_args::ConfigField,
|
|
command::Command,
|
|
config::Config as CoreConfig,
|
|
types_b::{GtkSingleInstance, WindowTheme},
|
|
};
|
|
use ghostty_input::binding::Action as BindingAction;
|
|
use gtk::{gdk, gio, glib};
|
|
|
|
use crate::{
|
|
class::{
|
|
close_confirmation_dialog::{self, CloseConfirmationDialog},
|
|
config::Config,
|
|
config_errors_dialog::ConfigErrorsDialog,
|
|
global_shortcuts::GlobalShortcuts,
|
|
split_tree::SplitTree,
|
|
surface::{Size, Surface},
|
|
tab::Tab,
|
|
window::{SelectTab, TabOverrides, Window},
|
|
},
|
|
css::CssProviders,
|
|
winproto,
|
|
};
|
|
|
|
/// Errors from [`Application::run_loop`].
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RunError {
|
|
#[error("unable to acquire the default main context")]
|
|
ContextAcquireFailed,
|
|
#[error("unable to register the application")]
|
|
ApplicationRegisterFailed,
|
|
#[error("core app tick failed: {0}")]
|
|
Tick(String),
|
|
}
|
|
|
|
/// State of the quit-after-last-window-closed timer.
|
|
#[derive(Debug, Default)]
|
|
pub enum QuitTimer {
|
|
#[default]
|
|
Off,
|
|
Active(glib::SourceId),
|
|
Expired,
|
|
}
|
|
|
|
/// Overrides for a new window (Zig: `newWindow` overrides struct).
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct WindowOverrides {
|
|
pub command: Option<Command>,
|
|
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
|
|
pub title: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Process level helpers
|
|
|
|
/// Set an environment variable.
|
|
#[allow(unsafe_code)]
|
|
fn set_env(key: &str, value: impl AsRef<std::ffi::OsStr>) {
|
|
// SAFETY: only called during application construction on the main
|
|
// thread, before GTK is initialized and before we spawn any child
|
|
// processes; no other thread in this crate reads the environment.
|
|
unsafe { std::env::set_var(key, value.as_ref()) }
|
|
}
|
|
|
|
/// Funnel GLib/GObject/GTK log messages into the `log` crate rather than
|
|
/// having them dumped directly to stderr.
|
|
fn glib_log_writer(level: glib::LogLevel, fields: &[glib::LogField<'_>]) -> glib::LogWriterOutput {
|
|
let mut message = None;
|
|
let mut domain = None;
|
|
for f in fields {
|
|
match f.key() {
|
|
"MESSAGE" => message = f.value_str(),
|
|
"GLIB_DOMAIN" => domain = f.value_str(),
|
|
_ => {}
|
|
}
|
|
}
|
|
let Some(message) = message else {
|
|
return glib::LogWriterOutput::Unhandled;
|
|
};
|
|
let domain = domain.unwrap_or("«unknown»");
|
|
match level {
|
|
glib::LogLevel::Error => log::error!(target: "glib", "ERROR: {domain}: {message}"),
|
|
glib::LogLevel::Critical => log::error!(target: "glib", "CRITICAL: {domain}: {message}"),
|
|
glib::LogLevel::Warning => log::warn!(target: "glib", "WARNING: {domain}: {message}"),
|
|
glib::LogLevel::Message => log::info!(target: "glib", "MESSAGE: {domain}: {message}"),
|
|
glib::LogLevel::Info => log::info!(target: "glib", "INFO: {domain}: {message}"),
|
|
glib::LogLevel::Debug => log::debug!(target: "glib", "DEBUG: {domain}: {message}"),
|
|
}
|
|
glib::LogWriterOutput::Handled
|
|
}
|
|
|
|
fn runtime_at_least(major: u32, minor: u32, micro: u32) -> bool {
|
|
(
|
|
gtk::major_version(),
|
|
gtk::minor_version(),
|
|
gtk::micro_version(),
|
|
) >= (major, minor, micro)
|
|
}
|
|
|
|
/// Sets various GTK-related environment variables as necessary given the
|
|
/// runtime environment or configuration. Must be called BEFORE GTK
|
|
/// initialization.
|
|
fn set_gtk_env(config: &CoreConfig) {
|
|
// gdk_debug flags
|
|
let mut opengl = config.gtk_opengl_debug;
|
|
let mut gl_disable_gles = false;
|
|
let mut gl_no_fractional = false;
|
|
let mut vulkan_disable = false;
|
|
|
|
// gdk_disable flags
|
|
let mut gles_api = false;
|
|
// current gtk implementation for color management is not good enough.
|
|
// see: https://bugs.kde.org/show_bug.cgi?id=495647
|
|
let mut color_mgmt = true;
|
|
let mut vulkan = false;
|
|
|
|
'environment: {
|
|
if runtime_at_least(4, 18, 0) {
|
|
color_mgmt = false;
|
|
}
|
|
|
|
if runtime_at_least(4, 16, 0) {
|
|
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
|
|
gles_api = true;
|
|
vulkan = true;
|
|
break 'environment;
|
|
}
|
|
if runtime_at_least(4, 14, 0) {
|
|
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
|
|
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
|
|
gl_disable_gles = true;
|
|
vulkan_disable = true;
|
|
if !runtime_at_least(4, 17, 5) {
|
|
// Removed at GTK v4.17.5
|
|
gl_no_fractional = true;
|
|
}
|
|
break 'environment;
|
|
}
|
|
// Versions prior to 4.14 are a bit of an unknown for Ghostty.
|
|
vulkan_disable = true;
|
|
}
|
|
// `gtk-opengl-debug` dumps logs directly to stderr.
|
|
opengl = opengl && std::io::IsTerminal::is_terminal(&std::io::stderr());
|
|
|
|
let join = |flags: &[(&str, bool)]| {
|
|
flags
|
|
.iter()
|
|
.filter(|(_, on)| *on)
|
|
.map(|(name, _)| *name)
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
};
|
|
|
|
let gdk_debug = join(&[
|
|
("opengl", opengl),
|
|
("gl-disable-gles", gl_disable_gles),
|
|
("gl-no-fractional", gl_no_fractional),
|
|
("vulkan-disable", vulkan_disable),
|
|
]);
|
|
log::warn!("setting GDK_DEBUG={gdk_debug}");
|
|
set_env("GDK_DEBUG", &gdk_debug);
|
|
|
|
let gdk_disable = join(&[
|
|
("gles-api", gles_api),
|
|
("color-mgmt", color_mgmt),
|
|
("vulkan", vulkan),
|
|
]);
|
|
log::warn!("setting GDK_DISABLE={gdk_disable}");
|
|
set_env("GDK_DISABLE", &gdk_disable);
|
|
}
|
|
|
|
/// Port of Ghostty's `os/locale.zig` `ensureLocale` (called from
|
|
/// `global.zig` at startup): set the process locale from the environment and,
|
|
/// if that fails (usually an invalid `LANG`), retry with `LANG` unset, then fall
|
|
/// back to `en_US.UTF-8` (updating `LANG` on success). Idempotent. This must
|
|
/// run before GTK initializes and before the child environment is captured.
|
|
/// Apply the `async-backend` config (Zig: `startupXev`): `auto` keeps the
|
|
/// backend detected at startup, the others request that backend and fall
|
|
/// back to the detected one when it is not available.
|
|
pub fn apply_async_backend(backend: ghostty_config::types_b::AsyncBackend) -> bool {
|
|
use ghostty_config::types_b::AsyncBackend;
|
|
use ghostty_os::xev::{self, Backend};
|
|
|
|
// If our backend is auto then we have no setup to do.
|
|
let want = match backend {
|
|
AsyncBackend::Auto => return true,
|
|
AsyncBackend::Epoll => Backend::Epoll,
|
|
AsyncBackend::IoUring => Backend::IoUring,
|
|
};
|
|
|
|
// Setup our event loop backend to the preferred method
|
|
let result = xev::is_dynamic() && xev::prefer(want);
|
|
if result {
|
|
log::info!("libxev manual backend={}", xev::backend());
|
|
} else {
|
|
log::warn!(
|
|
"libxev manual backend failed, using default={}",
|
|
xev::backend()
|
|
);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub fn init_locale() {
|
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
|
ONCE.call_once(|| {
|
|
// Shared implementation (also used by core's global init).
|
|
if let Some(locale) = ghostty_os::ensure_locale() {
|
|
log::debug!("locale set locale={locale}");
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// RtApp
|
|
|
|
/// The `RtApp` handed to core. Stateless; forwards to the default
|
|
/// application. Core must only call it from the main thread.
|
|
#[derive(Debug, Default)]
|
|
pub struct AppRt;
|
|
|
|
impl RtApp for AppRt {
|
|
fn perform_action(&self, target: Target<SurfaceId>, action: Action) -> bool {
|
|
Application::default().perform_action(target, action)
|
|
}
|
|
}
|
|
|
|
mod imp {
|
|
use adw::{prelude::*, subclass::prelude::*};
|
|
use gtk::gio;
|
|
|
|
use super::{
|
|
AppMessage,
|
|
Cell,
|
|
Config,
|
|
ConfigErrorsDialog,
|
|
CoreApp,
|
|
CssProviders,
|
|
GlobalShortcuts,
|
|
OnceCell,
|
|
QuitTimer,
|
|
Rc,
|
|
RefCell,
|
|
RtApp,
|
|
glib,
|
|
winproto,
|
|
};
|
|
|
|
#[derive(Default)]
|
|
pub struct Application {
|
|
/// The libghostty App instance.
|
|
pub core_app: OnceCell<Rc<dyn CoreApp>>,
|
|
pub rt_app: OnceCell<Rc<dyn RtApp>>,
|
|
|
|
/// The configuration for the application.
|
|
pub config: RefCell<Option<Config>>,
|
|
|
|
/// State and logic for the underlying windowing protocol.
|
|
pub winproto: RefCell<Option<winproto::App>>,
|
|
|
|
/// The global shortcut logic.
|
|
pub global_shortcuts: OnceCell<GlobalShortcuts>,
|
|
|
|
/// True once the shortcuts' `config` property is bound to ours
|
|
/// (Zig: `bindProperty` in `startupGlobalShortcuts`, after the
|
|
/// D-Bus connection was set; before that the config must not reach
|
|
/// the shortcuts, or the portal session is created twice).
|
|
pub global_shortcuts_bound: Cell<bool>,
|
|
|
|
/// Set to true so long as we request a window exactly once. This
|
|
/// prevents quitting the app before we've shown one window.
|
|
pub requested_window: Cell<bool>,
|
|
|
|
/// Set to false internally when the event loop should exit.
|
|
pub running: Cell<bool>,
|
|
|
|
pub quit_timer: RefCell<QuitTimer>,
|
|
|
|
/// The config errors dialog (weak: it can close on its own).
|
|
pub config_errors_dialog: glib::WeakRef<ConfigErrorsDialog>,
|
|
|
|
pub signal_thread_started: Cell<bool>,
|
|
|
|
/// CSS providers (runtime + custom).
|
|
pub css: RefCell<Option<CssProviders>>,
|
|
|
|
/// Copy of the LANG variable provided by the system, if any.
|
|
pub saved_language: RefCell<Option<std::ffi::OsString>>,
|
|
}
|
|
|
|
#[glib::object_subclass]
|
|
impl ObjectSubclass for Application {
|
|
const NAME: &'static str = "GhosttyApplication";
|
|
type Type = super::Application;
|
|
type ParentType = adw::Application;
|
|
}
|
|
|
|
impl ObjectImpl for Application {
|
|
fn properties() -> &'static [glib::ParamSpec] {
|
|
static PROPS: std::sync::OnceLock<Vec<glib::ParamSpec>> = std::sync::OnceLock::new();
|
|
PROPS.get_or_init(|| {
|
|
vec![
|
|
glib::ParamSpecObject::builder::<Config>("config")
|
|
.read_only()
|
|
.build(),
|
|
]
|
|
})
|
|
}
|
|
|
|
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
|
|
match pspec.name() {
|
|
"config" => self.config.borrow().to_value(),
|
|
name => unimplemented!("unknown property {name}"),
|
|
}
|
|
}
|
|
|
|
fn dispose(&self) {
|
|
if let Some(diag) = self.config_errors_dialog.upgrade() {
|
|
diag.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApplicationImpl for Application {
|
|
fn startup(&self) {
|
|
log::debug!("startup");
|
|
self.parent_startup();
|
|
let obj = self.obj();
|
|
|
|
// Set ourselves as the default application.
|
|
gio::Application::set_default(obj.upcast_ref::<gio::Application>());
|
|
|
|
// Setup our event loop
|
|
obj.startup_xev();
|
|
|
|
// Setup our style manager (light/dark mode)
|
|
obj.startup_style_manager();
|
|
// Setup some signal handlers
|
|
obj.startup_signals();
|
|
// Setup our action map
|
|
obj.startup_action_map();
|
|
// Setup our global shortcuts
|
|
obj.startup_global_shortcuts();
|
|
|
|
// If we have any config diagnostics from loading, then we show
|
|
// the diagnostics dialog. We show this one as a general modal
|
|
// (not to any specific window) because we don't even know if the
|
|
// window will load.
|
|
obj.show_config_errors_dialog();
|
|
}
|
|
|
|
fn activate(&self) {
|
|
log::debug!("activate");
|
|
// Queue a new window
|
|
if let Some(core) = self.core_app.get() {
|
|
core.push_message(AppMessage::NewWindow);
|
|
}
|
|
self.parent_activate();
|
|
}
|
|
}
|
|
|
|
impl GtkApplicationImpl for Application {}
|
|
impl AdwApplicationImpl for Application {}
|
|
}
|
|
|
|
glib::wrapper! {
|
|
pub struct Application(ObjectSubclass<imp::Application>)
|
|
@extends adw::Application, gtk::Application, gio::Application,
|
|
@implements gio::ActionGroup, gio::ActionMap;
|
|
}
|
|
|
|
impl Application {
|
|
/// Get the default (running) application.
|
|
///
|
|
/// Panics if there is no default application or it is not a
|
|
/// GhosttyApplication.
|
|
pub fn default() -> Self {
|
|
gio::Application::default()
|
|
.and_then(|a| a.downcast::<Application>().ok())
|
|
.expect("no default GhosttyApplication")
|
|
}
|
|
|
|
/// Creates a new Application instance. This does a lot more work than a
|
|
/// typical class instantiation, because we expect that this is the main
|
|
/// program entrypoint. Every error is mapped to some degraded state
|
|
/// where we can at least show a window with an error.
|
|
pub fn new(core_app: Rc<dyn CoreApp>) -> Self {
|
|
init_locale();
|
|
|
|
// AdwApplication loads style.css & co. from `resource-base-path` once,
|
|
// during startup, and only if the resources exist then. Zig's
|
|
// gresource is registered by a C constructor before main; ours must
|
|
// be registered explicitly before startup.
|
|
crate::register_resources();
|
|
|
|
// Capture GLib/GObject/GTK log messages.
|
|
glib::log_set_writer_func(glib_log_writer);
|
|
|
|
// Load our configuration.
|
|
let mut config = match CoreConfig::load() {
|
|
Ok(c) => c,
|
|
Err(err) => {
|
|
// If we fail to load the configuration, then we should log
|
|
// the error in the diagnostics so it can be shown to the user.
|
|
let mut def = CoreConfig::new_default();
|
|
def.add_diagnostic(format!("error loading user configuration: {err}"));
|
|
def
|
|
}
|
|
};
|
|
|
|
// Keep the inherited bytes: LANG need not be UTF-8.
|
|
let saved_language = std::env::var_os("LANG");
|
|
if let Some(language) = &config.language {
|
|
set_env("LANG", language.to_os());
|
|
}
|
|
|
|
// Set gettext global domain to be our app so that our unqualified
|
|
// translations map to our translations.
|
|
if let Err(err) = ghostty_os::i18n::init_global_domain() {
|
|
// Failures shouldn't stop application startup.
|
|
log::warn!("i18n initialization failed error={err:?}");
|
|
}
|
|
|
|
// Setup our GTK init env vars
|
|
set_gtk_env(&config);
|
|
if let Err(err) = gtk::init() {
|
|
log::error!("failed to initialize GTK: {err}");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(err) = adw::init() {
|
|
log::error!("failed to initialize libadwaita: {err}");
|
|
}
|
|
|
|
// Log our GTK versions (libadwaita requires GTK to be initialized
|
|
// first in the Rust bindings).
|
|
crate::gtk_version::log_version();
|
|
crate::adw_version::log_version();
|
|
|
|
let single_instance = match config.gtk_single_instance {
|
|
GtkSingleInstance::True => true,
|
|
GtkSingleInstance::False => false,
|
|
// This should have been resolved to true/false during config loading.
|
|
GtkSingleInstance::Detect => {
|
|
log::warn!("gtk-single-instance=detect was not resolved, assuming true");
|
|
true
|
|
}
|
|
};
|
|
|
|
// Setup the flags for our application.
|
|
let mut flags = gio::ApplicationFlags::default();
|
|
if !single_instance {
|
|
flags |= gio::ApplicationFlags::NON_UNIQUE;
|
|
}
|
|
|
|
// Our app ID determines uniqueness and maps to our desktop file.
|
|
let app_id: String = match &config.class {
|
|
Some(class) if gio::Application::id_is_valid(&class.to_text_lossy()) => {
|
|
class.to_text_lossy()
|
|
}
|
|
Some(_) => {
|
|
log::warn!("invalid 'class' in config, ignoring");
|
|
crate::APPLICATION_ID.to_owned()
|
|
}
|
|
None => crate::APPLICATION_ID.to_owned(),
|
|
};
|
|
|
|
let Some(display) = gdk::Display::default() else {
|
|
// I'm unsure of any scenario where this happens.
|
|
log::warn!("gdk display is null, exiting");
|
|
std::process::exit(1);
|
|
};
|
|
|
|
// Setup our windowing protocol logic
|
|
let wp = match winproto::App::init(&display, &app_id, &config) {
|
|
Ok(wp) => wp,
|
|
Err(err) => {
|
|
// If we fail to detect or setup the windowing protocol
|
|
// specifics, we fallback to a noop implementation.
|
|
log::warn!("error initializing windowing protocol err={err}");
|
|
winproto::App::None(winproto::noop::App)
|
|
}
|
|
};
|
|
log::debug!("windowing protocol={:?}", wp.protocol());
|
|
|
|
log::debug!("creating GTK application id={app_id} single-instance={single_instance}");
|
|
|
|
// Wrap our configuration in a GObject.
|
|
let _ = &mut config;
|
|
let config_obj = Config::new(&config);
|
|
|
|
// Internally, GTK ensures that only one instance of this provider
|
|
// exists in the provider list for the display.
|
|
let css = CssProviders::new(&display);
|
|
|
|
let obj: Self = glib::Object::builder()
|
|
.property("application-id", app_id)
|
|
.property("flags", flags)
|
|
// Force the resource path to a known value so it doesn't depend
|
|
// on the app id and force it to load in compiled resources.
|
|
.property("resource-base-path", crate::RESOURCE_PREFIX)
|
|
.build();
|
|
|
|
let imp = obj.imp();
|
|
let _ = imp.core_app.set(core_app);
|
|
let _ = imp.rt_app.set(Rc::new(AppRt));
|
|
*imp.config.borrow_mut() = Some(config_obj);
|
|
*imp.winproto.borrow_mut() = Some(wp);
|
|
*imp.css.borrow_mut() = Some(css);
|
|
let _ = imp.global_shortcuts.set(GlobalShortcuts::new());
|
|
*imp.saved_language.borrow_mut() = saved_language;
|
|
|
|
// Signals
|
|
obj.connect_notify_local(Some("config"), |this, _| this.prop_config());
|
|
|
|
// Trigger initial config changes
|
|
obj.notify("config");
|
|
|
|
obj
|
|
}
|
|
|
|
/// Force deinitialize the application (Zig: `deinit`). Applications are
|
|
/// never fully unreferenced so this ensures our state is released.
|
|
pub fn deinit(&self) {
|
|
let imp = self.imp();
|
|
*imp.css.borrow_mut() = None;
|
|
*imp.winproto.borrow_mut() = None;
|
|
*imp.config.borrow_mut() = None;
|
|
if let Some(gs) = imp.global_shortcuts.get() {
|
|
gs.set_connection(None);
|
|
}
|
|
*imp.saved_language.borrow_mut() = None;
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Accessors
|
|
|
|
/// The core app. Not reference counted in Zig; here a cheap `Rc` clone.
|
|
pub fn core(&self) -> Rc<dyn CoreApp> {
|
|
self.imp().core_app.get().expect("core app set").clone()
|
|
}
|
|
|
|
/// The `RtApp` implementation to hand to core.
|
|
pub fn rt(&self) -> Rc<dyn RtApp> {
|
|
self.imp().rt_app.get().expect("rt app set").clone()
|
|
}
|
|
|
|
/// The configuration object (property `config`). Increments its refcount.
|
|
pub fn get_config(&self) -> Config {
|
|
self.imp()
|
|
.config
|
|
.borrow()
|
|
.clone()
|
|
.expect("application config")
|
|
}
|
|
|
|
/// The configuration, as a shared core config.
|
|
pub fn core_config(&self) -> Arc<CoreConfig> {
|
|
self.get_config().get()
|
|
}
|
|
|
|
/// The windowing protocol state. Panics if called after `deinit`.
|
|
pub fn winproto(&self) -> Ref<'_, winproto::App> {
|
|
Ref::map(self.imp().winproto.borrow(), |v| {
|
|
v.as_ref().expect("winproto available")
|
|
})
|
|
}
|
|
|
|
/// Mutable windowing protocol state (needed by `init_quick_terminal`).
|
|
pub fn winproto_mut(&self) -> RefMut<'_, winproto::App> {
|
|
RefMut::map(self.imp().winproto.borrow_mut(), |v| {
|
|
v.as_mut().expect("winproto available")
|
|
})
|
|
}
|
|
|
|
/// The original `LANG` Ghostty was launched with (None if unset).
|
|
pub fn saved_language(&self) -> Option<std::ffi::OsString> {
|
|
self.imp().saved_language.borrow().clone()
|
|
}
|
|
|
|
/// All live surfaces (Zig: `core_app.surfaces.items` mapped to GObjects).
|
|
pub fn surfaces(&self) -> Vec<Surface> {
|
|
self.core()
|
|
.surface_ids()
|
|
.into_iter()
|
|
.filter_map(Surface::from_id)
|
|
.collect()
|
|
}
|
|
|
|
/// Wake the main loop (Zig: `wakeup`). Thread safe.
|
|
pub fn wakeup(&self) {
|
|
glib::MainContext::default().wakeup();
|
|
}
|
|
|
|
/// Mark that a window was requested at least once.
|
|
pub fn mark_requested_window(&self) {
|
|
self.imp().requested_window.set(true);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Run loop
|
|
|
|
/// Run the application. Replacement for `gio::Application::run` because
|
|
/// we want tighter control over the event loop to integrate with core.
|
|
pub fn run_loop(&self) -> Result<(), RunError> {
|
|
// Based on the actual `gio.Application.run` implementation.
|
|
let ctx = glib::MainContext::default();
|
|
let _guard = ctx.acquire().map_err(|_| RunError::ContextAcquireFailed)?;
|
|
|
|
let result = self.run_loop_inner(&ctx);
|
|
|
|
// The final cleanup that is always required at the end of running.
|
|
self.stop_quit_timer();
|
|
gio::Settings::sync();
|
|
// Clear out the event loop, don't block.
|
|
while ctx.iteration(false) {}
|
|
result
|
|
}
|
|
|
|
fn run_loop_inner(&self, ctx: &glib::MainContext) -> Result<(), RunError> {
|
|
let gapp = self.upcast_ref::<gio::Application>();
|
|
|
|
// Register the application
|
|
if let Err(err) = gapp.register(gio::Cancellable::NONE) {
|
|
log::warn!("error registering application: {}", err.message());
|
|
return Err(RunError::ApplicationRegisterFailed);
|
|
}
|
|
|
|
// This just calls the `activate` signal but its part of the normal
|
|
// startup routine, but only if the config allows it.
|
|
if self.core_config().initial_window {
|
|
gapp.activate();
|
|
}
|
|
|
|
// If we are NOT the primary instance, then we never want to run.
|
|
if gapp.is_remote() {
|
|
log::debug!("application is remote, exiting run loop after activation");
|
|
return Ok(());
|
|
}
|
|
|
|
// Tell systemd that we are ready.
|
|
ghostty_os::systemd::notify::ready();
|
|
|
|
// Developer aid: replay synthesized input (GHOSTTY_DEBUG_INPUT_SCRIPT).
|
|
crate::debug_input::start(self);
|
|
|
|
log::debug!("entering runloop");
|
|
let imp = self.imp();
|
|
imp.running.set(true);
|
|
let core = self.core();
|
|
let rt = self.rt();
|
|
let mut result = Ok(());
|
|
while imp.running.get() {
|
|
ctx.iteration(true);
|
|
|
|
// Tick the core Ghostty terminal app
|
|
if let Err(err) = core.tick(&*rt) {
|
|
log::error!("core tick failed: {err}");
|
|
result = Err(RunError::Tick(err.to_string()));
|
|
break;
|
|
}
|
|
|
|
// Check if we must quit based on the current state.
|
|
let must_quit = {
|
|
let config = self.core_config();
|
|
if !config.quit_after_last_window_closed {
|
|
// If we are configured to always stay running, don't quit.
|
|
false
|
|
} else if matches!(*imp.quit_timer.borrow(), QuitTimer::Expired) {
|
|
// If the quit timer has expired, quit.
|
|
log::debug!("must_quit due to quit timer expired");
|
|
true
|
|
} else if config.quit_after_last_window_closed_delay.is_none()
|
|
&& imp.requested_window.get()
|
|
&& self.windows().is_empty()
|
|
{
|
|
// No windows attached to our app. Only if we don't have
|
|
// the closed delay set.
|
|
log::debug!("must_quit due to no app windows");
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
if must_quit {
|
|
// All must quit scenarios do not need confirmation.
|
|
self.quit_now();
|
|
}
|
|
}
|
|
log::debug!("exiting runloop");
|
|
result
|
|
}
|
|
|
|
/// Quit the application. This starts the process to stop the run loop.
|
|
/// It will not exit the process.
|
|
pub fn quit(&self) {
|
|
let imp = self.imp();
|
|
|
|
// If our run loop has already exited then we are done.
|
|
if !imp.running.get() {
|
|
return;
|
|
}
|
|
|
|
// If our core app doesn't need to confirm quit then we can exit now.
|
|
if !self.core().needs_confirm_quit() {
|
|
self.quit_now();
|
|
return;
|
|
}
|
|
|
|
// Get the parent for our dialog
|
|
let parent = gtk::Window::list_toplevels().into_iter().find(|w| {
|
|
w.downcast_ref::<gtk::Window>()
|
|
.is_some_and(gtk::Window::is_active)
|
|
});
|
|
|
|
// Show a confirmation dialog
|
|
let dialog = CloseConfirmationDialog::new(close_confirmation_dialog::Target::App);
|
|
let this = self.downgrade();
|
|
dialog.connect_close_request(move |_| {
|
|
if let Some(this) = this.upgrade() {
|
|
this.quit_now();
|
|
}
|
|
});
|
|
|
|
// Show it
|
|
dialog.present(parent.as_ref());
|
|
}
|
|
|
|
fn quit_now(&self) {
|
|
// Get all our windows and destroy them, forcing them to free. We
|
|
// only want to destroy our windows (they own every other type of
|
|
// window) so this triggers a proper shutdown sequence.
|
|
for w in gtk::Window::list_toplevels() {
|
|
if let Ok(window) = w.downcast::<Window>() {
|
|
window.destroy();
|
|
}
|
|
}
|
|
|
|
// Trigger our runloop exit.
|
|
self.imp().running.set(false);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Quit timer
|
|
|
|
/// Called when there are no more open surfaces.
|
|
fn start_quit_timer(&self) {
|
|
let config = self.core_config();
|
|
|
|
// Cancel any previous timer.
|
|
self.stop_quit_timer();
|
|
|
|
// This is a no-op unless we are configured to quit after last window closed.
|
|
if !config.quit_after_last_window_closed {
|
|
return;
|
|
}
|
|
|
|
// If a delay is configured, set a timeout function to quit after the delay.
|
|
let imp = self.imp();
|
|
if let Some(v) = &config.quit_after_last_window_closed_delay {
|
|
let this = self.downgrade();
|
|
let id = glib::timeout_add_local(
|
|
std::time::Duration::from_millis(u64::from(v.as_milliseconds())),
|
|
move || {
|
|
if let Some(this) = this.upgrade() {
|
|
*this.imp().quit_timer.borrow_mut() = QuitTimer::Expired;
|
|
}
|
|
glib::ControlFlow::Break
|
|
},
|
|
);
|
|
*imp.quit_timer.borrow_mut() = QuitTimer::Active(id);
|
|
} else {
|
|
// If no delay is configured, treat it as expired.
|
|
*imp.quit_timer.borrow_mut() = QuitTimer::Expired;
|
|
}
|
|
}
|
|
|
|
/// Called when a new surface gets opened.
|
|
fn stop_quit_timer(&self) {
|
|
let old = std::mem::take(&mut *self.imp().quit_timer.borrow_mut());
|
|
if let QuitTimer::Active(id) = old {
|
|
id.remove();
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Config / CSS / accelerators
|
|
|
|
/// Set the configuration for this application. If the config has errors
|
|
/// this may show the config errors dialog.
|
|
fn set_config(&self, config: &Config) {
|
|
*self.imp().config.borrow_mut() = Some(config.clone());
|
|
self.notify("config");
|
|
|
|
// Show our errors if we have any
|
|
self.show_config_errors_dialog();
|
|
}
|
|
|
|
fn prop_config(&self) {
|
|
// Sync our accelerators for menu items.
|
|
self.sync_action_accelerators();
|
|
|
|
// Load our runtime and custom CSS.
|
|
let config = self.core_config();
|
|
if let Some(css) = self.imp().css.borrow_mut().as_mut() {
|
|
css.load_all(&config);
|
|
}
|
|
|
|
// Keep global shortcuts in sync with the config (property binding).
|
|
if self.imp().global_shortcuts_bound.get()
|
|
&& let Some(gs) = self.imp().global_shortcuts.get()
|
|
{
|
|
gs.set_config(self.imp().config.borrow().as_ref());
|
|
}
|
|
}
|
|
|
|
fn sync_action_accelerators(&self) {
|
|
use ghostty_input::binding::action::{
|
|
CloseTabMode,
|
|
CopyToClipboard,
|
|
InspectorMode,
|
|
SplitDirection,
|
|
};
|
|
let a = |name: &str, action: BindingAction| self.sync_action_accelerator(name, &action);
|
|
a("app.quit", BindingAction::Quit);
|
|
a("app.open-config", BindingAction::OpenConfig);
|
|
a("app.reload-config", BindingAction::ReloadConfig);
|
|
a(
|
|
"win.toggle-inspector",
|
|
BindingAction::Inspector(InspectorMode::Toggle),
|
|
);
|
|
a("app.show-gtk-inspector", BindingAction::ShowGtkInspector);
|
|
a(
|
|
"win.toggle-command-palette",
|
|
BindingAction::ToggleCommandPalette,
|
|
);
|
|
a("win.close", BindingAction::CloseWindow);
|
|
a("win.new-window", BindingAction::NewWindow);
|
|
a("win.new-tab", BindingAction::NewTab);
|
|
a(
|
|
"win.close-tab::this",
|
|
BindingAction::CloseTab(CloseTabMode::This),
|
|
);
|
|
a(
|
|
"tab.close::this",
|
|
BindingAction::CloseTab(CloseTabMode::This),
|
|
);
|
|
a(
|
|
"win.split-right",
|
|
BindingAction::NewSplit(SplitDirection::Right),
|
|
);
|
|
a(
|
|
"win.split-down",
|
|
BindingAction::NewSplit(SplitDirection::Down),
|
|
);
|
|
a(
|
|
"win.split-left",
|
|
BindingAction::NewSplit(SplitDirection::Left),
|
|
);
|
|
a("win.split-up", BindingAction::NewSplit(SplitDirection::Up));
|
|
a(
|
|
"win.copy",
|
|
BindingAction::CopyToClipboard(CopyToClipboard::Mixed),
|
|
);
|
|
a("win.paste", BindingAction::PasteFromClipboard);
|
|
a("win.reset", BindingAction::Reset);
|
|
a("win.clear", BindingAction::ClearScreen);
|
|
a("win.prompt-title", BindingAction::PromptSurfaceTitle);
|
|
a(
|
|
"split-tree.new-split::left",
|
|
BindingAction::NewSplit(SplitDirection::Left),
|
|
);
|
|
a(
|
|
"split-tree.new-split::right",
|
|
BindingAction::NewSplit(SplitDirection::Right),
|
|
);
|
|
a(
|
|
"split-tree.new-split::up",
|
|
BindingAction::NewSplit(SplitDirection::Up),
|
|
);
|
|
a(
|
|
"split-tree.new-split::down",
|
|
BindingAction::NewSplit(SplitDirection::Down),
|
|
);
|
|
}
|
|
|
|
fn sync_action_accelerator(&self, gtk_action: &str, action: &BindingAction) {
|
|
// Reset it initially
|
|
self.set_accels_for_action(gtk_action, &[]);
|
|
|
|
let config = self.core_config();
|
|
let Some(trigger) = config.keybind.set.get_trigger(action) else {
|
|
return;
|
|
};
|
|
let Some(accel) = crate::key::accel_from_trigger(trigger) else {
|
|
return;
|
|
};
|
|
self.set_accels_for_action(gtk_action, &[accel.as_str()]);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Startup
|
|
|
|
/// Configure the event loop (libxev) to use a specific backend.
|
|
///
|
|
/// This must be called before any event loop is created.
|
|
fn startup_xev(&self) {
|
|
let config = self.core_config();
|
|
apply_async_backend(config.async_backend);
|
|
}
|
|
|
|
/// Setup the style manager on startup: initial light/dark mode based on
|
|
/// the configuration, plus change listeners.
|
|
fn startup_style_manager(&self) {
|
|
let config = self.core_config();
|
|
|
|
// Setup our initial light/dark
|
|
let style = self.style_manager();
|
|
style.set_color_scheme(match config.window_theme {
|
|
WindowTheme::Auto | WindowTheme::Ghostty => {
|
|
let lum = config.background.to_terminal_rgb().perceived_luminance();
|
|
if lum > 0.5 {
|
|
adw::ColorScheme::PreferLight
|
|
} else {
|
|
adw::ColorScheme::PreferDark
|
|
}
|
|
}
|
|
WindowTheme::System => adw::ColorScheme::PreferLight,
|
|
WindowTheme::Dark => adw::ColorScheme::ForceDark,
|
|
WindowTheme::Light => adw::ColorScheme::ForceLight,
|
|
});
|
|
|
|
// Setup color change notifications
|
|
let this = self.downgrade();
|
|
style.connect_dark_notify(move |style| {
|
|
if let Some(this) = this.upgrade() {
|
|
this.handle_style_manager_dark(style);
|
|
}
|
|
});
|
|
|
|
// Do an initial color scheme sync. This is idempotent and does
|
|
// nothing if our current theme matches what libghostty has.
|
|
self.handle_style_manager_dark(&style);
|
|
}
|
|
|
|
fn handle_style_manager_dark(&self, style: &adw::StyleManager) {
|
|
let scheme = if style.is_dark() {
|
|
ColorScheme::Dark
|
|
} else {
|
|
ColorScheme::Light
|
|
};
|
|
log::debug!("style manager changed scheme={scheme:?}");
|
|
|
|
let core = self.core();
|
|
core.color_scheme_event(&*self.rt(), scheme);
|
|
for id in core.surface_ids() {
|
|
let Some(surface) = Surface::from_id(id) else {
|
|
continue;
|
|
};
|
|
let Some(core_surface) = surface.core() else {
|
|
continue;
|
|
};
|
|
if let Err(err) = core_surface.color_scheme_callback(scheme) {
|
|
log::warn!("unable to tell surface about color scheme change err={err}");
|
|
}
|
|
}
|
|
|
|
if let Some(css) = self.imp().css.borrow_mut().as_mut() {
|
|
css.set_color_scheme(scheme == ColorScheme::Dark);
|
|
}
|
|
}
|
|
|
|
/// Setup signal handlers (SIGUSR2 reloads the config).
|
|
fn startup_signals(&self) {
|
|
let imp = self.imp();
|
|
if imp.signal_thread_started.replace(true) {
|
|
return;
|
|
}
|
|
// glib-rs doesn't bind `g_unix_signal_add`, so a helper thread waits
|
|
// for SIGUSR2 and hops to the main context.
|
|
let mut signals = match signal_hook::iterator::Signals::new([libc::SIGUSR2]) {
|
|
Ok(s) => s,
|
|
Err(err) => {
|
|
log::warn!("unable to register SIGUSR2 handler: {err}");
|
|
return;
|
|
}
|
|
};
|
|
std::thread::spawn(move || {
|
|
for _ in signals.forever() {
|
|
glib::MainContext::default().invoke(|| {
|
|
log::info!("received SIGUSR2, reloading configuration");
|
|
Application::default()
|
|
.reload_config(Target::App, action::ReloadConfig::default());
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Setup our action map.
|
|
fn startup_action_map(&self) {
|
|
let add = |name: &str,
|
|
param: Option<&glib::VariantTy>,
|
|
f: Box<dyn Fn(&Self, Option<&glib::Variant>)>| {
|
|
let action = gio::SimpleAction::new(name, param);
|
|
let this = self.downgrade();
|
|
action.connect_activate(move |_, p| {
|
|
if let Some(this) = this.upgrade() {
|
|
f(&this, p);
|
|
}
|
|
});
|
|
self.add_action(&action);
|
|
};
|
|
|
|
let as_ty = glib::VariantTy::new("as").expect("valid variant type");
|
|
add("new-window", None, Box::new(Self::action_new_window));
|
|
add(
|
|
"new-window-command",
|
|
Some(as_ty),
|
|
Box::new(Self::action_new_window),
|
|
);
|
|
add(
|
|
"open-config",
|
|
None,
|
|
Box::new(|this, _| this.action_open_config()),
|
|
);
|
|
add(
|
|
"present-surface",
|
|
Some(glib::VariantTy::UINT64),
|
|
Box::new(Self::action_present_surface),
|
|
);
|
|
add(
|
|
"quit",
|
|
None,
|
|
Box::new(|this, _| {
|
|
this.perform_action(Target::App, Action::Quit);
|
|
}),
|
|
);
|
|
add(
|
|
"reload-config",
|
|
None,
|
|
Box::new(|this, _| {
|
|
this.perform_action(
|
|
Target::App,
|
|
Action::ReloadConfig(action::ReloadConfig::default()),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Setup our global shortcuts.
|
|
fn startup_global_shortcuts(&self) {
|
|
let Some(gs) = self.imp().global_shortcuts.get() else {
|
|
return;
|
|
};
|
|
|
|
// On startup, our dbus connection should be available.
|
|
gs.set_connection(self.dbus_connection().as_ref());
|
|
|
|
// Keep the shortcut config in sync with the app.
|
|
self.imp().global_shortcuts_bound.set(true);
|
|
gs.set_config(self.imp().config.borrow().as_ref());
|
|
|
|
// Setup the signal handler for global shortcut triggers
|
|
let this = self.downgrade();
|
|
gs.connect_trigger(move |_, action| {
|
|
let Some(this) = this.upgrade() else { return };
|
|
if let Err(err) = this.core().perform_all_action(&*this.rt(), action) {
|
|
log::warn!("failed to perform action={err}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Show the config errors dialog if the config on our application has
|
|
/// diagnostics.
|
|
fn show_config_errors_dialog(&self) {
|
|
let imp = self.imp();
|
|
let config = self.get_config();
|
|
|
|
// If we already have a dialog, just update the config.
|
|
if let Some(diag) = imp.config_errors_dialog.upgrade() {
|
|
diag.set_property("config", &config);
|
|
if config.has_diagnostics() {
|
|
diag.present(None::<>k::Widget>);
|
|
} else {
|
|
diag.close();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No diagnostics, do nothing.
|
|
if !config.has_diagnostics() {
|
|
return;
|
|
}
|
|
|
|
// No dialog yet, initialize a new one.
|
|
let dialog = ConfigErrorsDialog::new(&config);
|
|
imp.config_errors_dialog.set(Some(&dialog));
|
|
|
|
// Connect to the reload signal so we know to reload our config.
|
|
let this = self.downgrade();
|
|
dialog.connect_reload_config(move |_| {
|
|
let Some(this) = this.upgrade() else { return };
|
|
// We clear our dialog reference because it's going to close
|
|
// after response handling and we don't want to reuse it.
|
|
this.imp()
|
|
.config_errors_dialog
|
|
.set(None::<&ConfigErrorsDialog>);
|
|
// Reload our config as if the app reloaded.
|
|
this.reload_config(Target::App, action::ReloadConfig::default());
|
|
});
|
|
|
|
dialog.present(None::<>k::Widget>);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// GActions
|
|
|
|
/// `app.new-window` and `app.new-window-command`.
|
|
fn action_new_window(&self, parameter: Option<&glib::Variant>) {
|
|
log::debug!("received new window action");
|
|
|
|
let mut working_directory: Option<ghostty_os::osbytes::EncodedOs> = None;
|
|
let mut title: Option<String> = None;
|
|
let mut command: Option<Command> = None;
|
|
let mut args: Vec<ghostty_os::osbytes::EncodedOs> = Vec::new();
|
|
|
|
'overrides: {
|
|
// were we given a parameter?
|
|
let Some(parameter) = parameter else {
|
|
break 'overrides;
|
|
};
|
|
|
|
// ensure that the supplied parameter is an array of strings
|
|
let Some(values) = parameter.get::<Vec<String>>() else {
|
|
log::warn!("parameter is of type '{}', not 'as'", parameter.type_());
|
|
break 'overrides;
|
|
};
|
|
|
|
let mut e_seen = false;
|
|
for (i, s) in values.iter().enumerate() {
|
|
log::debug!("new-window argument: {i} {s}");
|
|
|
|
if e_seen {
|
|
args.push(ghostty_os::osbytes::EncodedOs::from_plain_text(s));
|
|
continue;
|
|
}
|
|
if s == "-e" {
|
|
e_seen = true;
|
|
continue;
|
|
}
|
|
if let Some(v) = s.strip_prefix("--command=") {
|
|
let mut cmd = Command::default();
|
|
if let Err(err) = cmd.parse_cli(Some(v)) {
|
|
log::warn!("unable to parse command: {err:?}");
|
|
continue;
|
|
}
|
|
command = Some(cmd);
|
|
continue;
|
|
}
|
|
if let Some(v) = s.strip_prefix("--working-directory=") {
|
|
// D-Bus strings are plain UTF-8, including literal U+FDD0.
|
|
working_directory = Some(ghostty_os::osbytes::EncodedOs::from_plain_text(
|
|
trim_ascii_whitespace(v),
|
|
));
|
|
continue;
|
|
}
|
|
if let Some(v) = s.strip_prefix("--title=") {
|
|
title = Some(trim_ascii_whitespace(v).to_owned());
|
|
}
|
|
}
|
|
}
|
|
|
|
if !args.is_empty() {
|
|
command = Some(Command::Direct(args));
|
|
}
|
|
|
|
self.new_window(
|
|
None,
|
|
WindowOverrides {
|
|
command,
|
|
working_directory,
|
|
title,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn action_open_config(&self) {
|
|
self.core().push_message(AppMessage::OpenConfig);
|
|
}
|
|
|
|
fn action_present_surface(&self, parameter: Option<&glib::Variant>) {
|
|
let Some(parameter) = parameter else { return };
|
|
|
|
// Make sure that we've received a u64 from the system.
|
|
let Some(id) = parameter.get::<u64>() else {
|
|
return;
|
|
};
|
|
|
|
// A value of zero means that there was no target surface for the
|
|
// notification so we don't focus any surface. Send the id through
|
|
// the core mailbox so core can validate it (a notification may
|
|
// outlive a Ghostty instance).
|
|
if id == 0 {
|
|
return;
|
|
}
|
|
self.core().push_message(AppMessage::PresentSurface(id));
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Action implementations
|
|
|
|
/// apprt API to perform an action. Returns whether it was performed.
|
|
pub fn perform_action(&self, target: Target<SurfaceId>, action: Action) -> bool {
|
|
// Resolve the surface target (Zig deref'd `*CoreSurface` directly).
|
|
let surface: Option<Surface> = match target {
|
|
Target::App => None,
|
|
Target::Surface(id) => {
|
|
let s = Surface::from_id(id);
|
|
if s.is_none() {
|
|
log::warn!("action for unknown surface id={id}");
|
|
}
|
|
s
|
|
}
|
|
};
|
|
let is_surface_target = matches!(target, Target::Surface(_));
|
|
// `surface` as the Zig `target` union.
|
|
let tgt = Tgt {
|
|
is_surface: is_surface_target,
|
|
surface,
|
|
};
|
|
|
|
match action {
|
|
Action::CloseTab(mode) => tgt.with_surface_bool(|s| {
|
|
let value = match mode {
|
|
action::CloseTabMode::This => "this",
|
|
action::CloseTabMode::Other => "other",
|
|
action::CloseTabMode::Right => "right",
|
|
};
|
|
s.activate_action("tab.close", Some(&value.to_variant()))
|
|
.is_ok()
|
|
}),
|
|
Action::CloseWindow => {
|
|
tgt.with_surface_bool(|s| s.activate_action("win.close", None).is_ok())
|
|
}
|
|
Action::CopyTitleToClipboard => tgt.with_surface_bool(Surface::copy_title_to_clipboard),
|
|
Action::ConfigChange(change) => {
|
|
// Wrap our config in a GObject (shares the Arc).
|
|
let config_obj = Config::from_arc(change.config);
|
|
match &tgt.surface {
|
|
Some(s) => s.set_config(&config_obj),
|
|
None => self.set_config(&config_obj),
|
|
}
|
|
true
|
|
}
|
|
Action::DesktopNotification(n) => {
|
|
self.desktop_notification(&tgt, &n);
|
|
true
|
|
}
|
|
Action::EqualizeSplits => {
|
|
if !tgt.is_surface {
|
|
log::warn!("equalize splits to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| s.activate_action("split-tree.equalize", None).is_ok())
|
|
}
|
|
Action::GotoSplit(to) => tgt.with_surface_bool(|s| Self::goto_split(s, to)),
|
|
Action::GotoWindow(dir) => Self::goto_window(dir),
|
|
Action::GotoTab(tab) => tgt.with_surface_bool(|s| {
|
|
let Some(window) = ancestor::<Window>(s) else {
|
|
log::warn!("surface is not in a window, ignoring goto_tab");
|
|
return false;
|
|
};
|
|
window.select_tab(match tab {
|
|
action::GotoTab::Previous => SelectTab::Previous,
|
|
action::GotoTab::Next => SelectTab::Next,
|
|
action::GotoTab::Last => SelectTab::Last,
|
|
action::GotoTab::Index(n) if n >= 0 => SelectTab::N(n as usize),
|
|
action::GotoTab::Index(_) => return false,
|
|
})
|
|
}),
|
|
Action::InitialSize(v) => tgt.with_surface_bool(|s| {
|
|
s.set_default_size(Size {
|
|
width: v.width,
|
|
height: v.height,
|
|
});
|
|
true
|
|
}),
|
|
Action::Inspector(v) => tgt.with_surface_bool(|s| s.control_inspector(v)),
|
|
Action::KeySequence(v) => {
|
|
if !tgt.is_surface {
|
|
log::warn!("key_sequence action to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
s.key_sequence_action(&v);
|
|
true
|
|
})
|
|
}
|
|
Action::KeyTable(v) => {
|
|
if !tgt.is_surface {
|
|
log::warn!("key_table action to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
s.key_table_action(&v);
|
|
true
|
|
})
|
|
}
|
|
Action::MouseOverLink(v) => {
|
|
let url = v.url.to_text_lossy();
|
|
tgt.with_surface_void("mouse over link", |s| {
|
|
s.set_mouse_hover_url(if url.is_empty() { None } else { Some(&url) });
|
|
});
|
|
true
|
|
}
|
|
Action::MouseShape(shape) => {
|
|
tgt.with_surface_void("mouse shape", |s| s.set_mouse_shape(shape));
|
|
true
|
|
}
|
|
Action::MouseVisibility(v) => {
|
|
tgt.with_surface_void("mouse visibility", |s| {
|
|
s.set_mouse_hidden(matches!(v, action::MouseVisibility::Hidden));
|
|
});
|
|
true
|
|
}
|
|
Action::MoveTab(v) => tgt.with_surface_bool(|s| {
|
|
let Some(window) = ancestor::<Window>(s) else {
|
|
log::warn!("surface is not in a window, ignoring move_tab");
|
|
return false;
|
|
};
|
|
window.move_tab(s, v.amount)
|
|
}),
|
|
Action::NewSplit(dir) => {
|
|
if !tgt.is_surface {
|
|
log::warn!("new split to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
let name = match dir {
|
|
action::SplitDirection::Right => "right",
|
|
action::SplitDirection::Down => "down",
|
|
action::SplitDirection::Left => "left",
|
|
action::SplitDirection::Up => "up",
|
|
};
|
|
s.activate_action("split-tree.new-split", Some(&name.to_variant()))
|
|
.is_ok()
|
|
})
|
|
}
|
|
Action::NewTab => {
|
|
if !tgt.is_surface {
|
|
log::warn!("new tab to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
// Get the window ancestor of the surface. Surfaces
|
|
// shouldn't be aware they might be in windows but at the
|
|
// app level we can do this.
|
|
let Some(window) = ancestor::<Window>(s) else {
|
|
log::warn!("surface is not in a window, ignoring new_tab");
|
|
return false;
|
|
};
|
|
window.new_tab(Some(s));
|
|
true
|
|
})
|
|
}
|
|
Action::NewWindow => {
|
|
self.new_window(tgt.surface.as_ref(), WindowOverrides::default());
|
|
true
|
|
}
|
|
Action::OpenConfig => self.open_config(),
|
|
Action::OpenUrl(v) => {
|
|
self.open_url(&v);
|
|
true
|
|
}
|
|
Action::Pwd(v) => {
|
|
tgt.with_surface_void("pwd", |s| s.set_pwd(Some(&v.pwd)));
|
|
true
|
|
}
|
|
Action::PresentTerminal => tgt.with_surface_bool(|s| {
|
|
s.present();
|
|
true
|
|
}),
|
|
Action::ProgressReport(v) => tgt.with_surface_bool(|s| {
|
|
s.set_progress_report(v);
|
|
true
|
|
}),
|
|
Action::PromptTitle(v) => match v {
|
|
action::PromptTitle::Surface => tgt.with_surface_bool(|s| {
|
|
s.prompt_title();
|
|
true
|
|
}),
|
|
action::PromptTitle::Tab => tgt.with_surface_bool(|s| {
|
|
let Some(tab) = ancestor::<Tab>(s) else {
|
|
log::warn!("surface is not in a tab, ignoring prompt_tab_title");
|
|
return false;
|
|
};
|
|
tab.prompt_tab_title();
|
|
true
|
|
}),
|
|
},
|
|
Action::Quit => {
|
|
self.quit();
|
|
true
|
|
}
|
|
Action::QuitTimer(mode) => {
|
|
match mode {
|
|
action::QuitTimer::Start => self.start_quit_timer(),
|
|
action::QuitTimer::Stop => self.stop_quit_timer(),
|
|
}
|
|
true
|
|
}
|
|
Action::ReloadConfig(opts) => {
|
|
self.reload_config(target, opts);
|
|
true
|
|
}
|
|
Action::Render => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.redraw();
|
|
}
|
|
true
|
|
}
|
|
Action::ResizeSplit(v) => {
|
|
if !tgt.is_surface {
|
|
log::warn!("resize_split to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
let Some(tree) = ancestor::<SplitTree>(s) else {
|
|
log::warn!("surface is not in a split tree, ignoring resize_split");
|
|
return false;
|
|
};
|
|
// If the tree has no splits (only one leaf), this action
|
|
// is not performable. This allows the key event to pass
|
|
// through to the terminal.
|
|
if !tree.get_is_split() {
|
|
return false;
|
|
}
|
|
let dir = match v.direction {
|
|
action::ResizeSplitDirection::Up => {
|
|
ghostty_datastruct::split_tree::Direction::Up
|
|
}
|
|
action::ResizeSplitDirection::Down => {
|
|
ghostty_datastruct::split_tree::Direction::Down
|
|
}
|
|
action::ResizeSplitDirection::Left => {
|
|
ghostty_datastruct::split_tree::Direction::Left
|
|
}
|
|
action::ResizeSplitDirection::Right => {
|
|
ghostty_datastruct::split_tree::Direction::Right
|
|
}
|
|
};
|
|
match tree.resize(dir, v.amount) {
|
|
Ok(b) => b,
|
|
Err(err) => {
|
|
log::warn!("unable to resize split: {err:?}");
|
|
false
|
|
}
|
|
}
|
|
})
|
|
}
|
|
Action::RingBell => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_bell_ringing(true);
|
|
}
|
|
true
|
|
}
|
|
Action::Scrollbar(v) => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_scrollbar(v);
|
|
}
|
|
true
|
|
}
|
|
Action::SetTitle(v) => {
|
|
tgt.with_surface_void("set_title", |s| s.set_title(Some(&v.title.to_text_lossy())));
|
|
true
|
|
}
|
|
Action::SetTabTitle(v) => {
|
|
if !tgt.is_surface {
|
|
log::warn!("set_tab_title to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
let Some(tab) = ancestor::<Tab>(s) else {
|
|
log::warn!("surface is not in a tab, ignoring set_tab_title");
|
|
return false;
|
|
};
|
|
let title = v.title.to_text_lossy();
|
|
tab.set_title_override(if title.is_empty() { None } else { Some(&title) });
|
|
true
|
|
})
|
|
}
|
|
Action::ShowChildExited(v) => tgt.with_surface_bool(|s| s.child_exited(&v)),
|
|
Action::ShowGtkInspector => {
|
|
gtk::Window::set_interactive_debugging(true);
|
|
true
|
|
}
|
|
Action::SizeLimit(v) => tgt.with_surface_bool(|s| {
|
|
// Note: we ignore the max size currently because we have
|
|
// no mechanism to enforce it.
|
|
s.set_min_size(Size {
|
|
width: v.min_width,
|
|
height: v.min_height,
|
|
});
|
|
true
|
|
}),
|
|
Action::ToggleMaximize => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.toggle_maximize();
|
|
}
|
|
true
|
|
}
|
|
Action::ToggleFullscreen(_) => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.toggle_fullscreen();
|
|
}
|
|
true
|
|
}
|
|
Action::ToggleQuickTerminal => self.toggle_quick_terminal(),
|
|
Action::ToggleTabOverview => tgt.with_surface_bool(|s| {
|
|
let Some(window) = ancestor::<Window>(s) else {
|
|
log::warn!("surface is not in a window, ignoring toggle_tab_overview");
|
|
return false;
|
|
};
|
|
window.toggle_tab_overview();
|
|
true
|
|
}),
|
|
Action::ToggleWindowDecorations => tgt.with_surface_bool(|s| {
|
|
let Some(window) = ancestor::<Window>(s) else {
|
|
log::warn!("surface is not in a window, ignoring toggle_window_decorations");
|
|
return false;
|
|
};
|
|
window.toggle_window_decorations();
|
|
true
|
|
}),
|
|
Action::ToggleCommandPalette => tgt.with_surface_bool(Surface::toggle_command_palette),
|
|
Action::ToggleSplitZoom => {
|
|
if !tgt.is_surface {
|
|
log::warn!("toggle_split_zoom to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(|s| {
|
|
let Some(tree) = ancestor::<SplitTree>(s) else {
|
|
log::warn!("surface is not in a split tree, ignoring toggle_split_zoom");
|
|
return false;
|
|
};
|
|
// If the tree has no splits (only one leaf), this action
|
|
// is not performable.
|
|
if !tree.get_is_split() {
|
|
return false;
|
|
}
|
|
s.activate_action("split-tree.zoom", None).is_ok()
|
|
})
|
|
}
|
|
Action::ShowOnScreenKeyboard => {
|
|
if !tgt.is_surface {
|
|
log::warn!("show_on_screen_keyboard to app is unexpected");
|
|
return false;
|
|
}
|
|
tgt.with_surface_bool(Surface::show_on_screen_keyboard)
|
|
}
|
|
Action::CommandFinished(v) => tgt.with_surface_bool(|s| s.command_finished(&v)),
|
|
Action::Readonly(v) => tgt.with_surface_bool(|s| s.set_readonly(v)),
|
|
Action::StartSearch(v) => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_search_active(true, &v.needle);
|
|
}
|
|
true
|
|
}
|
|
Action::EndSearch => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_search_active(false, "");
|
|
}
|
|
true
|
|
}
|
|
Action::SearchTotal(v) => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_search_total(v.total);
|
|
}
|
|
true
|
|
}
|
|
Action::SearchSelected(v) => {
|
|
if let Some(s) = &tgt.surface {
|
|
s.set_search_selected(v.selected);
|
|
}
|
|
true
|
|
}
|
|
|
|
// Unimplemented
|
|
Action::SecureInput(_)
|
|
| Action::CloseAllWindows
|
|
| Action::FloatWindow(_)
|
|
| Action::ToggleVisibility
|
|
| Action::ToggleBackgroundOpacity
|
|
| Action::CellSize(_)
|
|
| Action::RenderInspector
|
|
| Action::RendererHealth(_)
|
|
| Action::ColorChange(_)
|
|
| Action::ResetWindowSize
|
|
| Action::CheckForUpdates
|
|
| Action::Undo
|
|
| Action::Redo => {
|
|
log::warn!("unimplemented action={:?}", action.key());
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn desktop_notification(&self, tgt: &Tgt, n: &action::DesktopNotification) {
|
|
if let Some(s) = &tgt.surface {
|
|
s.send_desktop_notification(&n.title.to_text_lossy(), &n.body.to_text_lossy());
|
|
return;
|
|
}
|
|
|
|
// Set a default title if we don't already have one
|
|
let n_title = n.title.to_text_lossy();
|
|
let n_body = n.body.to_text_lossy();
|
|
let title = if n_title.is_empty() {
|
|
"Ghostty"
|
|
} else {
|
|
n_title.as_str()
|
|
};
|
|
|
|
let notification = gio::Notification::new(title);
|
|
notification.set_body(Some(&n_body));
|
|
notification.set_icon(&gio::ThemedIcon::new("com.mitchellh.ghostty"));
|
|
notification
|
|
.set_default_action_and_target_value("app.present-surface", Some(&0u64.to_variant()));
|
|
|
|
// We set the notification ID to the body content. If the content is
|
|
// the same, this notification may replace a previous notification.
|
|
self.send_notification(Some(&n_body), ¬ification);
|
|
}
|
|
|
|
fn goto_split(surface: &Surface, to: action::GotoSplit) -> bool {
|
|
use ghostty_datastruct::split_tree::{Direction, Goto};
|
|
// Design note: we can't use widget actions here because we need to
|
|
// know whether there is a goto target for returning the proper
|
|
// perform result (boolean).
|
|
let Some(tree) = ancestor::<SplitTree>(surface) else {
|
|
log::warn!("surface is not in a split tree, ignoring goto_split");
|
|
return false;
|
|
};
|
|
tree.goto(match to {
|
|
action::GotoSplit::Previous => Goto::PreviousWrapped,
|
|
action::GotoSplit::Next => Goto::NextWrapped,
|
|
action::GotoSplit::Up => Goto::Spatial(Direction::Up),
|
|
action::GotoSplit::Down => Goto::Spatial(Direction::Down),
|
|
action::GotoSplit::Left => Goto::Spatial(Direction::Left),
|
|
action::GotoSplit::Right => Goto::Spatial(Direction::Right),
|
|
})
|
|
}
|
|
|
|
fn goto_window(direction: action::GotoWindow) -> bool {
|
|
let list: Vec<gtk::Window> = gtk::Window::list_toplevels()
|
|
.into_iter()
|
|
.filter_map(|w| w.downcast::<gtk::Window>().ok())
|
|
.collect();
|
|
if list.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// The window we're starting from is typically our active window.
|
|
let start = list.iter().position(gtk::Window::is_active).unwrap_or(0);
|
|
|
|
// Go forward or backwards in the list until we find a valid window
|
|
// that is visible, wrapping around once.
|
|
let n = list.len();
|
|
for step in 0..n {
|
|
let idx = match direction {
|
|
action::GotoWindow::Next => (start + step) % n,
|
|
action::GotoWindow::Previous => (start + n - step) % n,
|
|
};
|
|
if Self::goto_window_maybe(&list[idx]) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn goto_window_maybe(gtk_window: >k::Window) -> bool {
|
|
// If it is already active skip it.
|
|
if gtk_window.is_active() {
|
|
return false;
|
|
}
|
|
// If it is hidden, skip it.
|
|
if !gtk_window.is_visible() {
|
|
return false;
|
|
}
|
|
// If it isn't a Ghostty window, skip it.
|
|
let Some(window) = gtk_window.downcast_ref::<Window>() else {
|
|
return false;
|
|
};
|
|
|
|
// Focus our active surface
|
|
let Some(surface) = window.get_active_surface() else {
|
|
return false;
|
|
};
|
|
gtk_window.present();
|
|
surface.grab_focus();
|
|
true
|
|
}
|
|
|
|
/// Create a new window (Zig: `Action.newWindow`).
|
|
pub fn new_window(&self, parent: Option<&Surface>, overrides: WindowOverrides) {
|
|
// Note that we've requested a window at least once. This is used to
|
|
// trigger quit on no windows.
|
|
self.mark_requested_window();
|
|
|
|
let win = Window::new(self, overrides.title.as_deref());
|
|
self.init_and_show_window(&win, parent, &overrides);
|
|
}
|
|
|
|
fn init_and_show_window(
|
|
&self,
|
|
win: &Window,
|
|
parent: Option<&Surface>,
|
|
overrides: &WindowOverrides,
|
|
) {
|
|
// Setup a binding so that whenever our config changes so does the
|
|
// window. There's never a time when the window config should be out
|
|
// of sync with the application config.
|
|
self.bind_property("config", win, "config").build();
|
|
|
|
// Create a new tab with window context (first tab in new window)
|
|
win.new_tab_for_window(
|
|
parent,
|
|
TabOverrides {
|
|
command: overrides.command.clone(),
|
|
working_directory: overrides.working_directory.clone(),
|
|
title: overrides.title.clone(),
|
|
},
|
|
);
|
|
|
|
// Estimate the initial window size before presenting so the window
|
|
// manager can position it correctly.
|
|
if let Some(surface) = win.get_active_surface() {
|
|
surface.estimate_initial_size();
|
|
if let Some(size) = surface.get_default_size() {
|
|
win.set_default_size(size.width as i32, size.height as i32);
|
|
}
|
|
}
|
|
|
|
// Show the window
|
|
win.present();
|
|
}
|
|
|
|
fn open_config(&self) -> bool {
|
|
// Get the config file path
|
|
let path = match ghostty_config::edit::open_path() {
|
|
Ok(p) => p,
|
|
Err(err) => {
|
|
log::warn!("error getting config file path: {err}");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Open it using open_url. "path" isn't actually a URL but at the
|
|
// time of writing that works just fine for GTK.
|
|
self.open_url(&action::OpenUrl {
|
|
kind: action::OpenUrlKind::Text,
|
|
url: path,
|
|
});
|
|
true
|
|
}
|
|
|
|
fn open_url(&self, value: &action::OpenUrl) {
|
|
// TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html
|
|
|
|
// Fallback to the minimal cross-platform way of opening a URL.
|
|
let kind = match value.kind {
|
|
action::OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown,
|
|
action::OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
|
|
action::OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
|
|
};
|
|
if let Err(err) = ghostty_os::open::open(kind, &value.url.to_os()) {
|
|
log::warn!("unable to open url: {err}");
|
|
}
|
|
}
|
|
|
|
/// Reload the configuration for the application and propagate it across
|
|
/// the entire application and all terminals.
|
|
fn reload_config(&self, target: Target<SurfaceId>, opts: action::ReloadConfig) {
|
|
// Tell systemd that reloading has started; when we exit this
|
|
// function tell it that reloading has finished.
|
|
ghostty_os::systemd::notify::reloading();
|
|
let _ready = scopeguard_ready();
|
|
|
|
// Get our config object.
|
|
let config: Arc<CoreConfig> = if opts.soft {
|
|
// Soft-reloading applies conditional logic to the existing
|
|
// loaded config so we return that as-is.
|
|
self.core_config()
|
|
} else {
|
|
// Hard reload, load a new config completely.
|
|
match CoreConfig::load() {
|
|
Ok(c) => Arc::new(c),
|
|
Err(err) => {
|
|
log::warn!("error reloading config: {err}");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update the proper target. This will trigger a `config_change`
|
|
// apprt action which will propagate the config properly to our
|
|
// property system.
|
|
match target {
|
|
Target::App => {
|
|
if let Err(err) = self.core().update_config(&*self.rt(), &config) {
|
|
log::warn!("error reloading config: {err}");
|
|
}
|
|
}
|
|
Target::Surface(id) => {
|
|
let Some(core) = Surface::from_id(id).and_then(|s| s.core()) else {
|
|
return;
|
|
};
|
|
if let Err(err) = core.update_config(&config) {
|
|
log::warn!("error reloading config: {err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn toggle_quick_terminal(&self) -> bool {
|
|
// If we already have a quick terminal window, we just toggle the
|
|
// visibility of it.
|
|
if let Some(win) = Self::quick_terminal_window() {
|
|
win.toggle_visibility();
|
|
return true;
|
|
}
|
|
|
|
// If we don't support quick terminals then we do nothing.
|
|
if !self.winproto().supports_quick_terminal() {
|
|
return false;
|
|
}
|
|
|
|
// Create our new window as a quick terminal
|
|
crate::register_resources();
|
|
let win: Window = glib::Object::builder()
|
|
.property("application", self)
|
|
.property("quick-terminal", true)
|
|
.build();
|
|
debug_assert!(win.is_quick_terminal());
|
|
self.init_and_show_window(&win, None, &WindowOverrides::default());
|
|
true
|
|
}
|
|
|
|
fn quick_terminal_window() -> Option<Window> {
|
|
gtk::Window::list_toplevels()
|
|
.into_iter()
|
|
.filter_map(|w| w.downcast::<Window>().ok())
|
|
.find(Window::is_quick_terminal)
|
|
}
|
|
}
|
|
|
|
/// The Zig `apprt.Target` as seen by action handlers.
|
|
struct Tgt {
|
|
is_surface: bool,
|
|
surface: Option<Surface>,
|
|
}
|
|
|
|
impl Tgt {
|
|
/// Run `f` for surface targets; app targets (or unknown surfaces) are
|
|
/// `false` (Zig: `.app => return false`).
|
|
fn with_surface_bool(&self, f: impl FnOnce(&Surface) -> bool) -> bool {
|
|
match &self.surface {
|
|
Some(s) => f(s),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Like Zig's void handlers that log a warning for app targets.
|
|
fn with_surface_void(&self, what: &str, f: impl FnOnce(&Surface)) {
|
|
match &self.surface {
|
|
Some(s) => f(s),
|
|
None if !self.is_surface => log::warn!("{what} to app is unexpected"),
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find an ancestor widget of type `T` (Zig: `ext.getAncestor`).
|
|
fn ancestor<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
|
|
widget.ancestor(T::static_type()).and_downcast::<T>()
|
|
}
|
|
|
|
/// Guard that tells systemd we're ready again on drop.
|
|
struct ReadyGuard;
|
|
impl Drop for ReadyGuard {
|
|
fn drop(&mut self) {
|
|
ghostty_os::systemd::notify::ready();
|
|
}
|
|
}
|
|
fn scopeguard_ready() -> ReadyGuard {
|
|
ReadyGuard
|
|
}
|
|
|
|
/// `std.mem.trim(u8, v, &std.ascii.whitespace)`: only ASCII whitespace
|
|
/// (space, `\t`, `\n`, VT, FF, `\r`) is trimmed, never Unicode whitespace
|
|
/// such as U+00A0.
|
|
fn trim_ascii_whitespace(v: &str) -> &str {
|
|
v.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c'))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod trim_tests {
|
|
use super::trim_ascii_whitespace;
|
|
|
|
#[test]
|
|
fn new_window_arguments_trim_only_ascii_whitespace() {
|
|
assert_eq!(
|
|
trim_ascii_whitespace(" \t\n\r\x0b\x0c/tmp/a b \n"),
|
|
"/tmp/a b"
|
|
);
|
|
assert_eq!(trim_ascii_whitespace("/tmp/name\u{a0}"), "/tmp/name\u{a0}");
|
|
assert_eq!(
|
|
trim_ascii_whitespace("\u{a0}title\u{2003} "),
|
|
"\u{a0}title\u{2003}"
|
|
);
|
|
assert_eq!(trim_ascii_whitespace("\x0btitle\x0b"), "title");
|
|
assert_eq!(trim_ascii_whitespace(" \t "), "");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod async_backend_tests {
|
|
use ghostty_config::types_b::AsyncBackend;
|
|
use ghostty_os::xev::{self, Backend};
|
|
|
|
use super::apply_async_backend;
|
|
|
|
#[test]
|
|
fn async_backend_selects_the_event_loop() {
|
|
let _ = xev::detect();
|
|
let detected = xev::backend();
|
|
|
|
// auto: nothing to do.
|
|
assert!(apply_async_backend(AsyncBackend::Auto));
|
|
assert_eq!(xev::backend(), detected);
|
|
|
|
// epoll is always available on Linux.
|
|
assert!(apply_async_backend(AsyncBackend::Epoll));
|
|
assert_eq!(xev::backend(), Backend::Epoll);
|
|
|
|
// io_uring only when the kernel allows it, else the backend stays.
|
|
let ok = apply_async_backend(AsyncBackend::IoUring);
|
|
assert_eq!(ok, xev::available(Backend::IoUring));
|
|
assert_eq!(
|
|
xev::backend(),
|
|
if ok { Backend::IoUring } else { Backend::Epoll }
|
|
);
|
|
|
|
let _ = xev::detect();
|
|
}
|
|
}
|