use gtk::glib;
use gtk::prelude::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::{Duration, Instant};
use vte::prelude::*;
use crate::terminal::VTE_REGEX_FLAGS;
const SEARCH_DEBOUNCE_DELAY: Duration = Duration::from_millis(100);
const SEARCH_UI_YIELD_DELAY: Duration = Duration::from_millis(16);
const SLOW_SEARCH_THRESHOLD: Duration = Duration::from_millis(50);
const MAX_LIVE_SEARCH_ROWS: u64 = 10_000;
const MAX_SEARCH_CHARACTERS: usize = 1_024;
const MAX_ENTRY_CHARACTERS: i32 = MAX_SEARCH_CHARACTERS as i32 + 1;
const PCRE2_JIT_COMPLETE: u32 = 0x0000_0001;
const PCRE2_JIT_PARTIAL_SOFT: u32 = 0x0000_0002;
const PCRE2_SEARCH_JIT_FLAGS: u32 = PCRE2_JIT_COMPLETE | PCRE2_JIT_PARTIAL_SOFT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SearchOptions {
match_case: bool,
whole_words: bool,
}
impl Default for SearchOptions {
fn default() -> Self {
Self {
match_case: true,
whole_words: false,
}
}
}
#[derive(Clone)]
pub(crate) struct SearchOverlay {
pub(crate) revealer: gtk::Revealer,
container: gtk::Box,
entry: gtk::Entry,
status: gtk::Label,
previous: gtk::Button,
next: gtk::Button,
match_case: gtk::ToggleButton,
whole_words: gtk::ToggleButton,
pending_search: Rc<RefCell<Option<glib::SourceId>>>,
committed_pattern: Rc<RefCell<Option<String>>>,
live_search_suppressed: Rc<Cell<bool>>,
entry_focused: Rc<Cell<bool>>,
}
pub(crate) struct WeakSearchOverlay {
revealer: glib::WeakRef<gtk::Revealer>,
container: glib::WeakRef<gtk::Box>,
entry: glib::WeakRef<gtk::Entry>,
status: glib::WeakRef<gtk::Label>,
previous: glib::WeakRef<gtk::Button>,
next: glib::WeakRef<gtk::Button>,
match_case: glib::WeakRef<gtk::ToggleButton>,
whole_words: glib::WeakRef<gtk::ToggleButton>,
pending_search: Rc<RefCell<Option<glib::SourceId>>>,
committed_pattern: Rc<RefCell<Option<String>>>,
live_search_suppressed: Rc<Cell<bool>>,
entry_focused: Rc<Cell<bool>>,
}
impl SearchOverlay {
pub(crate) fn downgrade(&self) -> WeakSearchOverlay {
WeakSearchOverlay {
revealer: self.revealer.downgrade(),
container: self.container.downgrade(),
entry: self.entry.downgrade(),
status: self.status.downgrade(),
previous: self.previous.downgrade(),
next: self.next.downgrade(),
match_case: self.match_case.downgrade(),
whole_words: self.whole_words.downgrade(),
pending_search: self.pending_search.clone(),
committed_pattern: self.committed_pattern.clone(),
live_search_suppressed: self.live_search_suppressed.clone(),
entry_focused: self.entry_focused.clone(),
}
}
pub(crate) fn entry_has_focus(&self) -> bool {
self.entry_focused.get()
}
fn options(&self) -> SearchOptions {
SearchOptions {
match_case: self.match_case.is_active(),
whole_words: self.whole_words.is_active(),
}
}
}
impl WeakSearchOverlay {
pub(crate) fn upgrade(&self) -> Option<SearchOverlay> {
Some(SearchOverlay {
revealer: self.revealer.upgrade()?,
container: self.container.upgrade()?,
entry: self.entry.upgrade()?,
status: self.status.upgrade()?,
previous: self.previous.upgrade()?,
next: self.next.upgrade()?,
match_case: self.match_case.upgrade()?,
whole_words: self.whole_words.upgrade()?,
pending_search: self.pending_search.clone(),
committed_pattern: self.committed_pattern.clone(),
live_search_suppressed: self.live_search_suppressed.clone(),
entry_focused: self.entry_focused.clone(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchFeedback {
Empty,
Searching,
Deferred,
Match,
NoMatch,
TooLong,
Unavailable,
}
impl SearchFeedback {
fn for_result(query: &str, regex_available: bool, found: bool) -> Self {
if query.is_empty() {
Self::Empty
} else if !regex_available {
Self::Unavailable
} else if found {
Self::Match
} else {
Self::NoMatch
}
}
fn status(self) -> &'static str {
match self {
Self::Empty | Self::Match => "",
Self::Searching => "Searching...",
Self::Deferred => "Press Enter to search",
Self::NoMatch => "No matches",
Self::TooLong => "Search is limited to 1,024 characters",
Self::Unavailable => "Search unavailable",
}
}
fn accessible_status(self) -> &'static str {
match self {
Self::Empty => "No search query",
Self::Match => "Match found",
_ => self.status(),
}
}
fn actions_enabled(self) -> bool {
matches!(
self,
Self::Searching | Self::Deferred | Self::Match | Self::NoMatch
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutomaticSearchPolicy {
Empty,
Debounced,
Deferred,
TooLong,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchTrigger {
Automatic,
Explicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SearchDirection {
Previous,
Next,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OverlayDismissal {
Search,
Preferences,
None,
}
pub(crate) fn overlay_dismissal(search_open: bool, preferences_open: bool) -> OverlayDismissal {
if search_open {
OverlayDismissal::Search
} else if preferences_open {
OverlayDismissal::Preferences
} else {
OverlayDismissal::None
}
}
fn literal_search_pattern(query: &str, options: SearchOptions) -> Option<String> {
if query.is_empty() {
return None;
}
let mut pattern = glib::Regex::escape_string(query).to_string();
if !options.match_case {
pattern = format!("(?i:{pattern})");
}
if options.whole_words {
pattern = format!(r"(?<![\p{{L}}\p{{N}}\p{{M}}_]){pattern}(?![\p{{L}}\p{{N}}\p{{M}}_])");
}
Some(pattern)
}
fn query_exceeds_limit(query: &str) -> bool {
query.chars().take(MAX_SEARCH_CHARACTERS + 1).count() > MAX_SEARCH_CHARACTERS
}
fn pattern_is_committed(pattern: &str, committed_pattern: Option<&str>) -> bool {
committed_pattern == Some(pattern)
}
fn automatic_search_policy(
query: &str,
buffer_rows: Option<u64>,
live_search_suppressed: bool,
) -> AutomaticSearchPolicy {
if query.is_empty() {
AutomaticSearchPolicy::Empty
} else if query_exceeds_limit(query) {
AutomaticSearchPolicy::TooLong
} else if live_search_suppressed || buffer_rows.is_none_or(|rows| rows > MAX_LIVE_SEARCH_ROWS) {
AutomaticSearchPolicy::Deferred
} else {
AutomaticSearchPolicy::Debounced
}
}
fn automatic_policy_feedback(policy: AutomaticSearchPolicy) -> Option<SearchFeedback> {
match policy {
AutomaticSearchPolicy::Empty => Some(SearchFeedback::Empty),
AutomaticSearchPolicy::Deferred => Some(SearchFeedback::Deferred),
AutomaticSearchPolicy::TooLong => Some(SearchFeedback::TooLong),
AutomaticSearchPolicy::Debounced => None,
}
}
fn buffer_row_span(lower: f64, upper: f64) -> Option<u64> {
let rows = upper - lower;
(rows.is_finite() && rows >= 0.0).then(|| rows.ceil() as u64)
}
fn terminal_buffer_rows(terminal: &vte::Terminal) -> Option<u64> {
if terminal.is_scroll_unit_is_pixels() {
return None;
}
terminal
.vadjustment()
.and_then(|adjustment| buffer_row_span(adjustment.lower(), adjustment.upper()))
}
fn should_initialize_search_session(search_open: bool) -> bool {
!search_open
}
pub(crate) fn build_search_revealer(terminal: &vte::Terminal) -> SearchOverlay {
let revealer = gtk::Revealer::new();
revealer.set_reveal_child(false);
revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown);
revealer.set_transition_duration(140);
let row = gtk::Box::new(gtk::Orientation::Vertical, 5);
row.set_sensitive(false);
row.add_css_class("lios-search");
row.set_accessible_role(gtk::AccessibleRole::Search);
row.update_property(&[gtk::accessible::Property::Label("Find in terminal")]);
row.update_state(&[gtk::accessible::State::Hidden(true)]);
let entry = gtk::Entry::new();
entry.add_css_class("lios-search-entry");
entry.set_placeholder_text(Some("Find in terminal"));
entry.set_width_chars(10);
entry.set_max_width_chars(22);
entry.set_hexpand(true);
entry.set_max_length(MAX_ENTRY_CHARACTERS);
entry.set_accessible_role(gtk::AccessibleRole::SearchBox);
entry.update_property(&[
gtk::accessible::Property::Label("Search terminal scrollback"),
gtk::accessible::Property::KeyShortcuts("Control+Shift+F"),
]);
entry.set_tooltip_text(Some(
"Find literal text (Ctrl+Shift+F). Use Match case and Whole words to refine results.",
));
entry.set_sensitive(false);
let entry_focused = Rc::new(Cell::new(false));
let entry_focus = gtk::EventControllerFocus::new();
entry_focus.connect_contains_focus_notify({
let entry_focused = entry_focused.clone();
move |focus| entry_focused.set(focus.contains_focus())
});
entry.add_controller(entry_focus);
let status = gtk::Label::new(None);
status.add_css_class("lios-search-status");
status.set_xalign(0.0);
status.set_wrap(true);
status.set_max_width_chars(30);
status.set_visible(false);
status.set_accessible_role(gtk::AccessibleRole::Status);
status.set_tooltip_text(Some("Terminal search status"));
entry.update_relation(&[gtk::accessible::Relation::DescribedBy(&[
status.upcast_ref()
])]);
let previous = search_button("↑", "Find previous match", "Shift+Enter");
let next = search_button("↓", "Find next match", "Enter");
let close = search_button("×", "Close search", "Escape");
close.add_css_class("lios-search-close");
previous.set_sensitive(false);
next.set_sensitive(false);
let query_row = gtk::Box::new(gtk::Orientation::Horizontal, 5);
query_row.append(&entry);
query_row.append(&previous);
query_row.append(&next);
query_row.append(&close);
let options_row = gtk::Box::new(gtk::Orientation::Horizontal, 5);
let defaults = SearchOptions::default();
let match_case = gtk::ToggleButton::with_label("Match case");
match_case.set_active(defaults.match_case);
match_case.set_tooltip_text(Some(
"On: Error differs from error. Off: ignore letter case.",
));
let whole_words = gtk::ToggleButton::with_label("Whole words");
whole_words.set_active(defaults.whole_words);
whole_words.set_tooltip_text(Some(
"Exclude matches inside longer words or identifiers (including underscores).",
));
for button in [&match_case, &whole_words] {
button.add_css_class("lios-search-button");
options_row.append(button);
}
row.append(&query_row);
row.append(&options_row);
row.append(&status);
revealer.set_child(Some(&row));
let search = SearchOverlay {
revealer,
container: row,
entry,
status,
previous,
next,
match_case,
whole_words,
pending_search: Rc::new(RefCell::new(None)),
committed_pattern: Rc::new(RefCell::new(None)),
live_search_suppressed: Rc::new(Cell::new(false)),
entry_focused,
};
for option in [&search.match_case, &search.whole_words] {
option.connect_toggled({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_| {
if let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) {
queue_terminal_search(&terminal, &search, search.entry.text().as_str());
}
}
});
}
search.entry.connect_changed({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |entry| {
let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) else {
return;
};
queue_terminal_search(&terminal, &search, entry.text().as_str());
}
});
search.entry.connect_activate({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_| {
let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) else {
return;
};
submit_or_navigate_search(&terminal, &search, SearchDirection::Next);
}
});
let entry_keys = gtk::EventControllerKey::new();
entry_keys.set_propagation_phase(gtk::PropagationPhase::Capture);
entry_keys.connect_key_pressed({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_, key, _, state| {
if matches!(key, gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter)
&& state.contains(gtk::gdk::ModifierType::SHIFT_MASK)
&& !state.intersects(
gtk::gdk::ModifierType::CONTROL_MASK
| gtk::gdk::ModifierType::ALT_MASK
| gtk::gdk::ModifierType::SUPER_MASK
| gtk::gdk::ModifierType::META_MASK,
)
{
if let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) {
submit_or_navigate_search(&terminal, &search, SearchDirection::Previous);
}
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
}
});
search.entry.add_controller(entry_keys);
search.previous.connect_clicked({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_| {
let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) else {
return;
};
submit_or_navigate_search(&terminal, &search, SearchDirection::Previous);
}
});
search.next.connect_clicked({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_| {
let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) else {
return;
};
submit_or_navigate_search(&terminal, &search, SearchDirection::Next);
}
});
close.connect_clicked({
let search = search.downgrade();
let terminal = terminal.downgrade();
move |_| {
let (Some(search), Some(terminal)) = (search.upgrade(), terminal.upgrade()) else {
return;
};
close_search(&search, &terminal, true);
}
});
search
}
fn search_button(label: &str, tooltip: &str, shortcut: &str) -> gtk::Button {
let button = gtk::Button::with_label(label);
button.add_css_class("lios-search-button");
button.set_tooltip_text(Some(tooltip));
button.update_property(&[
gtk::accessible::Property::Label(tooltip),
gtk::accessible::Property::KeyShortcuts(shortcut),
]);
button
}
fn clear_terminal_search(terminal: &vte::Terminal, search: &SearchOverlay) {
terminal.search_set_regex(None, 0);
search.committed_pattern.borrow_mut().take();
}
fn queue_terminal_search(terminal: &vte::Terminal, search: &SearchOverlay, query: &str) {
cancel_pending_search(search);
clear_terminal_search(terminal, search);
if !search.revealer.reveals_child() {
return;
}
let policy = automatic_search_policy(
query,
terminal_buffer_rows(terminal),
search.live_search_suppressed.get(),
);
if let Some(feedback) = automatic_policy_feedback(policy) {
set_search_feedback(search, feedback);
} else {
set_search_feedback(search, SearchFeedback::Searching);
schedule_terminal_search(
terminal,
search,
query.to_owned(),
SearchDirection::Next,
SearchTrigger::Automatic,
SEARCH_DEBOUNCE_DELAY,
);
}
}
fn schedule_terminal_search(
terminal: &vte::Terminal,
search: &SearchOverlay,
query: String,
direction: SearchDirection,
trigger: SearchTrigger,
delay: Duration,
) {
let weak_search = search.downgrade();
let weak_terminal = terminal.downgrade();
let source = glib::timeout_add_local_once(delay, move || {
let Some(search) = weak_search.upgrade() else {
return;
};
search.pending_search.borrow_mut().take();
if !search.revealer.reveals_child() || !search.entry.is_sensitive() {
return;
}
let Some(terminal) = weak_terminal.upgrade() else {
return;
};
if search.entry.text().as_str() != query {
return;
}
if trigger == SearchTrigger::Automatic {
let policy = automatic_search_policy(
&query,
terminal_buffer_rows(&terminal),
search.live_search_suppressed.get(),
);
if let Some(feedback) = automatic_policy_feedback(policy) {
set_search_feedback(&search, feedback);
return;
}
}
execute_terminal_search(&terminal, &search, &query, direction);
});
*search.pending_search.borrow_mut() = Some(source);
}
fn execute_terminal_search(
terminal: &vte::Terminal,
search: &SearchOverlay,
query: &str,
direction: SearchDirection,
) {
if query.is_empty() {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::Empty);
return;
}
if query_exceeds_limit(query) {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::TooLong);
return;
}
let Some(pattern) = literal_search_pattern(query, search.options()) else {
return;
};
let already_committed =
pattern_is_committed(&pattern, search.committed_pattern.borrow().as_deref());
if !already_committed {
let Ok(regex) = vte::Regex::for_search(&pattern, VTE_REGEX_FLAGS) else {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::Unavailable);
return;
};
let _ = regex.jit(PCRE2_SEARCH_JIT_FLAGS);
terminal.search_set_regex(Some(®ex), 0);
terminal.search_set_wrap_around(true);
terminal.unselect_all();
*search.committed_pattern.borrow_mut() = Some(pattern);
}
let started = Instant::now();
let had_selection = terminal.has_selection();
let find = || match direction {
SearchDirection::Previous => terminal.search_find_previous(),
SearchDirection::Next => terminal.search_find_next(),
};
let mut found = find();
if !found && had_selection {
terminal.unselect_all();
found = find();
}
if started.elapsed() >= SLOW_SEARCH_THRESHOLD {
search.live_search_suppressed.set(true);
}
set_search_feedback(search, SearchFeedback::for_result(query, true, found));
}
fn cancel_pending_search(search: &SearchOverlay) {
if let Some(source) = search.pending_search.borrow_mut().take() {
source.remove();
}
}
fn submit_or_navigate_search(
terminal: &vte::Terminal,
search: &SearchOverlay,
direction: SearchDirection,
) {
cancel_pending_search(search);
let query = search.entry.text().to_string();
if query.is_empty() {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::Empty);
return;
}
if query_exceeds_limit(&query) {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::TooLong);
return;
}
set_search_feedback(search, SearchFeedback::Searching);
schedule_terminal_search(
terminal,
search,
query,
direction,
SearchTrigger::Explicit,
SEARCH_UI_YIELD_DELAY,
);
}
fn set_search_feedback(search: &SearchOverlay, feedback: SearchFeedback) {
search.status.set_text(feedback.status());
search.status.set_visible(feedback != SearchFeedback::Empty);
search
.status
.update_property(&[gtk::accessible::Property::Label(
feedback.accessible_status(),
)]);
search.status.remove_css_class("lios-search-warning");
if matches!(
feedback,
SearchFeedback::NoMatch | SearchFeedback::TooLong | SearchFeedback::Unavailable
) {
search.status.add_css_class("lios-search-warning");
}
let enabled = feedback.actions_enabled();
search.previous.set_sensitive(enabled);
search.next.set_sensitive(enabled);
}
pub(crate) fn open_search(
search: &SearchOverlay,
preferences: >k::Revealer,
terminal: &vte::Terminal,
) {
let initialize_session = should_initialize_search_session(search.revealer.reveals_child());
preferences.set_reveal_child(false);
search.container.set_sensitive(true);
search.entry.set_sensitive(true);
search.revealer.set_reveal_child(true);
search
.container
.update_state(&[gtk::accessible::State::Hidden(false)]);
if initialize_session {
cancel_pending_search(search);
search.live_search_suppressed.set(false);
queue_terminal_search(terminal, search, search.entry.text().as_str());
}
search.entry.grab_focus();
search.entry.select_region(0, -1);
}
pub(crate) fn close_search(search: &SearchOverlay, terminal: &vte::Terminal, focus_terminal: bool) {
cancel_pending_search(search);
search.container.set_sensitive(false);
search.entry.set_sensitive(false);
search.revealer.set_reveal_child(false);
search
.container
.update_state(&[gtk::accessible::State::Hidden(true)]);
clear_terminal_search(terminal, search);
search.live_search_suppressed.set(false);
if focus_terminal {
terminal.grab_focus();
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn matching_modes_keep_input_literal_and_invalidate_cached_patterns() {
let default = SearchOptions::default();
assert!(default.match_case);
assert!(!default.whole_words);
let plain = literal_search_pattern("C++.*", default).unwrap();
let insensitive = literal_search_pattern(
"C++.*",
SearchOptions {
match_case: false,
..default
},
)
.unwrap();
let word = literal_search_pattern(
"C++.*",
SearchOptions {
whole_words: true,
..default
},
)
.unwrap();
assert_eq!(plain, r"C\+\+\.\*");
assert_eq!(insensitive, r"(?i:C\+\+\.\*)");
assert!(!pattern_is_committed(&insensitive, Some(&plain)));
assert!(!pattern_is_committed(&word, Some(&plain)));
for match_case in [true, false] {
for whole_words in [true, false] {
let options = SearchOptions {
match_case,
whole_words,
};
assert!(literal_search_pattern("", options).is_none());
for input in [
"(?i)(a+)+$",
"café",
"e\u{301}",
"[a-z]",
"\\E\\Q",
"C++",
" cat ",
] {
let pattern = literal_search_pattern(input, options).unwrap();
assert!(
vte::Regex::for_search(&pattern, VTE_REGEX_FLAGS).is_ok(),
"{pattern}"
);
}
}
}
}
pub(crate) fn exercise_search_ui(
app: >k::Application,
screenshot: impl Fn(>k::ApplicationWindow, &str),
) {
fn spin() {
let until = Instant::now() + Duration::from_millis(180);
let context = glib::MainContext::default();
while Instant::now() < until {
for _ in 0..100 {
if !context.pending() {
break;
}
context.iteration(false);
}
std::thread::sleep(Duration::from_millis(5));
}
}
let terminal = vte::Terminal::new();
terminal.set_hexpand(true);
terminal.set_vexpand(true);
let search = build_search_revealer(&terminal);
let preferences = gtk::Revealer::new();
let root = gtk::Overlay::new();
root.set_child(Some(&terminal));
search.revealer.set_halign(gtk::Align::Center);
search.revealer.set_valign(gtk::Align::Start);
search.revealer.set_margin_start(12);
search.revealer.set_margin_end(12);
root.add_overlay(&search.revealer);
root.set_clip_overlay(&search.revealer, true);
let window = gtk::ApplicationWindow::builder()
.application(app)
.default_width(360)
.default_height(360)
.decorated(false)
.build();
window.set_child(Some(&root));
window.present();
preferences.set_reveal_child(true);
open_search(&search, &preferences, &terminal);
spin();
assert!(!preferences.reveals_child());
assert!(search.entry_has_focus());
assert_eq!(search.options(), SearchOptions::default());
for (text, query, match_case, whole_words, found) in [
("Error", "error", true, false, false),
("Error", "error", false, false, true),
("CAFÉ", "café", false, true, true),
("concatenate", "cat", true, false, true),
("concatenate", "cat", true, true, false),
("cat_id", "cat", true, true, false),
("scat cat!", "cat", true, true, true),
("écat", "cat", true, true, false),
("caté", "cat", true, true, false),
("cat\u{301}", "cat", true, true, false),
("C++17", "C++", true, true, false),
("(C++)", "C++", true, true, true),
("file.txt", ".*", false, false, false),
("a.*b", ".*", false, false, true),
] {
terminal.reset(true, true);
terminal.feed(format!("{text}\r\n").as_bytes());
search.entry.set_text(query);
search.match_case.set_active(match_case);
search.whole_words.set_active(whole_words);
spin();
cancel_pending_search(&search);
execute_terminal_search(&terminal, &search, query, SearchDirection::Next);
assert_eq!(
search.status.text().is_empty(),
found,
"text={text:?}, query={query:?}, case={match_case}, words={whole_words}, status={}",
search.status.text()
);
if found {
assert_eq!(
terminal
.text_selected(vte::Format::Text)
.unwrap()
.to_lowercase(),
query.to_lowercase()
);
}
}
terminal.reset(true, true);
terminal.feed(b"Error\r\n");
search.entry.set_text("error");
search.match_case.set_active(true);
spin();
assert_eq!(search.status.text(), "No matches");
search.match_case.set_active(false);
spin();
assert_eq!(search.status.text(), "");
search.whole_words.set_active(true);
search.live_search_suppressed.set(true);
search.match_case.set_active(true);
search.match_case.set_active(false);
assert_eq!(search.status.text(), "Press Enter to search");
assert!(search.pending_search.borrow().is_none());
search.next.emit_clicked();
spin();
assert_eq!(search.status.text(), "");
search.previous.emit_clicked();
spin();
assert_eq!(search.status.text(), "");
search.entry.set_text("pending");
search.next.emit_clicked();
close_search(&search, &terminal, true);
assert!(search.pending_search.borrow().is_none());
assert!(search.committed_pattern.borrow().is_none());
assert!(!search.container.is_sensitive());
assert!(terminal.has_focus());
search.entry.set_text("error");
assert!(search.pending_search.borrow().is_none());
open_search(&search, &preferences, &terminal);
spin();
assert!(!search.match_case.is_active());
assert!(search.whole_words.is_active());
assert_eq!(search.status.text(), "");
assert_eq!(window.width(), 360);
for feedback in [
SearchFeedback::NoMatch,
SearchFeedback::TooLong,
SearchFeedback::Deferred,
] {
set_search_feedback(&search, feedback);
spin();
assert!(
search.container.width() <= window.width() - 24,
"search must fit narrow windows"
);
for widget in [
search.entry.upcast_ref::<gtk::Widget>(),
search.next.upcast_ref(),
search.previous.upcast_ref(),
search.match_case.upcast_ref(),
search.whole_words.upcast_ref(),
search.status.upcast_ref(),
] {
let bounds = widget.compute_bounds(&window).unwrap();
assert!(
bounds.x() >= 0.0 && bounds.x() + bounds.width() <= window.width() as f32,
"search control must remain inside the window"
);
}
}
screenshot(&window, "search-narrow.png");
close_search(&search, &terminal, false);
window.destroy();
spin();
}
#[test]
fn literal_search_escapes_metacharacters_without_changing_text() {
let pattern = literal_search_pattern(" Café.*[x] ", SearchOptions::default()).unwrap();
assert_eq!(pattern, r" Café\.\*\[x\] ");
assert_eq!(literal_search_pattern("", SearchOptions::default()), None);
}
#[test]
fn search_feedback_controls_status_and_actions() {
let cases = [
(SearchFeedback::Empty, "", "No search query", false),
(
SearchFeedback::Searching,
"Searching...",
"Searching...",
true,
),
(
SearchFeedback::Deferred,
"Press Enter to search",
"Press Enter to search",
true,
),
(SearchFeedback::Match, "", "Match found", true),
(SearchFeedback::NoMatch, "No matches", "No matches", true),
(
SearchFeedback::TooLong,
"Search is limited to 1,024 characters",
"Search is limited to 1,024 characters",
false,
),
(
SearchFeedback::Unavailable,
"Search unavailable",
"Search unavailable",
false,
),
];
for (feedback, status, accessible_status, actions_enabled) in cases {
assert_eq!(feedback.status(), status);
assert_eq!(feedback.accessible_status(), accessible_status);
assert_eq!(feedback.actions_enabled(), actions_enabled);
}
assert_eq!(
SearchFeedback::for_result("", true, true),
SearchFeedback::Empty
);
assert_eq!(
SearchFeedback::for_result("query", false, false),
SearchFeedback::Unavailable
);
assert_eq!(
SearchFeedback::for_result("query", true, false),
SearchFeedback::NoMatch
);
assert_eq!(
SearchFeedback::for_result("query", true, true),
SearchFeedback::Match
);
}
#[test]
fn automatic_search_defers_large_slow_and_unmeasurable_buffers() {
assert_eq!(
automatic_search_policy("needle", Some(MAX_LIVE_SEARCH_ROWS), false),
AutomaticSearchPolicy::Debounced
);
assert_eq!(
automatic_search_policy("needle", Some(MAX_LIVE_SEARCH_ROWS + 1), false),
AutomaticSearchPolicy::Deferred
);
assert_eq!(
automatic_search_policy("needle", Some(10), true),
AutomaticSearchPolicy::Deferred
);
assert_eq!(
automatic_search_policy("needle", None, false),
AutomaticSearchPolicy::Deferred
);
assert_eq!(
automatic_search_policy("", None, true),
AutomaticSearchPolicy::Empty
);
assert_eq!(
automatic_policy_feedback(AutomaticSearchPolicy::Debounced),
None
);
assert_eq!(
automatic_policy_feedback(AutomaticSearchPolicy::Deferred),
Some(SearchFeedback::Deferred)
);
}
#[test]
fn search_query_and_buffer_measurements_have_defensive_limits() {
let maximum = "x".repeat(MAX_SEARCH_CHARACTERS);
assert!(!query_exceeds_limit(&maximum));
assert!(query_exceeds_limit(&(maximum + "x")));
assert!(!query_exceeds_limit(&"é".repeat(MAX_SEARCH_CHARACTERS)));
assert!(query_exceeds_limit(&"é".repeat(MAX_SEARCH_CHARACTERS + 1)));
assert_eq!(
automatic_search_policy(&"x".repeat(MAX_SEARCH_CHARACTERS + 1), None, true,),
AutomaticSearchPolicy::TooLong
);
assert_eq!(buffer_row_span(0.0, 10_000.0), Some(10_000));
assert_eq!(buffer_row_span(0.0, 10_000.01), Some(10_001));
assert_eq!(buffer_row_span(-25.0, 10.2), Some(36));
assert_eq!(buffer_row_span(5.0, 4.0), None);
assert_eq!(buffer_row_span(0.0, f64::NAN), None);
assert_eq!(buffer_row_span(0.0, f64::INFINITY), None);
assert!(pattern_is_committed("needle", Some("needle")));
assert!(!pattern_is_committed("needle", Some("other")));
assert!(!pattern_is_committed("needle", None));
}
#[test]
fn repeated_open_preserves_the_current_search_session() {
assert!(should_initialize_search_session(false));
assert!(!should_initialize_search_session(true));
}
#[test]
fn escape_dismisses_search_before_preferences() {
assert_eq!(overlay_dismissal(true, true), OverlayDismissal::Search);
assert_eq!(
overlay_dismissal(false, true),
OverlayDismissal::Preferences
);
assert_eq!(overlay_dismissal(false, false), OverlayDismissal::None);
}
}