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(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,
pending_search: Rc<RefCell<Option<glib::SourceId>>>,
committed_query: 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>,
pending_search: Rc<RefCell<Option<glib::SourceId>>>,
committed_query: 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(),
pending_search: self.pending_search.clone(),
committed_query: self.committed_query.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()
}
}
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()?,
pending_search: self.pending_search.clone(),
committed_query: self.committed_query.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) -> Option<String> {
(!query.is_empty()).then(|| glib::Regex::escape_string(query).to_string())
}
fn query_exceeds_limit(query: &str) -> bool {
query.chars().take(MAX_SEARCH_CHARACTERS + 1).count() > MAX_SEARCH_CHARACTERS
}
fn query_is_committed(query: &str, committed_query: Option<&str>) -> bool {
committed_query == Some(query)
}
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::Horizontal, 5);
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(16);
entry.set_max_width_chars(28);
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("Literal, case-sensitive search (Ctrl+Shift+F)"));
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_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("Prev", "Find previous match", "Shift+Enter");
let next = search_button("Next", "Find next match", "Enter");
let close = search_button("X", "Close search", "Escape");
close.add_css_class("lios-search-close");
previous.set_sensitive(false);
next.set_sensitive(false);
row.append(&entry);
row.append(&status);
row.append(&previous);
row.append(&next);
row.append(&close);
revealer.set_child(Some(&row));
let search = SearchOverlay {
revealer,
container: row,
entry,
status,
previous,
next,
pending_search: Rc::new(RefCell::new(None)),
committed_query: Rc::new(RefCell::new(None)),
live_search_suppressed: Rc::new(Cell::new(false)),
entry_focused,
};
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_query.borrow_mut().take();
}
fn queue_terminal_search(terminal: &vte::Terminal, search: &SearchOverlay, query: &str) {
cancel_pending_search(search);
clear_terminal_search(terminal, search);
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 already_committed = query_is_committed(query, search.committed_query.borrow().as_deref());
if !already_committed {
let Some(pattern) = literal_search_pattern(query) else {
clear_terminal_search(terminal, search);
set_search_feedback(search, SearchFeedback::Empty);
return;
};
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);
*search.committed_query.borrow_mut() = Some(query.to_owned());
}
let started = Instant::now();
let found = match direction {
SearchDirection::Previous => terminal.search_find_previous(),
SearchDirection::Next => terminal.search_find_next(),
};
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
.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.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.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)]
mod tests {
use super::*;
#[test]
fn literal_search_escapes_metacharacters_without_changing_text() {
let pattern = literal_search_pattern(" Café.*[x] ").unwrap();
assert_eq!(pattern, r" Café\.\*\[x\] ");
assert_eq!(literal_search_pattern(""), 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!(query_is_committed("needle", Some("needle")));
assert!(!query_is_committed("needle", Some("other")));
assert!(!query_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);
}
}