Files
ghostty-rs/crates/ghostty-apprt-gtk/src/class/tab.rs
T
ddidderr 88cc13871c 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
2026-10-11 22:57:15 +02:00

424 lines
14 KiB
Rust

//! 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 {}
}