use gtk::glib;
use gtk::prelude::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use vte::prelude::*;
use crate::terminal::VTE_REGEX_FLAGS;
#[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>>>,
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>>>,
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(),
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(),
entry_focused: self.entry_focused.clone(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchFeedback {
Empty,
Searching,
Match,
NoMatch,
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::NoMatch => "No matches",
Self::Unavailable => "Search unavailable",
}
}
fn navigation_enabled(self) -> bool {
matches!(self, Self::Match)
}
}
#[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())
}
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_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"));
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)),
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;
};
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()) {
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;
};
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;
};
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 update_terminal_search(terminal: &vte::Terminal, search: &SearchOverlay, query: &str) {
let Some(pattern) = literal_search_pattern(query) else {
terminal.search_set_regex(None, 0);
set_search_feedback(search, SearchFeedback::Empty);
return;
};
match vte::Regex::for_search(&pattern, VTE_REGEX_FLAGS) {
Ok(regex) => {
terminal.search_set_regex(Some(®ex), 0);
terminal.search_set_wrap_around(true);
let found = terminal.search_find_next();
set_search_feedback(search, SearchFeedback::for_result(query, true, found));
}
Err(_) => {
terminal.search_set_regex(None, 0);
set_search_feedback(search, SearchFeedback::Unavailable);
}
}
}
fn queue_terminal_search(terminal: &vte::Terminal, search: &SearchOverlay, query: &str) {
cancel_pending_search(search);
if query.is_empty() {
update_terminal_search(terminal, search, query);
return;
}
set_search_feedback(search, SearchFeedback::Searching);
let weak_search = search.downgrade();
let weak_terminal = terminal.downgrade();
let source = glib::timeout_add_local_once(Duration::from_millis(100), 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;
};
update_terminal_search(&terminal, &search, search.entry.text().as_str());
});
*search.pending_search.borrow_mut() = Some(source);
}
fn cancel_pending_search(search: &SearchOverlay) {
if let Some(source) = search.pending_search.borrow_mut().take() {
source.remove();
}
}
pub(crate) fn navigate_search(
terminal: &vte::Terminal,
search: &SearchOverlay,
direction: SearchDirection,
) {
if !search.next.is_sensitive() {
return;
}
let found = match direction {
SearchDirection::Previous => terminal.search_find_previous(),
SearchDirection::Next => terminal.search_find_next(),
};
set_search_feedback(
search,
SearchFeedback::for_result(search.entry.text().as_str(), true, found),
);
}
fn set_search_feedback(search: &SearchOverlay, feedback: SearchFeedback) {
search.status.set_text(feedback.status());
search
.status
.update_property(&[gtk::accessible::Property::Label(feedback.status())]);
search.status.remove_css_class("lios-search-warning");
if matches!(
feedback,
SearchFeedback::NoMatch | SearchFeedback::Unavailable
) {
search.status.add_css_class("lios-search-warning");
search.status.announce(
feedback.status(),
gtk::AccessibleAnnouncementPriority::Medium,
);
}
let enabled = feedback.navigation_enabled();
search.previous.set_sensitive(enabled);
search.next.set_sensitive(enabled);
}
pub(crate) fn open_search(
search: &SearchOverlay,
preferences: >k::Revealer,
terminal: &vte::Terminal,
) {
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)]);
cancel_pending_search(search);
update_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)]);
terminal.search_set_regex(None, 0);
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_navigation() {
let cases = [
(SearchFeedback::Empty, "", false),
(SearchFeedback::Searching, "Searching...", false),
(SearchFeedback::Match, "", true),
(SearchFeedback::NoMatch, "No matches", false),
(SearchFeedback::Unavailable, "Search unavailable", false),
];
for (feedback, status, navigation_enabled) in cases {
assert_eq!(feedback.status(), status);
assert_eq!(feedback.navigation_enabled(), navigation_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 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);
}
}