crates/signaltty-gui/src/terminal.rs
//! One pane: a card holding a status header and a VTE terminal fed
//! externally from the server stream (the PTY lives in the server,
//! never here).
//!
//! ```text
//! ╭──────────────────────────────────────────────────────────────╮
//! │ ◌ worker Idle [Task Working] Approval ⫿ ⊟ × [Resume] │
//! │ terminal … │
//! ╰──────────────────────────────────────────────────────────────╯
//! ```
//!
//! Attention is a ring inside the card's edge (an inset shadow, so it
//! never resizes the terminal and a split can't clip it); focus marks
//! the card in multi-pane tabs.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use gtk4::prelude::*;
use serde_json::json;
use vte4::prelude::*;
use signaltty_core::{Decision, Lifecycle, LiveState, Pane, Theme};
use crate::actor::IpcHandle;
use crate::sidebar::agent_name;
use crate::status::{self, AttentionBadge, LifecycleIndicator};
use crate::task_chip::{TaskChipView, TaskChipWidget};
#[derive(Debug, Clone)]
pub enum PaneAction {
SplitRight,
SplitDown,
Close,
Resume,
AnswerDecision {
decision_id: String,
option_id: String,
},
}
/// What the inline decision bar shows for a pending decision.
/// Headless-tested seam: pure mapping, no widgets.
#[derive(Debug, PartialEq, Eq)]
pub struct DecisionRender {
pub prompt: String,
pub options: Vec<(String, String)>,
/// No proven answer channel: prompt + hint, no buttons.
pub read_only: bool,
}
/// Map a [`Decision`] to its bar contents. Prose-only attention never
/// reaches here (the server only sets structured decisions), so every
/// option listed is a real button.
pub fn decision_render(decision: &Decision) -> DecisionRender {
DecisionRender {
prompt: decision.prompt.clone(),
options: if decision.answerable {
decision
.options
.iter()
.map(|o| (o.id.clone(), o.label.clone()))
.collect()
} else {
Vec::new()
},
read_only: !decision.answerable,
}
}
/// The one suggested answer on a gate: allow once. Session-scoped allow
/// and deny stay secondary so the bar has a single primary.
fn decision_is_primary(id: &str, label: &str) -> bool {
let id = id.trim();
let label = label.trim();
id.eq_ignore_ascii_case("once")
|| label.eq_ignore_ascii_case("allow once")
|| label.eq_ignore_ascii_case("once")
}
const PCRE2_MULTILINE: u32 = 0x00000400;
/// The menu item's enabled flag is not the shortcut result: a disabled
/// `SimpleAction` reports the key as unhandled and VTE would deliver
/// Ctrl+C or Ctrl+V.
#[derive(Clone, Copy)]
enum TerminalCommand {
Copy,
Paste,
IncreaseTextSize,
DecreaseTextSize,
ResetTextSize,
}
const TEXT_TENTH_MIN: i32 = -5;
const TEXT_TENTH_MAX: i32 = 10;
fn text_factor(tenths: i32) -> f64 {
f64::from(10 + tenths) / 10.0
}
/// The keypad and synthetic key events may use shifted aliases.
fn terminal_chord(
key: gtk4::gdk::Key,
modifiers: gtk4::gdk::ModifierType,
) -> Option<TerminalCommand> {
if !modifiers.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|| !modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK)
|| modifiers.contains(gtk4::gdk::ModifierType::ALT_MASK)
|| modifiers.contains(gtk4::gdk::ModifierType::SUPER_MASK)
{
return None;
}
match key {
gtk4::gdk::Key::c | gtk4::gdk::Key::C => Some(TerminalCommand::Copy),
gtk4::gdk::Key::v | gtk4::gdk::Key::V => Some(TerminalCommand::Paste),
gtk4::gdk::Key::plus | gtk4::gdk::Key::equal | gtk4::gdk::Key::KP_Add => {
Some(TerminalCommand::IncreaseTextSize)
}
gtk4::gdk::Key::minus | gtk4::gdk::Key::underscore | gtk4::gdk::Key::KP_Subtract => {
Some(TerminalCommand::DecreaseTextSize)
}
gtk4::gdk::Key::_0 | gtk4::gdk::Key::parenright | gtk4::gdk::Key::KP_0 => {
Some(TerminalCommand::ResetTextSize)
}
_ => None,
}
}
type ActionCallback = Box<dyn Fn(&str, PaneAction)>;
pub struct PaneCallbacks {
pub on_focus: Box<dyn Fn(&str)>,
pub on_action: ActionCallback,
}
struct TerminalSearch {
root: gtk4::Box,
entry: gtk4::SearchEntry,
message: gtk4::Label,
compiled: RefCell<String>,
}
pub struct PaneWidget {
pub root: gtk4::Box,
term: vte4::Terminal,
search: TerminalSearch,
lifecycle: LifecycleIndicator,
title: gtk4::Label,
subtitle: gtk4::Label,
task_chip: TaskChipWidget,
badge: AttentionBadge,
resume: gtk4::Button,
decision_bar: gtk4::Box,
decision_prompt: gtk4::Label,
decision_options: libadwaita::WrapBox,
decision_hint: gtk4::Label,
shown_decision: RefCell<Option<Decision>>,
on_action: Rc<ActionCallback>,
pane_id: String,
actor: IpcHandle,
live: Cell<bool>,
paste_cancel: RefCell<gtk4::gio::Cancellable>,
last_size: Cell<(u16, u16)>,
text_tenths: Cell<i32>,
commands: gtk4::gio::SimpleActionGroup,
}
impl PaneWidget {
pub fn new(pane_id: &str, actor: IpcHandle, cb: PaneCallbacks) -> Rc<PaneWidget> {
let root = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
root.add_css_class("pane");
root.set_overflow(gtk4::Overflow::Hidden);
let lifecycle = LifecycleIndicator::new();
let title = gtk4::Label::new(None);
title.add_css_class("pane-title");
title.set_ellipsize(gtk4::pango::EllipsizeMode::End);
let subtitle = gtk4::Label::new(None);
subtitle.add_css_class("pane-subtitle");
subtitle.add_css_class("dimmed");
subtitle.set_ellipsize(gtk4::pango::EllipsizeMode::End);
subtitle.set_xalign(0.0);
subtitle.set_hexpand(false);
let badge = AttentionBadge::new();
let task_chip = TaskChipWidget::new();
let on_action = Rc::new(cb.on_action);
let action_button = |icon: &str, tooltip: &str, action: PaneAction| {
let b = gtk4::Button::from_icon_name(icon);
b.add_css_class("flat");
b.add_css_class("pane-tool-button");
b.set_tooltip_text(Some(tooltip));
b.update_property(&[gtk4::accessible::Property::Label(
tooltip.split(" (").next().unwrap_or(tooltip),
)]);
b.set_focus_on_click(false);
let on_action = Rc::clone(&on_action);
let pid = pane_id.to_string();
// Clone across the inner closure: the outer builder stays `Fn`.
let act = action.clone();
b.connect_clicked(move |_| on_action(&pid, act.clone()));
b
};
let actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
actions.add_css_class("pane-actions");
actions.append(&action_button(
"signaltty-split-right-symbolic",
"Split Right (Ctrl+Shift+E)",
PaneAction::SplitRight,
));
actions.append(&action_button(
"signaltty-split-down-symbolic",
"Split Down (Ctrl+Shift+O)",
PaneAction::SplitDown,
));
let close = action_button(
"window-close-symbolic",
"Close Pane (Ctrl+Shift+W)",
PaneAction::Close,
);
close.set_margin_start(6);
actions.append(&close);
let resume = gtk4::Button::with_label("Resume");
resume.add_css_class("suggested-action");
resume.add_css_class("resume-button");
resume.set_valign(gtk4::Align::Center);
resume.set_tooltip_text(Some("Restart this agent's session where it left off"));
resume.set_visible(false);
{
let on_action = Rc::clone(&on_action);
let pid = pane_id.to_string();
resume.connect_clicked(move |_| on_action(&pid, PaneAction::Resume));
}
// Title cluster (lifecycle, name, lifecycle word, task chip),
// then attention (never recedes) and actions (on hover/focus).
// No child of the cluster expands, so spare width stays ahead
// of the trailing controls. Those controls keep their space
// while hidden, and the chip stays with the title instead of
// floating in that reserved gap.
let info = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
info.add_css_class("pane-info");
info.set_hexpand(true);
info.append(&lifecycle.widget);
info.append(&title);
info.append(&subtitle);
info.append(&task_chip.root);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.add_css_class("pane-header");
header.append(&info);
header.append(&badge.widget);
header.append(&resume);
header.append(&actions);
let term = vte4::Terminal::new();
term.set_scrollback_lines(5000);
term.set_allow_hyperlink(true);
term.set_bold_is_bright(false);
// The card paints the background (and the padding around the
// grid), so terminals always match the surrounding theme.
term.set_clear_background(false);
term.set_vexpand(true);
term.set_hexpand(true);
let scroller = gtk4::ScrolledWindow::new();
scroller.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
scroller.set_child(Some(&term));
scroller.set_vexpand(true);
// Inline decision bar (directive 2): prompt + one button per
// option, built once and toggled. The VTE widget is never touched
// here, so the bar appearing never rebuilds the terminal.
let decision_prompt = gtk4::Label::new(None);
decision_prompt.add_css_class("decision-prompt");
decision_prompt.set_xalign(0.0);
decision_prompt.set_hexpand(true);
decision_prompt.set_wrap(true);
decision_prompt.set_wrap_mode(gtk4::pango::WrapMode::WordChar);
decision_prompt.set_selectable(true);
let decision_options = libadwaita::WrapBox::new();
decision_options.set_child_spacing(6);
decision_options.set_line_spacing(6);
decision_options.add_css_class("decision-options");
let decision_hint = gtk4::Label::new(Some("Answer in the terminal"));
decision_hint.add_css_class("decision-hint");
decision_hint.add_css_class("dimmed");
decision_hint.set_xalign(0.0);
let decision_bar = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
decision_bar.add_css_class("decision-bar");
decision_bar.append(&decision_prompt);
decision_bar.append(&decision_options);
decision_bar.append(&decision_hint);
decision_bar.set_visible(false);
root.append(&header);
root.append(&decision_bar);
let search_root = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
search_root.add_css_class("terminal-search");
let search_controls = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
search_root.set_margin_start(8);
search_root.set_margin_end(8);
let search_entry = gtk4::SearchEntry::new();
search_entry.set_placeholder_text(Some("Find in terminal"));
search_entry.set_hexpand(true);
search_entry.set_width_chars(8);
let search_message = gtk4::Label::new(None);
search_message.add_css_class("dimmed");
search_message.set_wrap(true);
search_message.set_xalign(0.0);
let previous = gtk4::Button::from_icon_name("go-up-symbolic");
previous.set_tooltip_text(Some("Previous match (Shift+Enter)"));
let next = gtk4::Button::from_icon_name("go-down-symbolic");
next.set_tooltip_text(Some("Next match (Enter)"));
let close_search = gtk4::Button::from_icon_name("window-close-symbolic");
close_search.set_tooltip_text(Some("Close search (Escape)"));
for button in [&previous, &next, &close_search] {
button.add_css_class("flat");
if let Some(label) = button.tooltip_text() {
button.update_property(&[gtk4::accessible::Property::Label(&label)]);
}
}
search_controls.append(&search_entry);
search_controls.append(&previous);
search_controls.append(&next);
search_controls.append(&close_search);
search_root.append(&search_controls);
search_root.append(&search_message);
search_root.set_visible(false);
root.append(&search_root);
root.append(&scroller);
let commands = gtk4::gio::SimpleActionGroup::new();
let w = Rc::new(PaneWidget {
root,
term,
search: TerminalSearch {
root: search_root,
entry: search_entry,
message: search_message,
compiled: RefCell::new(String::new()),
},
lifecycle,
title,
subtitle,
task_chip,
decision_bar,
decision_prompt,
decision_options,
decision_hint,
shown_decision: RefCell::new(None),
on_action: Rc::clone(&on_action),
badge,
resume,
pane_id: pane_id.to_string(),
actor,
live: Cell::new(true),
paste_cancel: RefCell::new(gtk4::gio::Cancellable::new()),
last_size: Cell::new((80, 24)),
text_tenths: Cell::new(0),
commands,
});
w.apply_style(Theme::Signal);
w.term.search_set_wrap_around(true);
let weak = Rc::downgrade(&w);
w.search.entry.connect_changed(move |_| {
if let Some(w) = weak.upgrade() {
if w.search.root.is_visible() {
w.set_search();
}
}
});
let weak = Rc::downgrade(&w);
next.connect_clicked(move |_| {
if let Some(w) = weak.upgrade() {
w.find(false);
}
});
let weak = Rc::downgrade(&w);
previous.connect_clicked(move |_| {
if let Some(w) = weak.upgrade() {
w.find(true);
}
});
let weak = Rc::downgrade(&w);
close_search.connect_clicked(move |_| {
if let Some(w) = weak.upgrade() {
w.close_search();
}
});
let weak = Rc::downgrade(&w);
w.search.entry.connect_stop_search(move |_| {
if let Some(w) = weak.upgrade() {
w.close_search();
}
});
let keys = gtk4::EventControllerKey::new();
let weak = Rc::downgrade(&w);
keys.connect_key_pressed(move |_, key, _, modifiers| {
let Some(w) = weak.upgrade() else {
return gtk4::glib::Propagation::Proceed;
};
if key == gtk4::gdk::Key::Return || key == gtk4::gdk::Key::KP_Enter {
w.find(modifiers.contains(gtk4::gdk::ModifierType::SHIFT_MASK));
gtk4::glib::Propagation::Stop
} else if key == gtk4::gdk::Key::Escape {
w.close_search();
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
w.search.entry.add_controller(keys);
// Input: VTE translates keys to bytes; forward to the server PTY.
// connect_local keeps the GValue string pointer. The generated
// connect_commit converts that pointer to UTF-8 and drops a NUL.
{
let weak = Rc::downgrade(&w);
w.term.connect_local("commit", false, move |values| {
let w = weak.upgrade()?;
let bytes = Self::commit_bytes(values)?;
if bytes.is_empty() || !w.live.get() {
return None;
}
w.send_input(&bytes);
None
});
}
w.term.insert_action_group("term", Some(&w.commands));
{
for (name, command, enabled) in [
("copy", TerminalCommand::Copy, false),
("paste", TerminalCommand::Paste, true),
(
"increase-text-size",
TerminalCommand::IncreaseTextSize,
true,
),
(
"decrease-text-size",
TerminalCommand::DecreaseTextSize,
true,
),
("reset-text-size", TerminalCommand::ResetTextSize, false),
] {
let action = gtk4::gio::SimpleAction::new(name, None);
action.set_enabled(enabled);
let weak = Rc::downgrade(&w);
action.connect_activate(move |_, _| {
if let Some(w) = weak.upgrade() {
w.run_command(command);
}
});
w.commands.add_action(&action);
}
let weak = Rc::downgrade(&w);
w.term.connect_selection_changed(move |term| {
let Some(w) = weak.upgrade() else {
return;
};
w.set_command_enabled("copy", term.has_selection());
});
let weak = Rc::downgrade(&w);
w.term.connect_increase_font_size(move |_| {
if let Some(w) = weak.upgrade() {
w.run_command(TerminalCommand::IncreaseTextSize);
}
});
let weak = Rc::downgrade(&w);
w.term.connect_decrease_font_size(move |_| {
if let Some(w) = weak.upgrade() {
w.run_command(TerminalCommand::DecreaseTextSize);
}
});
let menu = gtk4::gio::Menu::new();
menu.append(Some("Copy"), Some("term.copy"));
menu.append(Some("Paste"), Some("term.paste"));
menu.append(Some("Increase Text Size"), Some("term.increase-text-size"));
menu.append(Some("Decrease Text Size"), Some("term.decrease-text-size"));
menu.append(Some("Reset Text Size"), Some("term.reset-text-size"));
w.term.set_context_menu_model(Some(&menu));
// Ancestor capture runs before VTE's own key controller.
// Only the focused terminal claims the chord, so search
// keeps the keys it already handles.
let keys = gtk4::EventControllerKey::new();
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
let weak = Rc::downgrade(&w);
keys.connect_key_pressed(move |controller, key, keycode, modifiers| {
let unshifted = controller.current_event().and_then(|event| {
let event = event.downcast::<gtk4::gdk::KeyEvent>().ok()?;
event
.display()?
.translate_key(
keycode,
modifiers & !gtk4::gdk::ModifierType::SHIFT_MASK,
event.layout() as i32,
)
.map(|(key, _, _, _)| key)
});
let Some(command) = unshifted
.and_then(|key| terminal_chord(key, modifiers))
.or_else(|| terminal_chord(key, modifiers))
else {
return gtk4::glib::Propagation::Proceed;
};
let Some(w) = weak.upgrade() else {
return gtk4::glib::Propagation::Stop;
};
if !w.term.has_focus() {
return gtk4::glib::Propagation::Proceed;
}
w.run_command(command);
gtk4::glib::Propagation::Stop
});
w.root.add_controller(keys);
}
// Focus = explicit per-pane interaction: clear attention.
{
let weak = Rc::downgrade(&w);
let on_focus = cb.on_focus;
w.term.connect_has_focus_notify(move |term| {
let Some(w) = weak.upgrade() else { return };
if term.has_focus() {
w.send("pane.mark_seen", json!({"pane_id": w.pane_id}));
on_focus(&w.pane_id);
}
});
}
// Clicking the header focuses the pane too.
{
let click = gtk4::GestureClick::new();
let term = w.term.clone();
click.connect_pressed(move |_, _, _, _| {
term.grab_focus();
});
header.add_controller(click);
}
// Snapshots and live bytes arrive through the same ordered UI queue.
w.actor.attach(pane_id, 80, 24);
w
}
pub fn present_search(&self) {
self.search.root.set_visible(true);
self.search.entry.grab_focus();
}
pub fn close_search(&self) {
self.search.root.set_visible(false);
self.term.search_set_regex(None, 0);
self.search.compiled.borrow_mut().clear();
self.term.unselect_all();
self.focus();
}
fn set_search(&self) -> bool {
let text = self.search.entry.text();
if !text.is_empty() && self.search.compiled.borrow().as_str() == text.as_str() {
return true;
}
if text.is_empty() {
self.term.search_set_regex(None, 0);
self.search.message.set_text("");
self.search.compiled.borrow_mut().clear();
return false;
}
let mut pattern = String::new();
for ch in text.chars() {
if "\\.^$|?*+()[]{}".contains(ch) {
pattern.push('\\');
}
pattern.push(ch);
}
match vte4::Regex::for_search(&pattern, PCRE2_MULTILINE) {
Ok(regex) => {
self.term.search_set_regex(Some(®ex), 0);
*self.search.compiled.borrow_mut() = text.to_string();
true
}
Err(_) => {
self.term.search_set_regex(None, 0);
self.search.message.set_text("Invalid search");
false
}
}
}
pub fn find(&self, previous: bool) -> bool {
if !self.set_search() {
return false;
}
let found = if previous {
self.term.search_find_previous()
} else {
self.term.search_find_next()
};
self.search
.message
.set_text(if found { "" } else { "No matches" });
found
}
/// Font + palette from the desktop; re-run when either changes.
pub fn apply_style(&self, theme: Theme) {
let sm = libadwaita::StyleManager::default();
let name = sm.monospace_font_name();
let font = gtk4::pango::FontDescription::from_string(if name.is_empty() {
"Monospace 11"
} else {
name.as_str()
});
self.term.set_font(Some(&font));
self.term.set_cell_height_scale(CELL_HEIGHT_SCALE);
let fg_hex = if sm.is_dark() {
DARK_FOREGROUND
} else {
LIGHT_FOREGROUND
};
let rgba = |hex: &str| gtk4::gdk::RGBA::parse(hex).expect("palette colour");
let inks = if sm.is_dark() {
&DARK_PALETTE
} else {
&LIGHT_PALETTE
};
let palette: Vec<gtk4::gdk::RGBA> = inks.iter().map(|c| rgba(c)).collect();
let palette: Vec<>k4::gdk::RGBA> = palette.iter().collect();
self.term.set_colors(
Some(&rgba(fg_hex)),
Some(&rgba(theme.pane_bg(sm.is_dark()))),
&palette,
);
self.apply_text_scale();
}
fn apply_text_scale(&self) {
self.term
.set_font_scale(text_factor(self.text_tenths.get()));
}
fn set_text_tenths(&self, tenths: i32) {
let tenths = tenths.clamp(TEXT_TENTH_MIN, TEXT_TENTH_MAX);
self.text_tenths.set(tenths);
self.apply_text_scale();
self.set_command_enabled("increase-text-size", tenths < TEXT_TENTH_MAX);
self.set_command_enabled("decrease-text-size", tenths > TEXT_TENTH_MIN);
self.set_command_enabled("reset-text-size", tenths != 0);
}
/// Accepted prefix of a VTE `commit` emission.
///
/// The signal is a `G_TYPE_STRING` plus a declared byte count. On a
/// realized widget the native marshaller can keep that count after
/// copying only the bytes before an embedded NUL. The prefix is the
/// declared count when it still fits in the C string, including the
/// terminator so Ctrl+Space (size 1, empty text) is the single NUL.
/// A count past that storage is refused whole.
fn commit_bytes(values: &[gtk4::glib::Value]) -> Option<Vec<u8>> {
let declared = values.get(2)?.get::<u32>().ok()? as usize;
let value = values.get(1)?;
if value.type_() != gtk4::glib::Type::STRING {
return None;
}
// SAFETY: a string GValue holds a NUL-terminated pointer valid for
// this emission. The read stops at that terminator and is copied
// before the callback returns.
let available = unsafe {
use gtk4::glib::translate::ToGlibPtr;
let ptr = gtk4::glib::gobject_ffi::g_value_get_string(value.to_glib_none().0);
if ptr.is_null() {
Vec::new()
} else {
std::ffi::CStr::from_ptr(ptr).to_bytes_with_nul().to_vec()
}
};
if declared > available.len() {
None
} else {
Some(available[..declared].to_vec())
}
}
fn set_command_enabled(&self, name: &str, enabled: bool) {
if let Some(action) = self
.commands
.lookup_action(name)
.and_then(|action| action.downcast::<gtk4::gio::SimpleAction>().ok())
{
action.set_enabled(enabled);
}
}
fn run_command(self: &Rc<Self>, command: TerminalCommand) {
match command {
TerminalCommand::Copy => {
if self.term.has_selection() {
self.term.copy_clipboard_format(vte4::Format::Text);
}
}
TerminalCommand::Paste => self.paste_from_clipboard(),
TerminalCommand::IncreaseTextSize => {
self.set_text_tenths(self.text_tenths.get() + 1);
}
TerminalCommand::DecreaseTextSize => {
self.set_text_tenths(self.text_tenths.get() - 1);
}
TerminalCommand::ResetTextSize => self.set_text_tenths(0),
}
}
/// The desktop clipboard answers after this call returns. A resume
/// keeps the widget, so the reply must not paste into the next process.
fn paste_from_clipboard(self: &Rc<Self>) {
if !self.live.get() {
return;
}
let cancel = self.paste_cancel.borrow().clone();
let weak = Rc::downgrade(self);
self.term
.clipboard()
.read_text_async(Some(&cancel.clone()), move |result| {
let Some(pane) = weak.upgrade() else {
return;
};
if cancel.is_cancelled() || !pane.live.get() {
return;
}
let Ok(Some(text)) = result else {
return;
};
if text.is_empty() {
return;
}
pane.term.paste_text(text.as_str());
});
}
fn send_input(&self, bytes: &[u8]) {
use base64::Engine;
self.send(
"pane.input",
json!({
"pane_id": self.pane_id,
"data_b64": base64::engine::general_purpose::STANDARD.encode(bytes),
}),
);
}
fn send(&self, method: &'static str, params: serde_json::Value) {
let actor = self.actor.clone();
gtk4::glib::spawn_future_local(async move {
let _ = actor.call(method, params).await;
});
}
/// Replace the visible grid with one server snapshot and keep scrollback.
///
/// The snapshot has no alternate-screen identity. Leave that grid, clear
/// origin and margins, erase each visible row, then soft-reset with
/// DECSTR before the snapshot. VTE 0.80.1 `reset` would either drop this
/// history or archive the previous visible rows. One feed keeps the
/// sequence ordered without spinning the main loop.
pub fn replace_screen(&self, data: &[u8]) {
let rows = self.term.row_count().max(0) as usize;
let mut batch = Vec::with_capacity(data.len() + 32 + rows * 16);
batch.extend_from_slice(b"\x1b[?1049l\x1b[?6l\x1b[r");
for row in 1..=rows {
batch.extend_from_slice(b"\x1b[");
batch.extend_from_slice(row.to_string().as_bytes());
batch.extend_from_slice(b";1H\x1b[2K");
}
batch.extend_from_slice(b"\x1b[!p");
batch.extend_from_slice(data);
self.term.feed(&batch);
}
pub fn feed(&self, data: &[u8]) {
self.term.feed(data);
}
pub fn focus(&self) {
self.term.grab_focus();
}
/// Mark this pane as the target of pane actions.
pub fn set_focused(&self, focused: bool) {
if focused {
self.root.add_css_class("focused");
} else {
self.root.remove_css_class("focused");
}
}
pub fn detach(&self) {
self.actor.detach(&self.pane_id);
}
/// Report widget-driven size when it changed (called on a 250ms tick;
/// gtk4 0.11 has no size-allocate signal). Last-writer-wins.
pub fn sync_size(&self) {
if !self.root.is_mapped() {
return;
}
let cols = self.term.column_count().clamp(20, 500) as u16;
let rows = self.term.row_count().clamp(5, 200) as u16;
if self.last_size.get() == (cols, rows) {
return;
}
self.last_size.set((cols, rows));
if self.live.get() {
self.send(
"pane.resize",
json!({"pane_id": self.pane_id, "cols": cols, "rows": rows}),
);
}
}
/// Refresh header + ring from fresh pane state.
pub fn update_meta(&self, pane: &Pane) {
let live = matches!(pane.live, LiveState::Live);
self.title
.set_text(display_title(&pane.title, >k4::glib::host_name()));
self.title.set_tooltip_text(Some(&pane.cwd));
let mut context: Vec<String> = Vec::new();
if let Some(agent) = agent_name(pane.agent.kind) {
if !agent.eq_ignore_ascii_case(pane.title.trim()) {
context.push(agent.to_string());
}
}
match pane.live {
LiveState::Exited { code: Some(code) } if code != 0 => {
context.push(format!("Exited ({code})"))
}
LiveState::Exited { .. } => context.push("Exited".to_string()),
LiveState::Live if pane.lifecycle != Lifecycle::Unknown => {
context.push(status::lifecycle_label(pane.lifecycle).to_string())
}
LiveState::Live => {}
}
self.subtitle.set_text(&context.join(" · "));
self.prefer_chip_ellipsize();
self.lifecycle.set(status::effective_lifecycle(pane));
self.badge.set(pane.attention);
status::set_attention_class(&self.root, pane.attention);
let was_live = self.live.replace(live);
if was_live && !live {
self.paste_cancel
.replace(gtk4::gio::Cancellable::new())
.cancel();
self.term.feed(b"\r\n\x1b[2m[process exited]\x1b[0m\r\n");
}
// Resume affordance for restored/resumable tombstones.
self.resume
.set_visible(!live && pane.agent.resume_argv.is_some());
self.set_command_enabled("paste", live);
self.sync_decision_bar(pane.pending_decision.as_ref());
}
/// Paint the worker chip in place. `None` hides it. Never touches the VTE.
pub fn set_task_chip(&self, view: Option<&TaskChipView>) {
self.task_chip.set(view);
self.prefer_chip_ellipsize();
}
/// A short title keeps its full width while a chip is showing, so
/// the chip's label is what ellipsizes in a narrow pane. Long
/// titles still ellipsize. Hiding the chip restores the usual title.
fn prefer_chip_ellipsize(&self) {
let short_title = self.title.text().chars().count() <= 24;
let protect = self.task_chip.root.is_visible() && short_title;
self.title.set_ellipsize(if protect {
gtk4::pango::EllipsizeMode::None
} else {
gtk4::pango::EllipsizeMode::End
});
}
/// Toggle the inline decision bar. Buttons rebuild only when the
/// decision id, answerability or options change; showing/hiding
/// never touches the VTE.
fn sync_decision_bar(&self, decision: Option<&Decision>) {
match decision {
None => {
self.decision_bar.set_visible(false);
*self.shown_decision.borrow_mut() = None;
}
Some(d) => {
let rendered = decision_render(d);
self.decision_prompt.set_text(&rendered.prompt);
self.decision_prompt
.set_tooltip_text(Some(&rendered.prompt));
self.decision_hint.set_visible(rendered.read_only);
let rebuild = match self.shown_decision.borrow().as_ref() {
Some(shown) => {
shown.id != d.id
|| shown.answerable != d.answerable
|| shown.options != d.options
}
None => true,
};
if rebuild {
while let Some(child) = self.decision_options.first_child() {
self.decision_options.remove(&child);
}
let mut primary_used = false;
for (option_id, label) in &rendered.options {
let text = gtk4::Label::new(Some(label));
text.set_wrap(true);
text.set_wrap_mode(gtk4::pango::WrapMode::WordChar);
text.set_xalign(0.0);
let button = gtk4::Button::new();
button.set_child(Some(&text));
button.add_css_class("pill");
if !primary_used && decision_is_primary(option_id, label) {
button.add_css_class("suggested-action");
primary_used = true;
}
button.update_property(&[gtk4::accessible::Property::Label(label)]);
button.set_tooltip_text(Some(label));
button.set_focus_on_click(false);
let on_action = Rc::clone(&self.on_action);
let pane_id = self.pane_id.clone();
let decision_id = d.id.clone();
let option_id = option_id.clone();
button.connect_clicked(move |_| {
on_action(
&pane_id,
PaneAction::AnswerDecision {
decision_id: decision_id.clone(),
option_id: option_id.clone(),
},
);
});
self.decision_options.append(&button);
}
*self.shown_decision.borrow_mut() = Some(d.clone());
}
self.decision_bar.set_visible(true);
}
}
}
}
impl Drop for PaneWidget {
fn drop(&mut self) {
self.paste_cancel.get_mut().cancel();
}
}
const LIGHT_FOREGROUND: &str = "#241f31";
const DARK_FOREGROUND: &str = "#deddda";
/// GNOME palette (as in Console/Ptyxis) on dark panes.
const DARK_PALETTE: [&str; 16] = [
"#241f31", "#c01c28", "#2ec27e", "#f5c211", "#1e78e4", "#9841bb", "#0ab9dc", "#c0bfbc",
"#5e5c64", "#ed333b", "#57e389", "#f8e45c", "#51a1ff", "#c061cb", "#4fd2fd", "#f6f5f4",
];
/// Light panes need their own inks: GNOME's yellow and greens are made for
/// dark backgrounds and vanish on light panes (`git log` hashes, prompts).
/// Every ink, including grey output, reads at 4.5:1 on all light theme backgrounds.
const LIGHT_PALETTE: [&str; 16] = [
"#24292f", "#cf222e", "#116329", "#7d4e00", "#0969da", "#8250df", "#1b7c83", "#656e78",
"#57606a", "#a40e26", "#1a7f37", "#9a6700", "#196fce", "#805ac6", "#257a8e", "#69737d",
];
/// Leading for the terminal grid: VTE's default packs rows edge to edge.
const CELL_HEIGHT_SCALE: f64 = 1.1;
/// Window titles from shells read `user@host: ~/path`; the header already
/// says where the pane is, so a local `user@host:` prefix is noise. A
/// different host (an SSH session) is information and stays.
fn display_title<'a>(title: &'a str, local_host: &str) -> &'a str {
let trimmed = title.trim();
let short = |host: &str| host.split('.').next().unwrap_or(host).to_ascii_lowercase();
// `\u@\h: \w` and the space-less `\u@\h:\w` (only before a path).
let parts = trimmed.split_once(": ").or_else(|| {
trimmed
.split_once(':')
.filter(|(_, rest)| rest.starts_with(['~', '/']))
});
match parts {
Some((who, rest))
if !rest.is_empty()
&& !who.contains(char::is_whitespace)
&& who.split_once('@').is_some_and(|(user, host)| {
!user.is_empty()
&& !host.is_empty()
&& (host.eq_ignore_ascii_case(local_host)
|| ((!host.contains('.') || !local_host.contains('.'))
&& short(host) == short(local_host)))
}) =>
{
rest
}
_ => title,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_titles_drop_the_user_at_host_prefix() {
let t = |title| display_title(title, "luxhole");
assert_eq!(t("simone@luxhole: ~/code/app"), "~/code/app");
assert_eq!(t("root@LuxHole.local: /"), "/", "short, case-blind host");
assert_eq!(
t("deploy@prod-1: /srv"),
"deploy@prod-1: /srv",
"a remote host is kept"
);
assert_eq!(t("claude"), "claude");
assert_eq!(t("vim: main.rs"), "vim: main.rs", "no @, kept");
assert_eq!(t("Note: a@b c"), "Note: a@b c", "@ after the colon");
assert_eq!(
t("a b@luxhole: x"),
"a b@luxhole: x",
"spaces before the colon"
);
assert_eq!(t("me@luxhole: "), "me@luxhole: ", "nothing after it");
assert_eq!(t("me@luxhole:~/src"), "~/src", "no space before a path");
assert_eq!(t("me@luxhole:x"), "me@luxhole:x", "no space, not a path");
assert_eq!(t(" vim: main.rs "), " vim: main.rs ");
assert_eq!(
display_title(
"deploy@build.production.example: /srv",
"build.office.example"
),
"deploy@build.production.example: /srv",
"distinct qualified hosts keep their identity"
);
assert_eq!(
display_title("me@BUILD.office.example: /src", "build.office.example"),
"/src"
);
assert_eq!(
display_title("me@build: /src", "build.office.example"),
"/src"
);
}
/// WCAG relative luminance for an opaque sRGB colour.
fn luminance(hex: &str) -> f64 {
let channel = |i: usize| {
let v = f64::from(u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)
}
#[test]
fn light_palette_inks_read_on_every_theme() {
for theme in Theme::ALL {
let background = luminance(theme.pane_bg(false));
for (i, hex) in LIGHT_PALETTE.iter().enumerate() {
let ratio = (background + 0.05) / (luminance(hex) + 0.05);
assert!(
ratio >= 4.5,
"{theme:?} colour {i} {hex}: {ratio:.2}:1 < 4.5"
);
}
}
}
use signaltty_core::DecisionOption;
fn decision(answerable: bool) -> Decision {
Decision {
id: "d1".into(),
prompt: "Allow rm -rf /tmp/x?".into(),
options: vec![
DecisionOption {
id: "once".into(),
label: "Once".into(),
},
DecisionOption {
id: "always".into(),
label: "Always".into(),
},
DecisionOption {
id: "deny".into(),
label: "Deny".into(),
},
],
answerable,
received_at: chrono::Utc::now(),
}
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn dropped_pane_widget_is_freed() {
libadwaita::init().unwrap();
let (actor, _requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"freed",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let weak = Rc::downgrade(&pane);
drop(pane);
assert!(
weak.upgrade().is_none(),
"terminal signal handlers keep the pane alive"
);
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn terminal_search_is_literal_and_keeps_the_terminal() {
libadwaita::init().unwrap();
let (actor, _requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"search",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let term = pane.term.clone();
let window = gtk4::Window::new();
window.set_child(Some(&pane.root));
window.present();
pane.feed(b"alpha [literal].* omega\r\nsecond [literal].*\r\n");
for _ in 0..20 {
while gtk4::glib::MainContext::default().iteration(false) {}
}
pane.present_search();
pane.search.entry.set_text("[literal].*");
assert!(pane.find(false));
assert!(pane.term.has_selection());
pane.search.entry.set_text("absent needle");
assert!(!pane.find(false));
assert_eq!(pane.search.message.text(), "No matches");
pane.close_search();
assert!(!pane.search.root.is_visible());
pane.search.entry.set_text("hidden query");
assert!(pane.term.search_get_regex().is_none());
assert_eq!(pane.term, term);
window.destroy();
}
#[test]
fn render_lists_every_option_as_a_button() {
let r = decision_render(&decision(true));
assert_eq!(r.prompt, "Allow rm -rf /tmp/x?");
assert_eq!(
r.options,
vec![
("once".to_string(), "Once".to_string()),
("always".to_string(), "Always".to_string()),
("deny".to_string(), "Deny".to_string()),
]
);
assert!(!r.read_only);
}
#[test]
fn allow_once_is_the_only_primary_answer() {
assert!(decision_is_primary("once", "Allow once"));
assert!(decision_is_primary("once", "Once"));
assert!(decision_is_primary("other", "Allow once"));
assert!(!decision_is_primary("always", "Allow for this session"));
assert!(!decision_is_primary("session", "Allow for this session"));
assert!(!decision_is_primary("deny", "Deny"));
}
#[test]
fn render_without_channel_is_read_only_with_no_buttons() {
let r = decision_render(&decision(false));
assert_eq!(r.prompt, "Allow rm -rf /tmp/x?");
assert!(r.options.is_empty(), "never fake buttons");
assert!(r.read_only);
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn approval_question_and_choices_fit_a_narrow_pane() {
libadwaita::init().unwrap();
gtk4::gio::resources_register_include!("signaltty-gui.gresource").unwrap();
let display = gtk4::gdk::Display::default().unwrap();
let provider = gtk4::CssProvider::new();
provider.load_from_resource("/dev/signaltty/gui/style.css");
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
let (actor, _requests) = IpcHandle::test_channel();
let selected = Rc::new(RefCell::new(None));
let selection = Rc::clone(&selected);
let widget = PaneWidget::new(
"pane-narrow",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(move |pane, action| {
*selection.borrow_mut() = Some((pane.to_string(), action));
}),
},
);
let mut approval = decision(true);
approval.prompt = "Run the workspace verification suite and inspect the native interface in light and dark themes?".into();
approval.options[0].label = "Allow once".into();
approval.options[1].label =
"Allow this verification command for the current workspace session".into();
let mut pane = Pane::new(
"workspace".into(),
"tab".into(),
"/tmp".into(),
vec!["codex".into()],
Default::default(),
chrono::Utc::now(),
);
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
let minimum = widget.root.measure(gtk4::Orientation::Horizontal, -1).0;
assert!(minimum <= 320, "approval forces a {minimum}px pane");
let window = gtk4::Window::new();
window.set_default_size(320, 520);
window.set_child(Some(&widget.root));
window.present();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while widget.root.width() == 0 {
assert!(std::time::Instant::now() < deadline);
gtk4::glib::MainContext::default().iteration(true);
}
assert!(widget.root.width() <= 320);
assert!(!widget.decision_prompt.layout().is_ellipsized());
assert!(widget.decision_prompt.layout().line_count() > 1);
let mut child = widget.decision_options.first_child();
let mut choices = Vec::new();
while let Some(option) = child {
let button = option.clone().downcast::<gtk4::Button>().unwrap();
let bounds = button.compute_bounds(&widget.root).unwrap();
assert!(bounds.x() >= 0.0 && bounds.x() + bounds.width() <= 320.0);
let label = button.child().unwrap().downcast::<gtk4::Label>().unwrap();
assert!(!label.layout().is_ellipsized());
choices.push(button);
child = option.next_sibling();
}
assert_eq!(choices.len(), 3);
choices[1].emit_clicked();
assert!(matches!(
selected.borrow().as_ref(),
Some((id, PaneAction::AnswerDecision { decision_id, option_id }))
if id == "pane-narrow" && decision_id == "d1" && option_id == "always"
));
let terminal = widget.term.clone();
approval.prompt = "Updated approval context".into();
approval.received_at = chrono::Utc::now();
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
assert_eq!(
widget.decision_options.first_child().unwrap(),
choices[0].clone().upcast::<gtk4::Widget>(),
"prompt and timestamp changes must retain the choice widgets"
);
approval.answerable = false;
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
assert!(widget.decision_hint.is_visible());
assert!(
widget.decision_options.first_child().is_none(),
"the same decision ID becoming read-only must remove stale choices"
);
approval.answerable = true;
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
assert!(!widget.decision_hint.is_visible());
assert!(widget.decision_options.first_child().is_some());
approval.options[0].label = "Allow current request".into();
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
let renamed = widget
.decision_options
.first_child()
.unwrap()
.downcast::<gtk4::Button>()
.unwrap();
assert_eq!(
renamed
.child()
.unwrap()
.downcast::<gtk4::Label>()
.unwrap()
.text(),
"Allow current request",
"a label-only update under the same decision ID must refresh choices"
);
approval.options[0].id = "allow_current".into();
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
let current = widget
.decision_options
.first_child()
.unwrap()
.downcast::<gtk4::Button>()
.unwrap();
assert_eq!(
current
.child()
.unwrap()
.downcast::<gtk4::Label>()
.unwrap()
.text(),
"Allow current request"
);
current.emit_clicked();
assert!(matches!(
selected.borrow().as_ref(),
Some((id, PaneAction::AnswerDecision { decision_id, option_id }))
if id == "pane-narrow" && decision_id == "d1" && option_id == "allow_current"
));
assert_eq!(widget.term, terminal);
let capture = |name: &str| {
let Some(directory) = std::env::var_os("SIGNALTTY_UI_EVIDENCE") else {
return;
};
let directory = std::path::PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
while std::time::Instant::now() < deadline {
while gtk4::glib::MainContext::default().iteration(false) {}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let paintable = gtk4::WidgetPaintable::new(Some(&window));
let snapshot = gtk4::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
let renderer = gtk4::gsk::CairoRenderer::new();
renderer.realize_for_display(&display).unwrap();
let texture = renderer.render_texture(snapshot.to_node().unwrap(), None);
renderer.unrealize();
texture
.save_to_png(directory.join(format!("{name}.png")))
.unwrap();
};
let style = libadwaita::StyleManager::default();
let old_scheme = style.color_scheme();
for (name, scheme) in [
("light", libadwaita::ColorScheme::ForceLight),
("dark", libadwaita::ColorScheme::ForceDark),
] {
style.set_color_scheme(scheme);
if scheme == libadwaita::ColorScheme::ForceDark {
window.add_css_class("dark");
} else {
window.remove_css_class("dark");
}
approval.answerable = true;
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
capture(&format!("approval-current-{name}"));
approval.options[0].id = "once".into();
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
capture(&format!("approval-primary-{name}"));
approval.options[0].id = "allow_current".into();
approval.answerable = false;
pane.pending_decision = Some(approval.clone());
widget.update_meta(&pane);
assert!(widget.decision_options.first_child().is_none());
capture(&format!("approval-read-only-{name}"));
assert_eq!(widget.term, terminal);
}
style.set_color_scheme(old_scheme);
pane.pending_decision = None;
widget.update_meta(&pane);
assert_eq!(
widget.term, terminal,
"answering must retain the mounted VTE"
);
window.close();
gtk4::style_context_remove_provider_for_display(&display, &provider);
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn keyboard_focus_renders_an_inactive_panes_controls() {
libadwaita::init().unwrap();
gtk4::gio::resources_register_include!("signaltty-gui.gresource").unwrap();
let display = gtk4::gdk::Display::default().unwrap();
let provider = gtk4::CssProvider::new();
provider.load_from_resource("/dev/signaltty/gui/style.css");
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
let settings = gtk4::Settings::default().unwrap();
settings.set_gtk_enable_animations(false);
let (actor, _requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"inactive",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let window = gtk4::Window::new();
window.set_default_size(420, 180);
window.set_child(Some(&pane.root));
window.present();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while pane.root.width() == 0 {
assert!(std::time::Instant::now() < deadline);
gtk4::glib::MainContext::default().iteration(true);
}
gtk4::prelude::GtkWindowExt::set_focus(&window, Some(&pane.term));
pane.root.unset_state_flags(gtk4::StateFlags::PRELIGHT);
let header = pane.root.first_child().unwrap();
let actions = header.last_child().unwrap();
let control = actions.first_child().unwrap();
let split_down = control.next_sibling().unwrap();
let close = actions.last_child().unwrap();
let split_bounds = split_down.compute_bounds(&pane.root).unwrap();
let close_bounds = close.compute_bounds(&pane.root).unwrap();
assert!(
close_bounds.x() - split_bounds.x() - split_bounds.width() >= 8.0,
"close must be visually separated from the split pair"
);
assert!(close.width() >= 28 && close.height() >= 28);
let bounds = actions.compute_bounds(&pane.root).unwrap();
let pixels = || {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(60);
while std::time::Instant::now() < deadline {
while gtk4::glib::MainContext::default().iteration(false) {}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let paintable = gtk4::WidgetPaintable::new(Some(&pane.root));
let snapshot = gtk4::Snapshot::new();
paintable.snapshot(
&snapshot,
pane.root.width() as f64,
pane.root.height() as f64,
);
let node = snapshot.to_node().unwrap();
let renderer = gtk4::gsk::CairoRenderer::new();
renderer.realize_for_display(&display).unwrap();
let texture = renderer.render_texture(&node, Some(&bounds));
renderer.unrealize();
let stride = texture.width() as usize * 4;
let mut bytes = vec![0; stride * texture.height() as usize];
texture.download(&mut bytes, stride);
bytes
};
let hidden = pixels();
window.set_focus_visible(true);
assert!(control.grab_focus());
pane.root.unset_state_flags(gtk4::StateFlags::PRELIGHT);
let focused = pixels();
assert!(
hidden != focused,
"keyboard-focused controls remain invisible"
);
window.close();
gtk4::style_context_remove_provider_for_display(&display, &provider);
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn reduced_motion_removes_rendered_press_scaling() {
libadwaita::init().unwrap();
gtk4::gio::resources_register_include!("signaltty-gui.gresource").unwrap();
let display = gtk4::gdk::Display::default().unwrap();
let provider = gtk4::CssProvider::new();
provider.load_from_resource("/dev/signaltty/gui/style.css");
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
gtk4::Settings::default()
.unwrap()
.set_gtk_enable_animations(false);
let root = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
let button = gtk4::Button::with_label("Allow once");
button.add_css_class("pill");
button.set_focusable(false);
root.append(&button);
let window = gtk4::Window::new();
window.set_child(Some(&root));
window.present();
button.set_state_flags(gtk4::StateFlags::ACTIVE, false);
fn scaled(node: >k4::gsk::RenderNode) -> bool {
use gtk4::gsk;
if let Some(n) = node.downcast_ref::<gsk::TransformNode>() {
let (xx, _, _, yy, _, _) = n.transform().to_2d();
return (xx - 0.97).abs() < 0.001 && (yy - 0.97).abs() < 0.001
|| scaled(&n.child());
}
if let Some(n) = node.downcast_ref::<gsk::ContainerNode>() {
return (0..n.n_children()).any(|i| scaled(&n.child(i)));
}
if let Some(n) = node.downcast_ref::<gsk::ClipNode>() {
return scaled(&n.child());
}
if let Some(n) = node.downcast_ref::<gsk::RoundedClipNode>() {
return scaled(&n.child());
}
if let Some(n) = node.downcast_ref::<gsk::OpacityNode>() {
return scaled(&n.child());
}
false
}
let snapshot = || {
let end = std::time::Instant::now() + std::time::Duration::from_millis(80);
while std::time::Instant::now() < end {
while gtk4::glib::MainContext::default().pending() {
gtk4::glib::MainContext::default().iteration(false);
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let paintable = gtk4::WidgetPaintable::new(Some(&root));
let snapshot = gtk4::Snapshot::new();
paintable.snapshot(&snapshot, root.width() as f64, root.height() as f64);
snapshot.to_node().unwrap()
};
assert!(
scaled(&snapshot()),
"probe must observe normal press scaling"
);
root.add_css_class("reduced-motion");
assert!(
!scaled(&snapshot()),
"reduced motion still scales the pressed button"
);
root.remove_css_class("reduced-motion");
assert!(
scaled(&snapshot()),
"normal feedback must return immediately"
);
button.set_focusable(true);
window.set_focus_visible(true);
assert!(button.grab_focus());
assert!(!scaled(&snapshot()), "keyboard activation must not scale");
window.close();
gtk4::style_context_remove_provider_for_display(&display, &provider);
}
fn pump(ms: u64) {
let end = std::time::Instant::now() + std::time::Duration::from_millis(ms);
while std::time::Instant::now() < end {
while gtk4::glib::MainContext::default().pending() {
gtk4::glib::MainContext::default().iteration(false);
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
fn pump_until(mut done: impl FnMut() -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while !done() {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for the terminal"
);
while gtk4::glib::MainContext::default().pending() {
gtk4::glib::MainContext::default().iteration(false);
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
fn present_pane(pane: &PaneWidget) -> gtk4::Window {
let window = gtk4::Window::new();
window.set_default_size(720, 420);
window.set_child(Some(&pane.root));
window.present();
pump_until(|| pane.term.is_realized() && pane.term.row_count() > 0);
pane.focus();
window
}
fn take_inputs(
requests: &mut tokio::sync::mpsc::UnboundedReceiver<crate::actor::ActorRequest>,
wait_for_payload: bool,
) -> Vec<Vec<u8>> {
use base64::Engine;
let mut inputs = Vec::new();
let deadline = std::time::Instant::now()
+ if wait_for_payload {
std::time::Duration::from_secs(1)
} else {
std::time::Duration::from_millis(250)
};
let mut quiet = std::time::Instant::now();
while std::time::Instant::now() < deadline {
while gtk4::glib::MainContext::default().pending() {
gtk4::glib::MainContext::default().iteration(false);
}
let mut saw = false;
while let Ok(request) = requests.try_recv() {
saw = true;
if let crate::actor::ActorRequest::Call {
method,
params,
reply,
..
} = request
{
if method == "pane.input" {
let encoded = params
.get("data_b64")
.and_then(|value| value.as_str())
.unwrap_or("");
inputs.push(
base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap_or_default(),
);
}
let _ = reply.send(Ok(serde_json::json!({})));
}
}
if saw {
quiet = std::time::Instant::now();
} else if !inputs.is_empty() && quiet.elapsed() > std::time::Duration::from_millis(40) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
inputs
}
fn sample_pane(live: LiveState) -> Pane {
let mut pane = Pane::new(
"ws".into(),
"tab".into(),
"/tmp".into(),
vec!["shell".into()],
signaltty_core::PtySize::new(80, 24).unwrap(),
chrono::Utc::now(),
);
pane.live = live;
pane
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn realized_terminal_forwards_commit_bytes_and_refuses_truncated_nul() {
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"input",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let window = present_pane(&pane);
let _ = take_inputs(&mut requests, false);
pane.term.feed_child(&[0]);
assert_eq!(
take_inputs(&mut requests, true),
vec![vec![0]],
"Ctrl+Space must deliver one NUL"
);
pane.term.feed_child(&[0x03]);
assert_eq!(take_inputs(&mut requests, true), vec![vec![0x03]]);
pane.term.feed_child("héllo".as_bytes());
assert_eq!(
take_inputs(&mut requests, true),
vec!["héllo".as_bytes().to_vec()]
);
pane.term.feed_child(b"one\ntwo");
assert_eq!(take_inputs(&mut requests, true), vec![b"one\ntwo".to_vec()]);
window.clipboard().set_text("α\nβ");
pane.term.paste_clipboard();
assert_eq!(
take_inputs(&mut requests, true),
vec!["α\rβ".as_bytes().to_vec()],
"native paste maps the newline to CR and adds no further Enter"
);
pane.term.feed(b"\x1b[?2004h");
pump(80);
window.clipboard().set_text("paste");
pane.term.paste_clipboard();
assert_eq!(
take_inputs(&mut requests, true),
vec![b"\x1b[200~paste\x1b[201~".to_vec()]
);
pane.term.feed_child(&[b'a', 0, b'b']);
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"a realized terminal must refuse an already-truncated embedded NUL"
);
pane.update_meta(&sample_pane(LiveState::Exited { code: None }));
pane.term.feed_child(b"x");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
window.close();
}
fn activate_paste(pane: &PaneWidget) {
pane.commands
.lookup_action("paste")
.expect("paste action")
.activate(None);
}
fn pending_inputs(
requests: &mut tokio::sync::mpsc::UnboundedReceiver<crate::actor::ActorRequest>,
) -> Vec<Vec<u8>> {
use base64::Engine;
let mut inputs = Vec::new();
while let Ok(request) = requests.try_recv() {
if let crate::actor::ActorRequest::Call {
method,
params,
reply,
..
} = request
{
if method == "pane.input" {
let encoded = params
.get("data_b64")
.and_then(|value| value.as_str())
.unwrap_or("");
inputs.push(
base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap_or_default(),
);
}
let _ = reply.send(Ok(serde_json::json!({})));
}
}
inputs
}
#[test]
#[ignore = "requires a GTK display; run with dbus-run-session"]
fn paste_started_before_exit_is_not_delivered_after_resume() {
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"paste-race",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let window = present_pane(&pane);
let _ = take_inputs(&mut requests, false);
window.clipboard().set_text("α\nβ");
activate_paste(&pane);
assert_eq!(
take_inputs(&mut requests, true),
vec!["α\rβ".as_bytes().to_vec()],
"paste keeps the newline as CR and adds no Enter"
);
activate_paste(&pane);
activate_paste(&pane);
assert_eq!(
take_inputs(&mut requests, true),
vec!["α\rβ".as_bytes().to_vec(); 2],
"each paste command in the same live process must be delivered"
);
pane.term.feed(b"\x1b[?2004h");
pump(80);
window.clipboard().set_text("one\ntwo");
activate_paste(&pane);
assert_eq!(
take_inputs(&mut requests, true),
vec![b"\x1b[200~one\rtwo\x1b[201~".to_vec()],
"paste keeps bracketed mode"
);
window.clipboard().set_text("STALE");
activate_paste(&pane);
assert_eq!(
pending_inputs(&mut requests),
Vec::<Vec<u8>>::new(),
"the clipboard read must still be in flight"
);
pane.update_meta(&sample_pane(LiveState::Exited { code: None }));
pane.update_meta(&sample_pane(LiveState::Live));
assert_eq!(
take_inputs(&mut requests, true),
Vec::<Vec<u8>>::new(),
"stale clipboard text must not reach the resumed process"
);
window.clipboard().set_text("AFTER");
activate_paste(&pane);
assert_eq!(
take_inputs(&mut requests, true),
vec![b"\x1b[200~AFTER\x1b[201~".to_vec()],
"a paste started after resume still reaches that process"
);
window.clipboard().set_text("GONE");
activate_paste(&pane);
assert_eq!(pending_inputs(&mut requests), Vec::<Vec<u8>>::new());
pane.update_meta(&sample_pane(LiveState::Exited { code: None }));
assert_eq!(
take_inputs(&mut requests, true),
Vec::<Vec<u8>>::new(),
"an in-flight paste is dropped when the pane stays exited"
);
window.close();
let (actor, mut dropped_requests) = IpcHandle::test_channel();
let dropped = PaneWidget::new(
"paste-dropped",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let weak = Rc::downgrade(&dropped);
let dropped_window = present_pane(&dropped);
let _ = take_inputs(&mut dropped_requests, false);
dropped_window.clipboard().set_text("DROPPED");
activate_paste(&dropped);
assert_eq!(pending_inputs(&mut dropped_requests), Vec::<Vec<u8>>::new());
drop(dropped_window);
drop(dropped);
assert!(
weak.upgrade().is_none(),
"an in-flight paste must not keep the pane"
);
assert_eq!(
take_inputs(&mut dropped_requests, true),
Vec::<Vec<u8>>::new(),
"a dropped pane must not paste"
);
}
fn press_chord(title: &str, chord: &str) {
let output = std::process::Command::new("xdotool")
.args([
"search",
"--sync",
"--onlyvisible",
"--name",
title,
"windowfocus",
"--sync",
"key",
"--window",
"%1",
"--clearmodifiers",
chord,
])
.output()
.unwrap_or_else(|error| panic!("xdotool {chord}: {error}"));
assert!(
output.status.success(),
"xdotool {chord}: {}",
String::from_utf8_lossy(&output.stderr)
);
pump(200);
}
fn clipboard_text(widget: &impl gtk4::prelude::WidgetExt) -> String {
let (sender, receiver) = std::sync::mpsc::channel();
widget
.clipboard()
.read_text_async(None::<>k4::gio::Cancellable>, move |result| {
let text = result
.ok()
.flatten()
.map(|value| value.to_string())
.unwrap_or_default();
let _ = sender.send(text);
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
while gtk4::glib::MainContext::default().pending() {
gtk4::glib::MainContext::default().iteration(false);
}
if let Ok(text) = receiver.try_recv() {
return text;
}
assert!(
std::time::Instant::now() < deadline,
"clipboard read timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
#[ignore = "requires a GTK display and xdotool; run with dbus-run-session"]
fn terminal_chords_copy_paste_and_stay_consumed_when_unavailable() {
unsafe {
std::env::set_var("GDK_BACKEND", "x11");
std::env::remove_var("WAYLAND_DISPLAY");
}
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"keys",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let title = format!("signaltty-vte-keys-{}", std::process::id());
let window = present_pane(&pane);
window.set_title(Some(&title));
pump(100);
let _ = take_inputs(&mut requests, false);
window.clipboard().set_text("KEEP");
pump(50);
press_chord(&title, "ctrl+shift+c");
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"Copy with no selection must not reach the child as Ctrl+C"
);
assert_eq!(clipboard_text(&window), "KEEP");
pane.term.feed(b"COPY_TOKEN");
pump(80);
pane.term.select_all();
pump(40);
press_chord(&title, "ctrl+shift+c");
assert!(
clipboard_text(&window).contains("COPY_TOKEN"),
"clipboard was {:?}",
clipboard_text(&window)
);
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
window.clipboard().set_text("PASTE_TOKEN");
pump(40);
press_chord(&title, "ctrl+shift+v");
assert_eq!(
take_inputs(&mut requests, true),
vec![b"PASTE_TOKEN".to_vec()]
);
pane.term.feed(b"\x1b[?2004h");
pump(80);
press_chord(&title, "ctrl+shift+v");
assert_eq!(
take_inputs(&mut requests, true),
vec![b"\x1b[200~PASTE_TOKEN\x1b[201~".to_vec()]
);
pane.update_meta(&sample_pane(LiveState::Exited { code: None }));
press_chord(&title, "ctrl+shift+v");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
pane.update_meta(&sample_pane(LiveState::Live));
press_chord(&title, "ctrl+c");
assert_eq!(take_inputs(&mut requests, true), vec![vec![0x03]]);
press_chord(&title, "ctrl+v");
assert_eq!(take_inputs(&mut requests, true), vec![vec![0x16]]);
pane.present_search();
pump(40);
press_chord(&title, "ctrl+shift+c");
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"search keeps its own key handling"
);
pane.close_search();
assert!(pane.term.grab_focus());
pump(40);
let _ = take_inputs(&mut requests, false);
press_chord(&title, "Menu");
assert!(
menu_is_open(&window),
"Menu opens the terminal context menu"
);
press_chord(&title, "Escape");
assert!(
!menu_is_open(&window),
"Escape dismisses the terminal context menu"
);
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"Menu and Escape must not reach the child"
);
press_chord(&title, "shift+F10");
assert!(
menu_is_open(&window),
"Shift+F10 opens the terminal context menu"
);
press_chord(&title, "Escape");
assert!(
!menu_is_open(&window),
"Escape dismisses the Shift+F10 menu"
);
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"Shift+F10 and Escape must not reach the child"
);
let model = pane
.term
.context_menu_model()
.expect("terminal context menu");
let labels: Vec<String> = (0..model.n_items())
.filter_map(|index| {
model
.item_attribute_value(index, "label", None)
.and_then(|value| value.str().map(str::to_string))
})
.collect();
assert!(labels.iter().any(|label| label == "Copy"));
assert!(labels.iter().any(|label| label == "Paste"));
window.close();
}
fn terminal_contents(term: &vte4::Terminal) -> String {
let stream = gtk4::gio::MemoryOutputStream::new_resizable();
term.write_contents_sync(
&stream,
vte4::WriteFlags::Default,
None::<>k4::gio::Cancellable>,
)
.expect("terminal contents");
stream
.close(None::<>k4::gio::Cancellable>)
.expect("close contents");
String::from_utf8_lossy(&stream.steal_as_bytes()).into_owned()
}
fn visible_text(term: &vte4::Terminal) -> String {
term.text_format(vte4::Format::Text)
.map(|text| text.to_string())
.unwrap_or_default()
}
fn click_window(title: &str, x: i32, y: i32, button: &str) {
let listed = std::process::Command::new("xdotool")
.args(["search", "--sync", "--onlyvisible", "--name", title])
.output()
.unwrap_or_else(|error| panic!("xdotool search: {error}"));
assert!(
listed.status.success(),
"xdotool search: {}",
String::from_utf8_lossy(&listed.stderr)
);
let id = String::from_utf8_lossy(&listed.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
assert!(!id.is_empty(), "xdotool found no window {title}");
let focused = std::process::Command::new("xdotool")
.args(["windowfocus", "--sync", &id])
.status()
.unwrap_or_else(|error| panic!("xdotool focus: {error}"));
assert!(focused.success(), "xdotool focus failed");
let geometry = std::process::Command::new("xdotool")
.args(["getwindowgeometry", "--shell", &id])
.output()
.unwrap_or_else(|error| panic!("xdotool geometry: {error}"));
assert!(geometry.status.success());
let mut origin_x = 0;
let mut origin_y = 0;
for line in String::from_utf8_lossy(&geometry.stdout).lines() {
if let Some(value) = line.strip_prefix("X=") {
origin_x = value.parse().expect("window x");
} else if let Some(value) = line.strip_prefix("Y=") {
origin_y = value.parse().expect("window y");
}
}
// XTEST, not XSendEvent: GTK discards send_event button clicks.
let output = std::process::Command::new("xdotool")
.args([
"mousemove",
&(origin_x + x).to_string(),
&(origin_y + y).to_string(),
"click",
button,
])
.output()
.unwrap_or_else(|error| panic!("xdotool click: {error}"));
assert!(
output.status.success(),
"xdotool click: {}",
String::from_utf8_lossy(&output.stderr)
);
pump(200);
}
#[test]
#[ignore = "requires a GTK display and xdotool; run with dbus-run-session"]
fn replace_screen_keeps_one_history_marker_and_restores_modes() {
unsafe {
std::env::set_var("GDK_BACKEND", "x11");
std::env::remove_var("WAYLAND_DISPLAY");
}
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"screen",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let title = format!("signaltty-vte-screen-{}", std::process::id());
let window = present_pane(&pane);
window.set_title(Some(&title));
let _ = take_inputs(&mut requests, false);
pane.term.feed(b"HISTORY_MARKER\r\n");
let extra = pane.term.row_count().max(1) as usize + 4;
for index in 0..extra {
pane.term.feed(format!("filler-{index:02}\r\n").as_bytes());
}
pane.term.feed(b"OLD_VISIBLE");
pump(80);
let before = terminal_contents(&pane.term);
assert_eq!(
before.matches("HISTORY_MARKER").count(),
1,
"history must be seeded once before reconnect"
);
let snapshot = b"\x1b[0m\x1b[H\x1b[JMAIN_CANON\x1b[?2004h\x1b[?1000h\x1b[?1006h";
pane.replace_screen(snapshot);
pane.feed(b"LIVE_TAIL");
pump(80);
assert!(
visible_text(&pane.term).contains("MAIN_CANON"),
"queued live output follows the restored screen"
);
assert!(visible_text(&pane.term).contains("LIVE_TAIL"));
pane.replace_screen(snapshot);
pump(80);
let all = terminal_contents(&pane.term);
let visible = visible_text(&pane.term);
assert_eq!(
all.matches("HISTORY_MARKER").count(),
1,
"two reconnects keep the earlier line once: {all}"
);
assert_eq!(all.matches("MAIN_CANON").count(), 1, "{all}");
assert_eq!(visible.matches("MAIN_CANON").count(), 1, "{visible}");
assert_eq!(all.matches("OLD_VISIBLE").count(), 0, "{all}");
assert_eq!(
all.matches("LIVE_TAIL").count(),
0,
"the next snapshot replaces queued live bytes without archiving them: {all}"
);
window.clipboard().set_text("paste");
pane.term.paste_clipboard();
assert_eq!(
take_inputs(&mut requests, true),
vec![b"\x1b[200~paste\x1b[201~".to_vec()],
"the snapshot restores bracketed paste after DECSTR"
);
let point = pane
.term
.compute_point(
&window,
>k4::graphene::Point::new(
pane.term.width() as f32 / 2.0,
pane.term.height() as f32 / 2.0,
),
)
.expect("terminal coordinates");
click_window(
&title,
point.x().round() as i32,
point.y().round() as i32,
"1",
);
let reports = take_inputs(&mut requests, true);
let blob: Vec<u8> = reports.into_iter().flatten().collect();
assert!(
blob.windows(3).any(|seq| seq == b"\x1b[<"),
"the snapshot restores SGR mouse tracking: {blob:?}"
);
window.close();
let (actor, _requests) = IpcHandle::test_channel();
let alt = PaneWidget::new(
"alt",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let alt_window = present_pane(&alt);
alt.term.feed(b"HISTORY_MARKER\r\n");
let extra = alt.term.row_count().max(1) as usize + 4;
for index in 0..extra {
alt.term.feed(format!("filler-{index:02}\r\n").as_bytes());
}
alt.term.feed(b"MAIN_VISIBLE\x1b[?1049h\x1b[HALT_OLD");
pump(80);
let alt_snapshot = b"\x1b[0m\x1b[H\x1b[JALT_CANON";
alt.replace_screen(alt_snapshot);
alt.replace_screen(alt_snapshot);
pump(80);
let all = terminal_contents(&alt.term);
let visible = visible_text(&alt.term);
assert_eq!(all.matches("HISTORY_MARKER").count(), 1, "{all}");
assert_eq!(all.matches("ALT_CANON").count(), 1, "{all}");
assert_eq!(visible.matches("ALT_CANON").count(), 1, "{visible}");
assert_eq!(all.matches("ALT_OLD").count(), 0, "{all}");
alt_window.close();
}
fn assert_scale(term: &vte4::Terminal, expected: f64) {
let actual = term.font_scale();
assert!(
(actual - expected).abs() < 0.001,
"font scale {actual} != {expected}"
);
}
fn command_enabled(pane: &PaneWidget, name: &str) -> bool {
pane.commands.lookup_action(name).expect(name).is_enabled()
}
#[test]
#[ignore = "requires Xvfb, xdotool and setxkbmap"]
fn terminal_text_scale_uses_unshifted_italian_layout_keys() {
const CHILD_FLAG: &str = "SIGNALTTY_TEST_IT_KEYMAP_CHILD";
const TEST_NAME: &str =
"terminal::tests::terminal_text_scale_uses_unshifted_italian_layout_keys";
if std::env::var_os(CHILD_FLAG).is_none() {
let output = std::process::Command::new("xvfb-run")
.args([
"-a",
"-s",
"-screen 0 1600x1200x24 -noreset",
"dbus-run-session",
"--",
"sh",
"-c",
"setxkbmap it && setxkbmap -query && exec \"$1\" \"$2\" --exact --ignored --test-threads=1",
"sh",
])
.arg(std::env::current_exe().unwrap())
.arg(TEST_NAME)
.env(CHILD_FLAG, "1")
.env("GDK_BACKEND", "x11")
.env_remove("WAYLAND_DISPLAY")
.output()
.expect("isolated Italian keymap test");
assert!(
output.status.success(),
"Italian keymap child failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"zoom-it",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let title = format!("signaltty-vte-zoom-it-{}", std::process::id());
let window = present_pane(&pane);
window.set_title(Some(&title));
let _ = take_inputs(&mut requests, false);
for (chord, scale) in [
("ctrl+shift+plus", 1.1),
("ctrl+shift+minus", 1.0),
("ctrl+shift+plus", 1.1),
("ctrl+shift+0", 1.0),
] {
press_chord(&title, chord);
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, scale);
}
window.close();
}
#[test]
#[ignore = "requires a GTK display and xdotool; run with dbus-run-session"]
fn terminal_text_scale_follows_real_chords_and_stays_per_pane() {
unsafe {
std::env::set_var("GDK_BACKEND", "x11");
std::env::remove_var("WAYLAND_DISPLAY");
}
libadwaita::init().unwrap();
let (actor, mut requests) = IpcHandle::test_channel();
let pane = PaneWidget::new(
"zoom-a",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let (other_actor, mut other_requests) = IpcHandle::test_channel();
let other = PaneWidget::new(
"zoom-b",
other_actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let title = format!("signaltty-vte-zoom-a-{}", std::process::id());
let window = present_pane(&pane);
window.set_title(Some(&title));
let other_window = present_pane(&other);
other_window.set_title(Some(&format!(
"signaltty-vte-zoom-b-{}",
std::process::id()
)));
let same_term = pane.term.clone();
let face = pane.term.font().expect("desktop font").to_string();
let _ = take_inputs(&mut requests, false);
let _ = take_inputs(&mut other_requests, false);
assert_scale(&pane.term, 1.0);
assert_scale(&other.term, 1.0);
press_chord(&title, "ctrl+shift+plus");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.1);
assert_scale(&other.term, 1.0);
press_chord(&title, "ctrl+shift+equal");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.2);
pane.apply_style(signaltty_core::Theme::Signal);
assert_scale(&pane.term, 1.2);
assert!(
(pane.term.cell_height_scale() - 1.1).abs() < 0.001,
"leading stays on the desktop cell height"
);
assert_eq!(pane.term.font().expect("font").to_string(), face);
press_chord(&title, "ctrl+shift+minus");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.1);
press_chord(&title, "ctrl+shift+underscore");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.0);
press_chord(&title, "ctrl+shift+minus");
assert_scale(&pane.term, 0.9);
press_chord(&title, "ctrl+shift+0");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.0);
press_chord(&title, "ctrl+shift+equal");
assert_scale(&pane.term, 1.1);
press_chord(&title, "ctrl+shift+parenright");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.0);
press_chord(&title, "ctrl+shift+KP_Add");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 1.1);
press_chord(&title, "ctrl+shift+KP_Subtract");
assert_scale(&pane.term, 1.0);
press_chord(&title, "ctrl+shift+KP_0");
assert_scale(&pane.term, 1.0);
for _ in 0..12 {
press_chord(&title, "ctrl+shift+plus");
}
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 2.0);
assert!(!command_enabled(&pane, "increase-text-size"));
press_chord(&title, "ctrl+shift+plus");
assert_eq!(
take_inputs(&mut requests, false),
Vec::<Vec<u8>>::new(),
"zoom at the upper bound must not reach the child"
);
assert_scale(&pane.term, 2.0);
for _ in 0..20 {
press_chord(&title, "ctrl+shift+minus");
}
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 0.5);
assert!(!command_enabled(&pane, "decrease-text-size"));
press_chord(&title, "ctrl+shift+minus");
assert_eq!(take_inputs(&mut requests, false), Vec::<Vec<u8>>::new());
assert_scale(&pane.term, 0.5);
press_chord(&title, "ctrl+shift+0");
assert_scale(&pane.term, 1.0);
assert!(!command_enabled(&pane, "reset-text-size"));
assert!(command_enabled(&pane, "increase-text-size"));
assert!(command_enabled(&pane, "decrease-text-size"));
assert_scale(&other.term, 1.0);
assert_eq!(pane.term, same_term);
assert_ne!(pane.term, other.term);
pane.present_search();
assert_eq!(pane.term, same_term);
let model = pane.term.context_menu_model().expect("menu");
let labels: Vec<String> = (0..model.n_items())
.filter_map(|index| {
model
.item_attribute_value(index, "label", None)
.and_then(|value| value.str().map(str::to_string))
})
.collect();
assert!(labels.iter().any(|label| label == "Increase Text Size"));
assert!(labels.iter().any(|label| label == "Decrease Text Size"));
assert!(labels.iter().any(|label| label == "Reset Text Size"));
window.close();
other_window.close();
}
fn visible_popover(widget: &impl IsA<gtk4::Widget>) -> bool {
let widget = widget.as_ref();
if widget.is_visible() && widget.downcast_ref::<gtk4::Popover>().is_some() {
return true;
}
let mut child = widget.first_child();
while let Some(next) = child {
if visible_popover(&next) {
return true;
}
child = next.next_sibling();
}
false
}
fn menu_is_open(window: >k4::Window) -> bool {
visible_popover(window) || gtk4::Window::list_toplevels().iter().any(visible_popover)
}
fn popdown_popovers(widget: &impl IsA<gtk4::Widget>) {
let widget = widget.as_ref();
if let Some(popover) = widget.downcast_ref::<gtk4::Popover>() {
popover.popdown();
}
let mut child = widget.first_child();
while let Some(next) = child {
popdown_popovers(&next);
child = next.next_sibling();
}
}
fn capture_window(window: >k4::Window, path: &std::path::Path) {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
while std::time::Instant::now() < deadline {
while gtk4::glib::MainContext::default().iteration(false) {}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let paintable = gtk4::WidgetPaintable::new(Some(window));
let snapshot = gtk4::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
let renderer = gtk4::gsk::CairoRenderer::new();
renderer
.realize_for_display(>k4::prelude::WidgetExt::display(window))
.unwrap();
let texture = renderer.render_texture(snapshot.to_node().unwrap(), None);
renderer.unrealize();
texture.save_to_png(path).unwrap();
}
#[test]
#[ignore = "requires a GTK display and xdotool; run with dbus-run-session"]
fn terminal_scale_and_menu_render_light_and_dark() {
unsafe {
std::env::set_var("GDK_BACKEND", "x11");
std::env::remove_var("WAYLAND_DISPLAY");
}
libadwaita::init().unwrap();
let (actor, _) = IpcHandle::test_channel();
let large = PaneWidget::new(
"large",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
let (actor, _) = IpcHandle::test_channel();
let small = PaneWidget::new(
"small",
actor,
PaneCallbacks {
on_focus: Box::new(|_| {}),
on_action: Box::new(|_, _| {}),
},
);
large.set_text_tenths(10);
small.set_text_tenths(-5);
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
row.append(&large.root);
row.append(&small.root);
let window = gtk4::Window::new();
let title = format!("signaltty-vte-evidence-{}", std::process::id());
window.set_title(Some(&title));
window.set_default_size(960, 420);
window.set_child(Some(&row));
window.present();
pump_until(|| {
large.term.is_realized()
&& small.term.is_realized()
&& large.term.row_count() > 0
&& small.term.row_count() > 0
});
let style = libadwaita::StyleManager::default();
let old_scheme = style.color_scheme();
let directory = std::env::var_os("SIGNALTTY_UI_EVIDENCE").map(std::path::PathBuf::from);
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
}
for (name, scheme) in [
("light", libadwaita::ColorScheme::ForceLight),
("dark", libadwaita::ColorScheme::ForceDark),
] {
for top in gtk4::Window::list_toplevels() {
popdown_popovers(&top);
}
pump(40);
style.set_color_scheme(scheme);
if scheme == libadwaita::ColorScheme::ForceDark {
window.add_css_class("dark");
} else {
window.remove_css_class("dark");
}
large.apply_style(signaltty_core::Theme::Signal);
small.apply_style(signaltty_core::Theme::Signal);
large.term.feed(b"\x1b[HLARGE TEXT\r\n");
small.term.feed(b"\x1b[Hsmall text\r\n");
pump(80);
large.term.select_all();
pump(40);
assert!(
large.term.has_selection() && command_enabled(&large, "copy"),
"selection={} copy={}",
large.term.has_selection(),
command_enabled(&large, "copy")
);
assert!(!command_enabled(&large, "increase-text-size"));
large.focus();
let point = large
.term
.compute_point(
&window,
>k4::graphene::Point::new(
large.term.width() as f32 / 2.0,
large.term.height() as f32 / 2.0,
),
)
.expect("terminal coordinates");
click_window(
&title,
point.x().round() as i32,
point.y().round() as i32,
"3",
);
if let Some(directory) = &directory {
capture_window(
&window,
&directory.join(format!("terminal-scale-{name}.png")),
);
let root = directory.join(format!("terminal-scale-menu-{name}.png"));
let status = std::process::Command::new("import")
.args(["-window", "root", root.to_str().unwrap()])
.status()
.expect("import");
assert!(status.success(), "root capture failed");
}
assert!(menu_is_open(&window), "right click shows the terminal menu");
pump(40);
}
style.set_color_scheme(old_scheme);
window.close();
}
}