feat(apprt-gtk): port the GTK4/libadwaita app runtime
Ports ZIG/src/apprt/gtk into ghostty-apprt-gtk: every GObject class (application, window, tab, split tree, surface + scrolled window, child-exited banner, search/key-state/resize overlays, command palette, global shortcuts, all dialogs, config wrapper) as glib subclasses with the same GType names, properties, signals and template children; GDK key translation; X11 and Wayland window-protocol helpers (blur, CSD, quick terminal via gtk4-layer-shell); single-instance D-Bus IPC; gsettings; cgroup scope creation. build.rs compiles Ghostty's blueprints with blueprint-compiler and bundles UI, CSS and icons into a gresource, as Ghostty's build does. The core is not ported yet, so the crate defines the boundary it needs in src/core.rs (CoreApp, CoreSurface, RtApp, RtSurface, Waker); the spec is in docs/rewrite/notes/ghostty-apprt-gtk.md. ghostty-core will implement these traits (the crate no longer depends on core to avoid a cycle). examples/demo.rs runs the app with a dummy core that clears the GLArea; it launches on Wayland without GTK criticals. Stubs/open items: toggle_visibility, float_window, secure_input and close_all_windows return false; inspector is a no-op; X11/Wayland protocol code is untested against real compositors; gtk4-rs doesn't expose the GLArea framebuffer id, so the renderer must query it. Also registers ghostty-apprt and ghostty-cli in workspace.dependencies. Test Plan: - cargo test -p ghostty-apprt-gtk (36 tests) - DEMO_QUIT_AFTER_MS=3000 cargo run -p ghostty-apprt-gtk --example demo
This commit is contained in:
1 parent
72434a1255
commit
88cc13871c
80 files changed
+17271
-4
No files matched your search
@@ -0,0 +1,560 @@
|
||||
//! Port of apprt/gtk/class/command_palette.zig (`GhosttyCommandPalette` and
|
||||
//! the private `GhosttyCommand` list item wrapper).
|
||||
//!
|
||||
//! Property `config`; signal `trigger(action: BindingAction)`. The palette
|
||||
//! keeps itself alive (a self-reference) from `new()` until its dialog is
|
||||
//! closed, like the Zig `refSink`/`unref` dance.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use ghostty_input::binding::Action as BindingAction;
|
||||
use ghostty_input::command::Command as InputCommand;
|
||||
use gtk::glib;
|
||||
use gtk::glib::subclass::Signal;
|
||||
use gtk::{CompositeTemplate, TemplateChild, gio};
|
||||
|
||||
use super::application::Application;
|
||||
use super::config::Config;
|
||||
use super::surface::Surface;
|
||||
use super::window::Window;
|
||||
|
||||
/// Boxed wrapper so a binding action can travel through the `trigger`
|
||||
/// signal (Zig passes `*const input.Binding.Action`).
|
||||
#[derive(Debug, Clone, glib::Boxed)]
|
||||
#[boxed_type(name = "GhosttyBindingAction")]
|
||||
pub struct BoxedAction(pub BindingAction);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Command (list item)
|
||||
|
||||
mod command_imp {
|
||||
use super::{
|
||||
BindingAction, Config, InputCommand, RefCell, Surface, ghostty_command_title_for_jump, glib,
|
||||
};
|
||||
use adw::prelude::*;
|
||||
use gtk::glib::subclass::prelude::*;
|
||||
|
||||
pub enum CommandData {
|
||||
Regular {
|
||||
command: InputCommand,
|
||||
},
|
||||
Jump {
|
||||
surface: glib::WeakRef<Surface>,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
sort_key: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for CommandData {
|
||||
fn default() -> Self {
|
||||
CommandData::Regular {
|
||||
command: InputCommand::new(BindingAction::Ignore, "", ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, glib::Properties)]
|
||||
#[properties(wrapper_type = super::Command)]
|
||||
pub struct Command {
|
||||
#[property(get, set, nullable)]
|
||||
pub config: RefCell<Option<Config>>,
|
||||
|
||||
pub data: RefCell<CommandData>,
|
||||
|
||||
#[property(name = "action-key", get = Self::prop_get_action_key, type = Option<String>)]
|
||||
#[property(name = "action", get = Self::prop_get_action, type = Option<String>)]
|
||||
#[property(name = "title", get = Self::prop_get_title, type = Option<String>)]
|
||||
#[property(name = "description", get = Self::prop_get_description, type = Option<String>)]
|
||||
_props: (),
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for Command {
|
||||
const NAME: &'static str = "GhosttyCommand";
|
||||
type Type = super::Command;
|
||||
type ParentType = glib::Object;
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for Command {
|
||||
fn dispose(&self) {
|
||||
self.config.replace(None);
|
||||
if let CommandData::Jump { surface, .. } = &*self.data.borrow() {
|
||||
surface.set(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn prop_get_action_key(&self) -> Option<String> {
|
||||
match &*self.data.borrow() {
|
||||
CommandData::Regular { command } => Some(command.action.to_string()),
|
||||
CommandData::Jump { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn prop_get_action(&self) -> Option<String> {
|
||||
let data = self.data.borrow();
|
||||
let CommandData::Regular { command } = &*data else {
|
||||
return None;
|
||||
};
|
||||
let config = self.config.borrow();
|
||||
let cfg = config.as_ref()?.get();
|
||||
let trigger = cfg.keybind.set.get_trigger(&command.action)?;
|
||||
crate::key::accel_from_trigger(trigger)
|
||||
}
|
||||
|
||||
fn prop_get_title(&self) -> Option<String> {
|
||||
let mut data = self.data.borrow_mut();
|
||||
match &mut *data {
|
||||
CommandData::Regular { command } => Some(command.title.clone()),
|
||||
CommandData::Jump { surface, title, .. } => {
|
||||
if let Some(t) = title {
|
||||
return Some(t.clone());
|
||||
}
|
||||
let surface = surface.upgrade()?;
|
||||
*title = Some(ghostty_command_title_for_jump(&surface));
|
||||
title.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prop_get_description(&self) -> Option<String> {
|
||||
let mut data = self.data.borrow_mut();
|
||||
match &mut *data {
|
||||
CommandData::Regular { command } => Some(command.description.clone()),
|
||||
CommandData::Jump {
|
||||
surface,
|
||||
description,
|
||||
..
|
||||
} => {
|
||||
if let Some(d) = description {
|
||||
return Some(d.clone());
|
||||
}
|
||||
let surface = surface.upgrade()?;
|
||||
let title = surface
|
||||
.get_effective_title()
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
if let Some(pwd) = surface.get_pwd()
|
||||
&& !title.contains(&pwd)
|
||||
{
|
||||
*description = Some(pwd);
|
||||
}
|
||||
description.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Focus: {title}" for jump commands.
|
||||
fn ghostty_command_title_for_jump(surface: &Surface) -> String {
|
||||
let t = surface
|
||||
.get_effective_title()
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
format!("Focus: {t}")
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
/// Object wrapping a command so GTK list models can consume it.
|
||||
pub struct Command(ObjectSubclass<command_imp::Command>);
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Create a regular command.
|
||||
pub fn new(config: &Config, command: &InputCommand) -> Self {
|
||||
let obj: Self = glib::Object::builder().property("config", config).build();
|
||||
obj.imp().data.replace(command_imp::CommandData::Regular {
|
||||
command: command.clone(),
|
||||
});
|
||||
obj
|
||||
}
|
||||
|
||||
/// Create a new jump command that focuses a specific surface.
|
||||
pub fn new_jump(config: &Config, surface: &Surface) -> Self {
|
||||
let obj: Self = glib::Object::builder().property("config", config).build();
|
||||
obj.imp().data.replace(command_imp::CommandData::Jump {
|
||||
surface: surface.downgrade(),
|
||||
title: None,
|
||||
description: None,
|
||||
// TODO: Replace with surface id whenever Ghostty adds one
|
||||
sort_key: surface.as_ptr() as usize,
|
||||
});
|
||||
obj
|
||||
}
|
||||
|
||||
/// The action (None for jump commands).
|
||||
pub fn get_action(&self) -> Option<BindingAction> {
|
||||
match &*self.imp().data.borrow() {
|
||||
command_imp::CommandData::Regular { command } => Some(command.action.clone()),
|
||||
command_imp::CommandData::Jump { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_jump(&self) -> bool {
|
||||
matches!(
|
||||
&*self.imp().data.borrow(),
|
||||
command_imp::CommandData::Jump { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Strong reference to the jump surface, if it still exists.
|
||||
pub fn get_jump_surface(&self) -> Option<Surface> {
|
||||
match &*self.imp().data.borrow() {
|
||||
command_imp::CommandData::Regular { .. } => None,
|
||||
command_imp::CommandData::Jump { surface, .. } => surface.upgrade(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_key(&self) -> Option<usize> {
|
||||
match &*self.imp().data.borrow() {
|
||||
command_imp::CommandData::Regular { .. } => None,
|
||||
command_imp::CommandData::Jump { sort_key, .. } => Some(*sort_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two commands for sorting (`compareCommands`). Sorts
|
||||
/// alphabetically by title (case-insensitive), with colon normalization so
|
||||
/// "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker. Returns
|
||||
/// true if `a` sorts before `b`.
|
||||
fn compare_commands(a: &Command, b: &Command) -> bool {
|
||||
let Some(a_title) = a.property::<Option<String>>("title") else {
|
||||
return false;
|
||||
};
|
||||
let Some(b_title) = b.property::<Option<String>>("title") else {
|
||||
return true;
|
||||
};
|
||||
title_less_than(&a_title, &b_title).unwrap_or_else(|| {
|
||||
// Titles are equal - use sort_key as tie-breaker if both are jump
|
||||
// commands.
|
||||
match (a.sort_key(), b.sort_key()) {
|
||||
(Some(ka), Some(kb)) => ka < kb,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns Some(ordering result) if the titles differ, None if equal.
|
||||
fn title_less_than(a_title: &str, b_title: &str) -> Option<bool> {
|
||||
let a = a_title.as_bytes();
|
||||
let b = b_title.as_bytes();
|
||||
for i in 0..a.len().min(b.len()) {
|
||||
let norm = |c: u8| if c == b':' { b'\t' } else { c }.to_ascii_lowercase();
|
||||
let (al, bl) = (norm(a[i]), norm(b[i]));
|
||||
if al != bl {
|
||||
return Some(al < bl);
|
||||
}
|
||||
}
|
||||
// If one title is a prefix of the other, shorter one comes first
|
||||
if a.len() != b.len() {
|
||||
return Some(a.len() < b.len());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if an action is supported on GTK.
|
||||
fn is_action_supported_on_gtk(action: &BindingAction) -> bool {
|
||||
!matches!(
|
||||
action,
|
||||
BindingAction::CloseAllWindows
|
||||
| BindingAction::ToggleSecureInput
|
||||
| BindingAction::CheckForUpdates
|
||||
| BindingAction::Redo
|
||||
| BindingAction::Undo
|
||||
| BindingAction::ResetWindowSize
|
||||
| BindingAction::ToggleWindowFloatOnTop
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CommandPalette
|
||||
|
||||
mod imp {
|
||||
use super::{
|
||||
Application, BoxedAction, Command, CompositeTemplate, Config, OnceLock, Ordering, RefCell,
|
||||
Signal, TemplateChild, compare_commands, is_action_supported_on_gtk,
|
||||
};
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
|
||||
#[derive(Default, glib::Properties, CompositeTemplate)]
|
||||
#[properties(wrapper_type = super::CommandPalette)]
|
||||
#[template(resource = "/com/mitchellh/ghostty/ui/1.5/command-palette.ui")]
|
||||
pub struct CommandPalette {
|
||||
/// The configuration that this command palette is using.
|
||||
#[property(get, set, nullable)]
|
||||
pub config: RefCell<Option<Config>>,
|
||||
|
||||
/// Self reference held while the palette is alive (until the dialog
|
||||
/// closes), see `new()`.
|
||||
pub self_ref: RefCell<Option<super::CommandPalette>>,
|
||||
|
||||
#[template_child]
|
||||
pub dialog: TemplateChild<adw::Dialog>,
|
||||
#[template_child]
|
||||
pub search: TemplateChild<gtk::SearchEntry>,
|
||||
#[template_child]
|
||||
pub view: TemplateChild<gtk::ListView>,
|
||||
#[template_child]
|
||||
pub model: TemplateChild<gtk::SingleSelection>,
|
||||
#[template_child]
|
||||
pub source: TemplateChild<gio::ListStore>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for CommandPalette {
|
||||
const NAME: &'static str = "GhosttyCommandPalette";
|
||||
type Type = super::CommandPalette;
|
||||
type ParentType = adw::Bin;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
Command::ensure_type();
|
||||
klass.bind_template();
|
||||
klass.bind_template_callbacks();
|
||||
}
|
||||
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for CommandPalette {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
// Listen for any changes to our config.
|
||||
self.obj()
|
||||
.connect_config_notify(super::CommandPalette::prop_config);
|
||||
}
|
||||
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
|
||||
SIGNALS.get_or_init(|| {
|
||||
vec![
|
||||
Signal::builder("trigger")
|
||||
.param_types([BoxedAction::static_type()])
|
||||
.build(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn dispose(&self) {
|
||||
self.source.remove_all();
|
||||
self.config.replace(None);
|
||||
self.dispose_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for CommandPalette {}
|
||||
impl BinImpl for CommandPalette {}
|
||||
|
||||
impl CommandPalette {
|
||||
pub(super) fn collect_jump_commands(config: &Config, out: &mut Vec<Command>) {
|
||||
let app = Application::default();
|
||||
for surface in app.surfaces() {
|
||||
out.push(Command::new_jump(config, &surface));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn collect_regular_commands(config: &Config, out: &mut Vec<Command>) {
|
||||
let cfg = config.get();
|
||||
for command in &cfg.command_palette_entry.value {
|
||||
// Filter out actions that are not implemented or don't make
|
||||
// sense for GTK.
|
||||
if !is_action_supported_on_gtk(&command.action) {
|
||||
continue;
|
||||
}
|
||||
out.push(Command::new(config, command));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sort(commands: &mut [Command]) {
|
||||
// std.mem.sort is stable; sort_by is stable too.
|
||||
commands.sort_by(|a, b| {
|
||||
if compare_commands(a, b) {
|
||||
Ordering::Less
|
||||
} else if compare_commands(b, a) {
|
||||
Ordering::Greater
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[gtk::template_callbacks]
|
||||
impl CommandPalette {
|
||||
#[template_callback]
|
||||
fn closed(_dialog: &adw::Dialog, palette: &super::CommandPalette) {
|
||||
// Drop the self reference taken in `new()`.
|
||||
palette.imp().self_ref.replace(None);
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
fn search_stopped(_entry: >k::SearchEntry, palette: &super::CommandPalette) {
|
||||
// ESC was pressed - close the palette
|
||||
palette.close();
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
fn search_activated(_entry: >k::SearchEntry, palette: &super::CommandPalette) {
|
||||
// If Enter is pressed, activate the selected entry
|
||||
let pos = palette.imp().model.selected();
|
||||
palette.activated(pos);
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
fn row_activated(_view: >k::ListView, pos: u32, palette: &super::CommandPalette) {
|
||||
palette.activated(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct CommandPalette(ObjectSubclass<imp::CommandPalette>)
|
||||
@extends adw::Bin, gtk::Widget,
|
||||
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
/// Create a new instance of the command palette. The palette keeps
|
||||
/// itself alive until its dialog is closed (the caller's reference is
|
||||
/// independent).
|
||||
pub fn new() -> Self {
|
||||
crate::register_resources();
|
||||
let obj: Self = glib::Object::new();
|
||||
// Sink ourselves so that we aren't floating anymore. We'll drop
|
||||
// this reference when the palette is closed or an action is
|
||||
// activated.
|
||||
obj.imp().self_ref.replace(Some(obj.clone()));
|
||||
obj
|
||||
}
|
||||
|
||||
fn prop_config(&self) {
|
||||
let imp = self.imp();
|
||||
let Some(config) = self.config() else {
|
||||
log::warn!("command palette does not have a config!");
|
||||
return;
|
||||
};
|
||||
|
||||
// Clear existing binds
|
||||
imp.source.remove_all();
|
||||
|
||||
let mut commands: Vec<Command> = Vec::new();
|
||||
imp::CommandPalette::collect_jump_commands(&config, &mut commands);
|
||||
imp::CommandPalette::collect_regular_commands(&config, &mut commands);
|
||||
imp::CommandPalette::sort(&mut commands);
|
||||
|
||||
for cmd in &commands {
|
||||
imp.source.append(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
self.imp().dialog.close();
|
||||
}
|
||||
|
||||
/// Show or hide the command palette dialog. If the dialog is shown it
|
||||
/// will be modal over the given window.
|
||||
pub fn toggle(&self, window: &Window) {
|
||||
let imp = self.imp();
|
||||
|
||||
// If the dialog has been shown, close it.
|
||||
if imp.dialog.is_realized() {
|
||||
self.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the dialog
|
||||
imp.dialog.present(Some(window));
|
||||
|
||||
// Focus on the search bar when opening the dialog
|
||||
imp.search.grab_focus();
|
||||
}
|
||||
|
||||
/// Send the `trigger` signal for the item at `pos` of the filtered model.
|
||||
fn activated(&self, pos: u32) {
|
||||
let imp = self.imp();
|
||||
|
||||
// Use the model and not the source here to use the list of
|
||||
// *visible* results
|
||||
let object = imp.model.upcast_ref::<gio::ListModel>().item(pos);
|
||||
|
||||
// Close before running the action in order to avoid being replaced
|
||||
// by another dialog (such as the change title dialog). If that
|
||||
// occurs then the command palette dialog won't be counted as having
|
||||
// closed properly and cannot receive focus when reopened.
|
||||
self.close();
|
||||
|
||||
let Some(cmd) = object.and_then(|o| o.downcast::<Command>().ok()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Handle jump commands differently
|
||||
if cmd.is_jump() {
|
||||
if let Some(surface) = cmd.get_jump_surface() {
|
||||
surface.present();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular command - emit trigger signal
|
||||
let Some(action) = cmd.get_action() else {
|
||||
return;
|
||||
};
|
||||
self.emit_by_name::<()>("trigger", &[&BoxedAction(action)]);
|
||||
}
|
||||
|
||||
/// Connect to the `trigger` signal, emitted when a command from the
|
||||
/// palette is activated.
|
||||
pub fn connect_trigger<F: Fn(&Self, &BindingAction) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"trigger",
|
||||
false,
|
||||
glib::closure_local!(move |obj: &Self, action: BoxedAction| f(obj, &action.0)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandPalette {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_ordering_colon_normalization() {
|
||||
// "Foo:" sorts before "Foo Bar:"
|
||||
assert_eq!(title_less_than("Foo:", "Foo Bar:"), Some(true));
|
||||
assert_eq!(title_less_than("Foo Bar:", "Foo:"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_ordering_case_insensitive_and_prefix() {
|
||||
assert_eq!(title_less_than("abc", "ABD"), Some(true));
|
||||
assert_eq!(title_less_than("abc", "ABC"), None);
|
||||
assert_eq!(title_less_than("ab", "abc"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_actions_filtered() {
|
||||
assert!(!is_action_supported_on_gtk(&BindingAction::Undo));
|
||||
assert!(!is_action_supported_on_gtk(&BindingAction::CloseAllWindows));
|
||||
assert!(is_action_supported_on_gtk(&BindingAction::NewTab));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user