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
6675db1eeb
commit
69d823cbc7
80 files changed
+17271
-4
No files matched your search
@@ -0,0 +1,423 @@
|
||||
//! Port of apprt/gtk/class/tab.zig: `GhosttyTab`, the content of one tab
|
||||
//! page (a split tree plus tab-level title/actions).
|
||||
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use ghostty_apprt::surface::NewSurfaceContext;
|
||||
use gtk::gio;
|
||||
use gtk::glib::{self, clone, subclass::Signal};
|
||||
|
||||
use super::application::Application;
|
||||
use super::config::Config;
|
||||
use super::split_tree::{SplitTree, SurfaceTree};
|
||||
use super::surface::{Surface, SurfaceOverrides};
|
||||
use super::title_dialog::{Target as TitleTarget, TitleDialog};
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct Tab(ObjectSubclass<imp::Tab>)
|
||||
@extends gtk::Box, gtk::Widget,
|
||||
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
/// Create a new tab. If `config` is None the application config is used.
|
||||
/// An initial surface is created in the split tree.
|
||||
pub fn new(config: Option<&Config>, overrides: SurfaceOverrides) -> Self {
|
||||
let tab: Self = glib::Object::new();
|
||||
let imp = tab.imp();
|
||||
|
||||
// If our configuration is null then we get the configuration from
|
||||
// the application.
|
||||
let config = config
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Application::default().get_config());
|
||||
imp.config.replace(Some(config));
|
||||
tab.notify("config");
|
||||
|
||||
// Create our initial surface in the split tree.
|
||||
if let Err(e) = imp.split_tree.new_split(
|
||||
ghostty_datastruct::split_tree::Direction::Right,
|
||||
None,
|
||||
overrides,
|
||||
) {
|
||||
// TODO: show a nicer "no surfaces" state. Incredibly unlikely.
|
||||
panic!("oom creating initial split: {e}");
|
||||
}
|
||||
|
||||
tab
|
||||
}
|
||||
|
||||
/// Set the parent of this tab page. This only affects the first surface
|
||||
/// ever created for a tab. If a surface was already created this does
|
||||
/// nothing.
|
||||
pub fn set_parent(&self, parent: &Surface) {
|
||||
self.set_parent_with_context(parent, NewSurfaceContext::Tab);
|
||||
}
|
||||
|
||||
pub fn set_parent_with_context(&self, parent: &Surface, context: NewSurfaceContext) {
|
||||
if let Some(surface) = self.get_active_surface() {
|
||||
surface.set_parent(parent, context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overridden title. Generally shown over the title unless unset.
|
||||
pub fn set_title_override(&self, title: Option<&str>) {
|
||||
self.imp().title_override.replace(title.map(str::to_owned));
|
||||
self.notify("title-override");
|
||||
}
|
||||
|
||||
pub fn get_title_override(&self) -> Option<String> {
|
||||
self.imp().title_override.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn get_title(&self) -> Option<String> {
|
||||
self.imp().title.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn prompt_tab_title(&self) {
|
||||
let imp = self.imp();
|
||||
let initial = imp
|
||||
.title_override
|
||||
.borrow()
|
||||
.clone()
|
||||
.or_else(|| imp.title.borrow().clone());
|
||||
let dialog = TitleDialog::new(TitleTarget::Tab, initial.as_deref());
|
||||
dialog.connect_set(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, title| this.set_title_override(if title.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(title)
|
||||
})
|
||||
));
|
||||
dialog.present(self);
|
||||
}
|
||||
|
||||
/// Get the currently active surface. See the "active-surface" property.
|
||||
pub fn get_active_surface(&self) -> Option<Surface> {
|
||||
self.get_split_tree().get_active_surface()
|
||||
}
|
||||
|
||||
/// Get the surface tree of this tab.
|
||||
pub fn get_surface_tree(&self) -> Option<super::split_tree::Tree> {
|
||||
self.get_split_tree().get_tree()
|
||||
}
|
||||
|
||||
/// Get the split tree widget that is in this tab.
|
||||
pub fn get_split_tree(&self) -> SplitTree {
|
||||
self.imp().split_tree.get()
|
||||
}
|
||||
|
||||
/// Get the config this tab was created with.
|
||||
pub fn get_config(&self) -> Option<Config> {
|
||||
self.imp().config.borrow().clone()
|
||||
}
|
||||
|
||||
/// Returns true if this tab needs confirmation before quitting based on
|
||||
/// the various Ghostty configurations.
|
||||
pub fn get_needs_confirm_quit(&self) -> bool {
|
||||
self.get_split_tree().get_needs_confirm_quit()
|
||||
}
|
||||
|
||||
/// Connect to the `close-request` signal, emitted whenever the tab would
|
||||
/// like to be closed.
|
||||
pub fn connect_close_request<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_local("close-request", false, move |args| {
|
||||
if let Some(Ok(this)) = args.first().map(glib::Value::get::<Tab>) {
|
||||
f(&this);
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the tab view holding this tab, if any.
|
||||
fn get_tab_view(&self) -> Option<adw::TabView> {
|
||||
self.ancestor(adw::TabView::static_type())
|
||||
.and_then(|w| w.downcast().ok())
|
||||
}
|
||||
|
||||
/// Get the tab page holding this tab, if any.
|
||||
fn get_tab_page(&self) -> Option<adw::TabPage> {
|
||||
Some(self.get_tab_view()?.page(self))
|
||||
}
|
||||
|
||||
fn init_action_map(&self) {
|
||||
let group = gio::SimpleActionGroup::new();
|
||||
|
||||
let close = gio::SimpleAction::new("close", Some(glib::VariantTy::STRING));
|
||||
close.connect_activate(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, param| this.action_close(param.and_then(|p| p.str()))
|
||||
));
|
||||
group.add_action(&close);
|
||||
|
||||
let ring_bell = gio::SimpleAction::new("ring-bell", None);
|
||||
ring_bell.connect_activate(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, _| {
|
||||
// If the page is selected already we don't mark it as
|
||||
// needing attention. We only want to mark unfocused pages.
|
||||
// This will then clear when the page is selected.
|
||||
let Some(page) = this.get_tab_page() else {
|
||||
return;
|
||||
};
|
||||
if page.is_selected() {
|
||||
return;
|
||||
}
|
||||
page.set_needs_attention(true);
|
||||
}
|
||||
));
|
||||
group.add_action(&ring_bell);
|
||||
|
||||
let next = gio::SimpleAction::new("next-page", None);
|
||||
next.connect_activate(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, _| {
|
||||
if let Some(v) = this.get_tab_view() {
|
||||
v.select_next_page();
|
||||
}
|
||||
}
|
||||
));
|
||||
group.add_action(&next);
|
||||
|
||||
let prev = gio::SimpleAction::new("previous-page", None);
|
||||
prev.connect_activate(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, _| {
|
||||
if let Some(v) = this.get_tab_view() {
|
||||
v.select_previous_page();
|
||||
}
|
||||
}
|
||||
));
|
||||
group.add_action(&prev);
|
||||
|
||||
let prompt = gio::SimpleAction::new("prompt-tab-title", None);
|
||||
prompt.connect_activate(clone!(
|
||||
#[weak(rename_to = this)]
|
||||
self,
|
||||
move |_, _| this.prompt_tab_title()
|
||||
));
|
||||
group.add_action(&prompt);
|
||||
|
||||
self.insert_action_group("tab", Some(&group));
|
||||
}
|
||||
|
||||
fn action_close(&self, mode: Option<&str>) {
|
||||
let Some(mode) = mode else {
|
||||
log::warn!("tab.close-tab called without a parameter");
|
||||
return;
|
||||
};
|
||||
let Some(tab_view) = self.get_tab_view() else {
|
||||
return;
|
||||
};
|
||||
let page = tab_view.page(self);
|
||||
|
||||
// Delegate to our parent to handle this, since this will emit a
|
||||
// close-page signal that the parent can intercept.
|
||||
match mode {
|
||||
"this" => tab_view.close_page(&page),
|
||||
"other" => tab_view.close_other_pages(&page),
|
||||
"right" => tab_view.close_pages_after(&page),
|
||||
// Need to be defensive here since actions can be triggered
|
||||
// externally.
|
||||
_ => log::warn!("invalid mode provided to tab.close-tab: {mode}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default, gtk::CompositeTemplate)]
|
||||
#[template(resource = "/com/mitchellh/ghostty/ui/1.5/tab.ui")]
|
||||
pub struct Tab {
|
||||
/// The configuration that this tab is using.
|
||||
pub config: RefCell<Option<Config>>,
|
||||
|
||||
/// The title of this tab. This is usually bound to the active surface.
|
||||
pub title: RefCell<Option<String>>,
|
||||
|
||||
/// The manually overridden title from `prompt_tab_title`.
|
||||
pub title_override: RefCell<Option<String>>,
|
||||
|
||||
/// The tooltip of this tab. This is usually bound to the active surface.
|
||||
pub tooltip: RefCell<Option<String>>,
|
||||
|
||||
#[template_child]
|
||||
pub split_tree: TemplateChild<SplitTree>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for Tab {
|
||||
const NAME: &'static str = "GhosttyTab";
|
||||
type Type = super::Tab;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
SplitTree::ensure_type();
|
||||
Surface::ensure_type();
|
||||
klass.bind_template();
|
||||
klass.bind_template_callbacks();
|
||||
}
|
||||
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[gtk::template_callbacks]
|
||||
impl Tab {
|
||||
#[template_callback]
|
||||
fn notify_active_surface(split: &SplitTree, _pspec: glib::ParamSpec) {
|
||||
if let Some(tab) = split.ancestor(super::Tab::static_type()) {
|
||||
tab.notify("active-surface");
|
||||
}
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
fn notify_tree(split: &SplitTree, _pspec: glib::ParamSpec) {
|
||||
let Some(tab) = split
|
||||
.ancestor(super::Tab::static_type())
|
||||
.and_then(|w| w.downcast::<super::Tab>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
tab.notify("surface-tree");
|
||||
|
||||
// If our tree is empty we close the tab.
|
||||
if tab.get_surface_tree().is_none_or(|t| t.is_empty()) {
|
||||
tab.emit_by_name::<()>("close-request", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[template_callback(function)]
|
||||
fn computed_title(
|
||||
config: Option<Config>,
|
||||
terminal: Option<String>,
|
||||
surface_override: Option<String>,
|
||||
tab_override: Option<String>,
|
||||
zoomed: bool,
|
||||
bell_ringing: bool,
|
||||
) -> String {
|
||||
// Our plain title is the manually tab overridden title if it
|
||||
// exists, otherwise the overridden title if it exists,
|
||||
// otherwise the terminal title if it exists, otherwise a
|
||||
// default string.
|
||||
let config_title = config.as_ref().and_then(|c| c.get().title.clone());
|
||||
let plain = tab_override
|
||||
.or(surface_override)
|
||||
.or(terminal)
|
||||
.or(config_title)
|
||||
.unwrap_or_else(|| "Ghostty".to_owned());
|
||||
|
||||
// We don't need a config in every case, but if we don't have one
|
||||
// let's just assume something went terribly wrong and use our
|
||||
// default title.
|
||||
let Some(config) = config else {
|
||||
log::warn!("config unavailable for computed title, likely bug");
|
||||
return plain;
|
||||
};
|
||||
let config = config.get();
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
// If our bell is ringing, then we prefix the bell icon.
|
||||
if bell_ringing && config.bell_features.title {
|
||||
out.push_str("🔔 ");
|
||||
}
|
||||
|
||||
// If we're zoomed, prefix with the magnifying glass emoji.
|
||||
if zoomed {
|
||||
out.push_str("🔍 ");
|
||||
}
|
||||
|
||||
out.push_str(&plain);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for Tab {
|
||||
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::<Surface>("active-surface")
|
||||
.read_only()
|
||||
.build(),
|
||||
glib::ParamSpecObject::builder::<Config>("config").build(),
|
||||
glib::ParamSpecObject::builder::<SplitTree>("split-tree")
|
||||
.read_only()
|
||||
.build(),
|
||||
glib::ParamSpecBoxed::builder::<SurfaceTree>("surface-tree")
|
||||
.read_only()
|
||||
.build(),
|
||||
glib::ParamSpecString::builder("tooltip").build(),
|
||||
glib::ParamSpecString::builder("title").build(),
|
||||
glib::ParamSpecString::builder("title-override").build(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: std::sync::OnceLock<Vec<Signal>> = std::sync::OnceLock::new();
|
||||
SIGNALS.get_or_init(|| {
|
||||
// Emitted whenever the tab would like to be closed.
|
||||
vec![Signal::builder("close-request").build()]
|
||||
})
|
||||
}
|
||||
|
||||
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
|
||||
let obj = self.obj();
|
||||
match pspec.name() {
|
||||
"active-surface" => obj.get_active_surface().to_value(),
|
||||
"config" => self.config.borrow().to_value(),
|
||||
"split-tree" => self.split_tree.get().to_value(),
|
||||
"surface-tree" => obj.get_surface_tree().map(SurfaceTree).to_value(),
|
||||
"tooltip" => self.tooltip.borrow().to_value(),
|
||||
"title" => self.title.borrow().to_value(),
|
||||
"title-override" => self.title_override.borrow().to_value(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
|
||||
match pspec.name() {
|
||||
"config" => {
|
||||
self.config.replace(value.get().expect("Config"));
|
||||
}
|
||||
"tooltip" => {
|
||||
self.tooltip.replace(value.get().expect("string"));
|
||||
}
|
||||
"title" => {
|
||||
self.title.replace(value.get().expect("string"));
|
||||
}
|
||||
"title-override" => {
|
||||
self.title_override.replace(value.get().expect("string"));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
self.obj().init_action_map();
|
||||
}
|
||||
|
||||
fn dispose(&self) {
|
||||
self.config.replace(None);
|
||||
self.dispose_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for Tab {}
|
||||
impl BoxImpl for Tab {}
|
||||
}
|
||||
Reference in new issue
Block a user