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;
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>,
last_size: Cell<(u16, u16)>,
}
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("circular");
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,
));
actions.append(&action_button(
"window-close-symbolic",
"Close Pane (Ctrl+Shift+W)",
PaneAction::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);
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 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),
last_size: Cell::new((80, 24)),
});
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.
{
let weak = Rc::downgrade(&w);
w.term.connect_commit(move |_, text: &str, _| {
let Some(w) = weak.upgrade() else { return };
if !w.live.get() {
return;
}
use base64::Engine;
w.send(
"pane.input",
json!({
"pane_id": w.pane_id,
"data_b64": base64::engine::general_purpose::STANDARD.encode(text.as_bytes()),
}),
);
});
}
// 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));
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 palette: Vec<gtk4::gdk::RGBA> = PALETTE.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,
);
}
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;
});
}
pub fn replace_screen(&self, data: &[u8]) {
self.term.reset(true, true);
self.term.feed(data);
}
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(&pane.title);
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.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.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);
}
}
}
}
const LIGHT_FOREGROUND: &str = "#241f31";
const DARK_FOREGROUND: &str = "#deddda";
/// GNOME palette (as in Console/Ptyxis): legible on both schemes.
const PALETTE: [&str; 16] = [
"#241f31", "#c01c28", "#2ec27e", "#f5c211", "#1e78e4", "#9841bb", "#0ab9dc", "#c0bfbc",
"#5e5c64", "#ed333b", "#57e389", "#f8e45c", "#51a1ff", "#c061cb", "#4fd2fd", "#f6f5f4",
];
#[cfg(test)]
mod tests {
use super::*;
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.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 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);
}
}