Files
ghostty-rs/crates/ghostty-inspector/src/widgets/termio.rs
T
ddidderr 8bcb85a29a style: apply nightly rustfmt import grouping across the workspace
rustfmt.toml sets group_imports, imports_granularity and imports_layout,
which are nightly-only; agents formatted with stable rustfmt, which
ignored them. This is a formatting-only pass with `cargo +nightly fmt`.

Test Plan:
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test --workspace
2026-10-12 05:03:37 +02:00

994 lines
33 KiB
Rust

//! VT event stream inspector (ZIG/src/inspector/widgets/termio.zig).
use std::{collections::VecDeque, convert::Infallible, fmt::Write as _};
use dear_imgui_rs::{
FocusedFlags,
Key,
SelectableFlags,
TableColumnFlags,
TableColumnWidth,
TableFlags,
Ui,
WindowFlags,
};
use ghostty_terminal::terminal::{ScrollingRegion, Terminal};
use ghostty_vt::{
color::Palette,
osc::{self, Command as OscCommand},
parser::Action as ParserAction,
stream::{Action as StreamAction, Handler, Stream as VtStream},
};
use super::screen::{CursorSnapshot, cursor_style, cursor_table};
/// The maximum number of recorded events (see the key event ring).
pub const MAX_EVENTS: usize = 100;
/// The tags of `Parser.Action` (used for filtering).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionTag {
Print,
Execute,
CsiDispatch,
EscDispatch,
OscDispatch,
DcsHook,
DcsPut,
DcsUnhook,
ApcStart,
ApcPut,
ApcEnd,
}
impl ActionTag {
pub const ALL: [ActionTag; 11] = [
ActionTag::Print,
ActionTag::Execute,
ActionTag::CsiDispatch,
ActionTag::EscDispatch,
ActionTag::OscDispatch,
ActionTag::DcsHook,
ActionTag::DcsPut,
ActionTag::DcsUnhook,
ActionTag::ApcStart,
ActionTag::ApcPut,
ActionTag::ApcEnd,
];
pub fn of(action: &ParserAction) -> ActionTag {
match action {
ParserAction::Print(_) => ActionTag::Print,
ParserAction::Execute(_) => ActionTag::Execute,
ParserAction::CsiDispatch(_) => ActionTag::CsiDispatch,
ParserAction::EscDispatch(_) => ActionTag::EscDispatch,
ParserAction::OscDispatch(_) => ActionTag::OscDispatch,
ParserAction::DcsHook(_) => ActionTag::DcsHook,
ParserAction::DcsPut(_) => ActionTag::DcsPut,
ParserAction::DcsUnhook => ActionTag::DcsUnhook,
ParserAction::ApcStart => ActionTag::ApcStart,
ParserAction::ApcPut(_) => ActionTag::ApcPut,
ParserAction::ApcEnd => ActionTag::ApcEnd,
}
}
pub fn name(self) -> String {
crate::keys::tag_name(&self)
}
}
/// A simple set of excluded action tags (Zig: `std.EnumSet`).
#[derive(Debug, Clone, Default)]
pub struct ActionTagSet(Vec<ActionTag>);
impl ActionTagSet {
pub fn contains(&self, tag: ActionTag) -> bool {
self.0.contains(&tag)
}
pub fn insert(&mut self, tag: ActionTag) {
if !self.contains(tag) {
self.0.push(tag);
}
}
pub fn remove(&mut self, tag: ActionTag) {
self.0.retain(|t| *t != tag);
}
}
/// Port of `ImGuiTextFilter` (build + pass filter): comma separated terms,
/// a term starting with `-` excludes, matching is ASCII case insensitive.
#[derive(Debug, Clone, Default)]
pub struct TextFilter {
pub input: String,
/// `(exclude, lowercase term)` in order.
filters: Vec<(bool, String)>,
count_grep: usize,
}
impl TextFilter {
/// Re-parse the filter expression (`ImGuiTextFilter::Build`).
pub fn build(&mut self) {
self.filters.clear();
self.count_grep = 0;
for part in self.input.split(',') {
let part = part.trim_matches(|c| c == ' ' || c == '\t');
if part.is_empty() {
continue;
}
self.filters
.push((part.starts_with('-'), part.to_ascii_lowercase()));
// `-` alone is a no-op term, like in ImGui.
if !part.starts_with('-') {
self.count_grep += 1;
}
}
}
/// `ImGuiTextFilter::PassFilter`.
pub fn pass_filter(&self, text: &str) -> bool {
if self.filters.is_empty() {
return true;
}
let lower = text.to_ascii_lowercase();
for (exclude, term) in &self.filters {
if *exclude {
// Subtract hyphen
let needle = &term[1..];
if !needle.is_empty() && lower.contains(needle) {
return false;
}
} else if lower.contains(term.as_str()) {
return true;
}
}
// Implicit * grep
self.count_grep == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Print,
Execute,
Csi,
Esc,
Osc,
Dcs,
Apc,
}
impl Kind {
fn name(self) -> &'static str {
match self {
Kind::Print => "print",
Kind::Execute => "execute",
Kind::Csi => "csi",
Kind::Esc => "esc",
Kind::Osc => "osc",
Kind::Dcs => "dcs",
Kind::Apc => "apc",
}
}
}
/// VT event. This is just how we store internal events.
#[derive(Debug, Clone)]
pub struct VtEvent {
/// Sequence number, just monotonically increasing and wrapping if
/// it ever overflows. It gives us a nice way to visualize progress.
pub seq: usize,
/// Kind of event, for filtering
pub kind: Kind,
/// The description of the raw event in a more human-friendly format.
/// For example for control sequences this is the full sequence but
/// control characters are replaced with human-readable names, e.g.
/// 0x07 (bell) becomes BEL.
pub raw_description: String,
/// Various metadata at the time of the event (before processing).
pub cursor: CursorSnapshot,
pub scrolling_region: ScrollingRegion,
pub metadata: Vec<(String, String)>,
/// imgui selection state
pub imgui_selected: bool,
}
impl VtEvent {
/// Initialize the event information for the given parser action.
pub fn new(
cursor: CursorSnapshot,
scrolling_region: ScrollingRegion,
action: &ParserAction,
) -> Self {
let mut md = Vec::new();
let desc = encode_action(&mut md, action);
let kind = match action {
ParserAction::Print(_) => Kind::Print,
ParserAction::Execute(_) => Kind::Execute,
ParserAction::CsiDispatch(_) => Kind::Csi,
ParserAction::EscDispatch(_) => Kind::Esc,
ParserAction::OscDispatch(_) => Kind::Osc,
ParserAction::DcsHook(_) | ParserAction::DcsPut(_) | ParserAction::DcsUnhook => {
Kind::Dcs
}
ParserAction::ApcStart | ParserAction::ApcPut(_) | ParserAction::ApcEnd => Kind::Apc,
};
VtEvent {
seq: 1,
kind,
raw_description: desc,
cursor,
scrolling_region,
metadata: md,
imgui_selected: false,
}
}
/// Returns true if the event passes the given filter.
pub fn pass_filter(&self, filter: &TextFilter) -> bool {
// Check our main string
if filter.pass_filter(&self.raw_description) {
return true;
}
// We also check all metadata keys and values
self.metadata
.iter()
.any(|(k, v)| filter.pass_filter(k) || filter.pass_filter(v))
}
}
/// Encode a parser action as a string that we show in the logs.
fn encode_action(md: &mut Vec<(String, String)>, action: &ParserAction) -> String {
match action {
ParserAction::Print(ch) => encode_print(*ch),
ParserAction::Execute(ch) => encode_execute(*ch),
ParserAction::CsiDispatch(v) => encode_csi(v),
ParserAction::EscDispatch(v) => encode_esc(*v),
ParserAction::OscDispatch(v) => encode_osc(md, v),
other => format_action(other),
}
}
/// Mirrors `Parser.Action.format` for the actions that are not specially
/// encoded.
fn format_action(action: &ParserAction) -> String {
let (tag, value) = match action {
ParserAction::DcsHook(d) => (
"dcs_hook",
format!(
"{{ intermediates = {:?}, params = {:?}, final = {} }}",
d.intermediates(),
d.params(),
d.final_
),
),
ParserAction::DcsPut(b) => ("dcs_put", format!("0x{b:x}")),
ParserAction::DcsUnhook => ("dcs_unhook", "void".to_owned()),
ParserAction::ApcStart => ("apc_start", "void".to_owned()),
ParserAction::ApcPut(b) => ("apc_put", format!("0x{b:x}")),
ParserAction::ApcEnd => ("apc_end", "void".to_owned()),
// Handled by the specific encoders.
_ => ("unknown", String::new()),
};
format!("parser.Parser.Action{{ .{tag} = {value} }}")
}
fn encode_print(ch: u32) -> String {
let c = char::from_u32(ch).unwrap_or(char::REPLACEMENT_CHARACTER);
format!("'{c}' (U+{ch:X})")
}
fn encode_execute(ch: u8) -> String {
let name = match ch {
0x00 => "NUL",
0x01 => "SOH",
0x02 => "STX",
0x03 => "ETX",
0x04 => "EOT",
0x05 => "ENQ",
0x06 => "ACK",
0x07 => "BEL",
0x08 => "BS",
0x09 => "HT",
0x0A => "LF",
0x0B => "VT",
0x0C => "FF",
0x0D => "CR",
0x0E => "SO",
0x0F => "SI",
_ => "?",
};
format!("{name} (0x{ch:X})")
}
fn encode_csi(csi: &ghostty_vt::parser::Csi) -> String {
let mut out = String::new();
for v in csi.intermediates() {
let _ = write!(out, "{} ", char::from(*v));
}
for (i, v) in csi.params().iter().enumerate() {
if i != 0 {
out.push(';');
}
let _ = write!(out, "{v}");
}
if !csi.intermediates().is_empty() || !csi.params().is_empty() {
out.push(' ');
}
out.push(char::from(csi.final_));
out
}
fn encode_esc(esc: ghostty_vt::parser::Esc) -> String {
let mut out = String::new();
for v in esc.intermediates() {
let _ = write!(out, "{} ", char::from(*v));
}
out.push(char::from(esc.final_));
out
}
fn bytes(v: &[u8]) -> String {
String::from_utf8_lossy(v).into_owned()
}
fn terminator_name(t: osc::Terminator) -> &'static str {
match t {
osc::Terminator::St => "st",
osc::Terminator::Bel => "bel",
}
}
fn optional_bool(v: Option<bool>) -> String {
match v {
Some(true) => "true".to_owned(),
Some(false) => "false".to_owned(),
None => "(unset)".to_owned(),
}
}
fn encode_osc(md: &mut Vec<(String, String)>, cmd: &OscCommand) -> String {
// The description is just the tag
let out = format!("{} ", crate::keys::tag_name(&cmd.key()));
encode_osc_metadata(md, cmd);
out
}
/// Add the OSC fields to the metadata (Zig: `encodeMetadata`, which walks
/// the fields of the command union with comptime reflection).
fn encode_osc_metadata(md: &mut Vec<(String, String)>, cmd: &OscCommand) {
let mut put = |k: &str, v: String| md.push((k.to_owned(), v));
match cmd {
OscCommand::Invalid | OscCommand::HyperlinkEnd | OscCommand::ConemuWaitInput => {}
OscCommand::ChangeWindowTitle(v)
| OscCommand::ChangeWindowIcon(v)
| OscCommand::ConemuShowMessageBox(v)
| OscCommand::ConemuGuimacro(v)
| OscCommand::ConemuRunProcess(v)
| OscCommand::ConemuOutputEnvironmentVariable(v)
| OscCommand::ConemuComment(v) => put("data", bytes(v)),
OscCommand::SemanticPrompt(v) => {
put("action", crate::keys::tag_name(&v.action));
put("options_unvalidated", bytes(&v.options_unvalidated));
}
OscCommand::ClipboardContents { kind, data } => {
put("kind", kind.to_string());
put("data", bytes(data));
}
OscCommand::ReportPwd { value } | OscCommand::MouseShape { value } => {
put("value", bytes(value));
}
OscCommand::ColorOperation {
op,
requests: _,
terminator,
} => {
// `Operation` tags are named `osc_4`, `osc_104`, ...
let dbg = format!("{op:?}");
let name = dbg
.strip_prefix("Osc")
.map_or_else(|| crate::keys::snake_case(&dbg), |n| format!("osc_{n}"));
put("op", name);
put(
"requests",
"array_list.Aligned(terminal.osc.parsers.color.Request,null)".to_owned(),
);
put("terminator", terminator_name(*terminator).to_owned());
}
OscCommand::KittyColorProtocol(v) => {
put(
"list",
"array_list.Aligned(terminal.kitty.color.OSC.Request,null)".to_owned(),
);
put("terminator", terminator_name(v.terminator).to_owned());
}
OscCommand::ShowDesktopNotification { title, body } => {
put("title", bytes(title));
put("body", bytes(body));
}
OscCommand::HyperlinkStart { id, uri } => {
put(
"id",
id.as_deref().map_or_else(|| "(unset)".to_owned(), bytes),
);
put("uri", bytes(uri));
}
OscCommand::ConemuSleep { duration_ms } => put("duration_ms", duration_ms.to_string()),
OscCommand::ConemuChangeTabTitle(v) => match v {
osc::ConemuChangeTabTitle::Reset => put("data", "reset".to_owned()),
osc::ConemuChangeTabTitle::Value(s) => put("value", bytes(s)),
},
OscCommand::ConemuProgressReport(v) => {
put("state", crate::keys::tag_name(&v.state));
put(
"progress",
v.progress
.map_or_else(|| "(unset)".to_owned(), |p| p.to_string()),
);
}
OscCommand::ConemuXtermEmulation { keyboard, output } => {
put("keyboard", optional_bool(*keyboard));
put("output", optional_bool(*output));
}
OscCommand::KittyTextSizing(v) => {
put("scale", v.scale.to_string());
put("width", v.width.to_string());
put("numerator", v.numerator.to_string());
put("denominator", v.denominator.to_string());
put("valign", crate::keys::tag_name(&v.valign));
put("halign", crate::keys::tag_name(&v.halign));
put("text", bytes(&v.text));
}
OscCommand::KittyClipboardProtocol(v) => {
put("metadata", bytes(&v.metadata));
put(
"payload",
v.payload
.as_deref()
.map_or_else(|| "(unset)".to_owned(), bytes),
);
put("terminator", terminator_name(v.terminator).to_owned());
}
OscCommand::ContextSignal(v) => {
put("action", crate::keys::tag_name(&v.action));
put("id", bytes(&v.id));
put("metadata", bytes(&v.metadata));
}
}
}
/// The capture state of the VT handler: the terminal state at the time of
/// the events.
#[derive(Debug, Clone)]
struct CaptureState {
cursor: CursorSnapshot,
scrolling_region: ScrollingRegion,
}
/// Our VT stream handler for the Stream widget.
#[derive(Debug)]
pub struct VtHandler {
/// The capture state, must be set before use. If `None`, then
/// events are dropped.
state: Option<CaptureState>,
/// The event ring to write events to.
pub events: VecDeque<VtEvent>,
/// True to pause this artificially.
pub paused: bool,
/// Current sequence number
pub current_seq: usize,
/// Exclude certain actions by tag.
pub filter_exclude: ActionTagSet,
pub filter_text: TextFilter,
}
impl VtHandler {
fn new() -> Self {
let mut filter_exclude = ActionTagSet::default();
filter_exclude.insert(ActionTag::Print);
Self {
state: None,
events: VecDeque::with_capacity(MAX_EVENTS),
paused: false,
current_seq: 1,
filter_exclude,
filter_text: TextFilter::default(),
}
}
}
impl Handler for VtHandler {
type Error = Infallible;
fn vt(&mut self, _action: StreamAction<'_>) -> Result<(), Self::Error> {
Ok(())
}
/// This is called with every single terminal action.
fn vt_raw(&mut self, action: &ParserAction) -> Result<bool, Self::Error> {
let Some(state) = self.state.clone() else {
return Ok(true);
};
// We always increment the sequence number, even if we're paused or
// filter out the event. This helps show the user that there is a gap
// between events and roughly how large that gap was.
let seq = self.current_seq;
self.current_seq = self.current_seq.wrapping_add(1);
// If we're manually paused, we ignore all events.
if self.paused {
return Ok(true);
}
// We ignore certain action types that are too noisy.
if matches!(action, ParserAction::DcsPut(_) | ParserAction::ApcPut(_)) {
return Ok(true);
}
// If we requested a specific type to be ignored, ignore it.
// We return true because we did "handle" it by ignoring it.
if self.filter_exclude.contains(ActionTag::of(action)) {
return Ok(true);
}
// Build our event
let mut ev = VtEvent::new(state.cursor, state.scrolling_region, action);
ev.seq = seq;
// Check if the event passes the filter
if !ev.pass_filter(&self.filter_text) {
return Ok(true);
}
if self.events.len() >= MAX_EVENTS {
self.events.pop_front();
}
self.events.push_back(ev);
// Do NOT skip it, because we want to record more information
// about this event.
Ok(false)
}
}
/// Enum representing keyboard navigation actions
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyAction {
Down,
None,
Up,
}
/// Helper function to check keyboard state and determine navigation action.
fn get_key_action(ui: &Ui) -> KeyAction {
let keys = [
(Key::J, KeyAction::Down),
(Key::DownArrow, KeyAction::Down),
(Key::K, KeyAction::Up),
(Key::UpArrow, KeyAction::Up),
];
for (key, action) in keys {
if ui.is_key_pressed(key) {
return action;
}
}
KeyAction::None
}
/// VT event stream inspector widget.
pub struct Stream {
pub parser_stream: VtStream<VtHandler>,
/// The currently selected event sequence number for keyboard navigation
pub selected_event_seq: Option<u32>,
/// Flag indicating whether we need to scroll to the selected item
pub need_scroll_to_selected: bool,
/// Flag indicating whether the selection was made by keyboard
pub is_keyboard_selection: bool,
}
impl std::fmt::Debug for Stream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Stream")
.field("events", &self.parser_stream.handler.events.len())
.field("selected_event_seq", &self.selected_event_seq)
.finish_non_exhaustive()
}
}
impl Default for Stream {
fn default() -> Self {
Self::new()
}
}
impl Stream {
pub fn new() -> Self {
Self {
parser_stream: VtStream::new_alloc(VtHandler::new()),
selected_event_seq: None,
need_scroll_to_selected: false,
is_keyboard_selection: false,
}
}
pub fn events(&self) -> &VecDeque<VtEvent> {
&self.parser_stream.handler.events
}
pub fn record_pty_read(&mut self, t: &Terminal, data: &[u8]) {
self.parser_stream.handler.state = Some(CaptureState {
cursor: CursorSnapshot::new(&t.screens.active().cursor),
scrolling_region: t.scrolling_region,
});
let res = self.parser_stream.next_slice(data);
self.parser_stream.handler.state = None;
if let Err(err) = res {
log::warn!("error recording pty read in inspector err={err:?}");
}
}
pub fn draw(&mut self, ui: &Ui, palette: &Palette) {
let popup_filter = "Filter";
let handler = &mut self.parser_stream.handler;
// Controls
{
let pause_play = if handler.paused {
"Resume##pause_play"
} else {
"Pause##pause_play"
};
if ui.button(pause_play) {
handler.paused = !handler.paused;
}
let inner = ui.clone_style().item_inner_spacing()[0];
ui.same_line_with_spacing(0.0, inner);
if ui.button("Filter") {
ui.open_popup(popup_filter);
}
if !handler.events.is_empty() {
ui.same_line_with_spacing(0.0, inner);
if ui.button("Clear") {
handler.events.clear();
handler.current_seq = 1;
}
}
}
// Events Table
if handler.events.is_empty() {
ui.text("Waiting for events...");
}
{
ui.separator();
let table = ui.begin_table_with_flags(
"table_vt_events",
3,
TableFlags::ROW_BG | TableFlags::BORDERS,
);
if let Some(_table) = table {
ui.table_setup_column(
"Seq",
TableColumnFlags::NONE,
Some(TableColumnWidth::Fixed(0.0)),
);
ui.table_setup_column(
"Kind",
TableColumnFlags::NONE,
Some(TableColumnWidth::Fixed(0.0)),
);
ui.table_setup_column(
"Description",
TableColumnFlags::NONE,
Some(TableColumnWidth::Stretch(0.0)),
);
// Handle keyboard navigation when window is focused
if ui.is_window_focused_with_flags(FocusedFlags::ROOT_AND_CHILD_WINDOWS) {
let key_pressed = get_key_action(ui);
let events = &handler.events;
match key_pressed {
KeyAction::None => {}
KeyAction::Up | KeyAction::Down => {
// If no event is selected, select the first/last event based on direction
if let Some(selected) = self.selected_event_seq {
// Find next/previous event based on current selection
// (iterating newest to oldest).
match key_pressed {
KeyAction::Down => {
let mut found = false;
for ev in events.iter().rev() {
if found {
self.selected_event_seq = Some(ev.seq as u32);
break;
}
if ev.seq as u32 == selected {
found = true;
}
}
}
KeyAction::Up => {
let mut prev_ev: Option<&VtEvent> = None;
for ev in events.iter().rev() {
if ev.seq as u32 == selected
&& let Some(prev) = prev_ev
{
self.selected_event_seq = Some(prev.seq as u32);
break;
}
prev_ev = Some(ev);
}
}
KeyAction::None => {}
}
} else if !events.is_empty() {
// `.forward` iterator for up, `.reverse` for down.
let first = if key_pressed == KeyAction::Up {
events.front()
} else {
events.back()
};
if let Some(ev) = first {
self.selected_event_seq = Some(ev.seq as u32);
}
}
// Mark that we need to scroll to the newly selected item
self.need_scroll_to_selected = true;
self.is_keyboard_selection = true;
}
}
}
for (i, ev) in handler.events.iter_mut().enumerate().rev() {
// Need to push an ID so that our selectable is unique.
let _id = ui.push_id(i as i32);
ui.table_next_row();
ui.table_next_column();
// Store the previous selection state to detect changes
let was_selected = ev.imgui_selected;
// Update selection state based on keyboard navigation
if let Some(seq) = self.selected_event_seq {
ev.imgui_selected = ev.seq as u32 == seq;
}
// Handle selectable widget
if ui
.selectable_config("##select")
.selected(ev.imgui_selected)
.flags(SelectableFlags::SPAN_ALL_COLUMNS)
.build()
{
ev.imgui_selected = !ev.imgui_selected;
// If selection state changed, update keyboard navigation state
if ev.imgui_selected != was_selected {
self.selected_event_seq = if ev.imgui_selected {
Some(ev.seq as u32)
} else {
None
};
self.is_keyboard_selection = false;
}
}
ui.same_line();
ui.text(ev.seq.to_string());
ui.table_next_column();
ui.text(ev.kind.name());
ui.table_next_column();
ui.text(&ev.raw_description);
// If the event is selected, we render info about it. For now
// we put this in the last column because that's the widest and
// imgui has no way to make a column span.
if ev.imgui_selected {
{
cursor_table(ui, &ev.cursor);
cursor_style(ui, &ev.cursor, Some(palette));
if let Some(_details) =
ui.begin_table_with_flags("details", 2, TableFlags::NONE)
{
ui.table_next_row();
{
ui.table_set_column_index(0);
ui.text("Scroll Region");
}
{
ui.table_set_column_index(1);
ui.text(format!(
"T={} B={} L={} R={}",
ev.scrolling_region.top,
ev.scrolling_region.bottom,
ev.scrolling_region.left,
ev.scrolling_region.right,
));
}
for (key, value) in &ev.metadata {
ui.table_next_row();
ui.table_next_column();
ui.text(key);
ui.table_next_column();
ui.text(value);
}
}
}
// If this is the selected event and scrolling is needed, scroll to it
if self.need_scroll_to_selected && self.is_keyboard_selection {
ui.set_scroll_here_y(0.5);
self.need_scroll_to_selected = false;
}
}
}
}
} // table
if let Some(_popup) = ui
.begin_modal_popup_config(popup_filter)
.flags(WindowFlags::ALWAYS_AUTO_RESIZE)
.begin()
{
ui.text("Changed filter settings will only affect future events.");
ui.separator();
if let Some(_t) = ui.begin_table_with_flags("table_filter_kind", 3, TableFlags::NONE) {
for tag in ActionTag::ALL {
if matches!(tag, ActionTag::ApcPut | ActionTag::DcsPut) {
continue;
}
ui.table_next_column();
let mut value = !handler.filter_exclude.contains(tag);
if ui.checkbox(tag.name(), &mut value) {
if value {
handler.filter_exclude.remove(tag);
} else {
handler.filter_exclude.insert(tag);
}
}
}
} // Filter kind table
ui.separator();
ui.text(
"Filter by string. Empty displays all, \"abc\" finds lines\n\
containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n\
or \"xyz\", \"-abc\" excludes lines containing \"abc\".",
);
if ui
.input_text("##filter_text", &mut handler.filter_text.input)
.build()
{
handler.filter_text.build();
}
ui.separator();
if ui.button("Close") {
ui.close_current_popup();
}
} // filter popup
}
}
#[cfg(test)]
mod tests {
use super::*;
fn filter(s: &str) -> TextFilter {
let mut f = TextFilter {
input: s.to_owned(),
..TextFilter::default()
};
f.build();
f
}
#[test]
fn text_filter() {
assert!(filter("").pass_filter("anything"));
assert!(filter("abc").pass_filter("xxABCxx"));
assert!(!filter("abc").pass_filter("xyz"));
assert!(filter("abc,xyz").pass_filter("xyz"));
assert!(!filter("-abc").pass_filter("zabcz"));
assert!(filter("-abc").pass_filter("zzz"));
assert!(filter("abc,-abc").pass_filter("abc"));
assert!(!filter("-abc,abc").pass_filter("abc"));
assert!(filter("-").pass_filter("abc"));
}
#[test]
fn encode_execute_names() {
assert_eq!(encode_execute(0x07), "BEL (0x7)");
assert_eq!(encode_execute(0x0A), "LF (0xA)");
assert_eq!(encode_execute(0x1f), "? (0x1F)");
}
#[test]
fn encode_print_format() {
assert_eq!(encode_print(u32::from('a')), "'a' (U+61)");
}
#[test]
fn encode_csi_and_esc() {
let mut stream = VtStream::new_alloc(VtHandler::new());
stream.handler.state = Some(CaptureState {
cursor: CursorSnapshot {
x: 0,
y: 0,
pending_wrap: false,
protected: false,
style: ghostty_terminal::style::Style::default(),
hyperlink_uri: None,
},
scrolling_region: ScrollingRegion {
top: 0,
bottom: 23,
left: 0,
right: 79,
},
});
stream.handler.filter_exclude = ActionTagSet::default();
// Like Zig, `vt_raw` only sees the parser actions of escape
// sequences: printable text and C0 controls in the ground state are
// dispatched directly to the handler.
stream
.next_slice(b"a\x07\x1b[1;2H\x1bM\x1b]0;title\x07")
.unwrap();
let descs: Vec<_> = stream
.handler
.events
.iter()
.map(|e| (e.seq, e.kind.name(), e.raw_description.clone()))
.collect();
assert_eq!(descs[0], (1, "csi", "1;2 H".to_owned()));
assert_eq!(descs[1], (2, "esc", "M".to_owned()));
assert_eq!(descs[2].1, "osc");
assert_eq!(descs[2].2, "change_window_title ");
assert_eq!(
stream.handler.events[2].metadata,
vec![("data".to_owned(), "title".to_owned())]
);
}
#[test]
fn paused_still_increments_seq_and_drops() {
let mut stream = VtStream::new_alloc(VtHandler::new());
stream.handler.state = None;
stream.next_slice(b"\x07").unwrap();
// No capture state: events dropped and the sequence is untouched.
assert!(stream.handler.events.is_empty());
assert_eq!(stream.handler.current_seq, 1);
}
}