use gtk::prelude::*;
use gtk::{gdk, glib};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use vte::prelude::*;
const MAX_QUERY_CHARS: usize = 1024;
const REGEX_FLAGS: u32 = vte::ffi::VTE_REGEX_FLAGS_DEFAULT as u32 | 0x0000_0400 | 0x0002_0000;
#[derive(Clone)]
pub(crate) struct SearchBar(Rc<Inner>);
struct Inner {
revealer: gtk::Revealer,
content: gtk::Box,
entry: gtk::Entry,
status: gtk::Label,
previous: gtk::Button,
next: gtk::Button,
match_case: gtk::CheckButton,
whole_words: gtk::CheckButton,
terminal: glib::WeakRef<vte::Terminal>,
committed: RefCell<Option<String>>,
pending: RefCell<Option<glib::SourceId>>,
}
impl Drop for Inner {
fn drop(&mut self) {
if let Some(source) = self.pending.get_mut().take() {
source.remove();
}
}
}
impl SearchBar {
pub(crate) fn new(terminal: &vte::Terminal) -> Self {
let content = gtk::Box::new(gtk::Orientation::Vertical, 6);
content.add_css_class("liosbar-find");
content.set_accessible_role(gtk::AccessibleRole::Search);
content.update_property(&[gtk::accessible::Property::Label("Find in terminal")]);
content.set_sensitive(false);
let entry = gtk::Entry::builder()
.placeholder_text("Find in terminal")
.width_chars(10)
.max_width_chars(22)
.max_length(MAX_QUERY_CHARS as i32 + 1)
.hexpand(true)
.build();
entry.set_widget_name("liosbar-find-entry");
entry.update_property(&[gtk::accessible::Property::Label(
"Search terminal scrollback",
)]);
let button = |icon, tooltip| {
gtk::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.build()
};
let previous = button("go-up-symbolic", "Previous match (Shift+Enter or Shift+F3)");
let next = button("go-down-symbolic", "Next match (Enter or F3)");
let close = button("window-close-symbolic", "Close search (Escape)");
let query_row = gtk::Box::new(gtk::Orientation::Horizontal, 4);
query_row.append(&entry);
query_row.append(&previous);
query_row.append(&next);
query_row.append(&close);
content.append(&query_row);
let options = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let match_case = gtk::CheckButton::with_label("Match case");
match_case.set_active(true);
let whole_words = gtk::CheckButton::with_label("Whole words");
options.append(&match_case);
options.append(&whole_words);
content.append(&options);
let status = gtk::Label::new(None);
status.set_xalign(0.0);
status.set_wrap(true);
status.set_max_width_chars(30);
status.set_accessible_role(gtk::AccessibleRole::Status);
entry.update_relation(&[gtk::accessible::Relation::DescribedBy(&[
status.upcast_ref()
])]);
content.append(&status);
let revealer = gtk::Revealer::builder()
.child(&content)
.halign(gtk::Align::End)
.valign(gtk::Align::Start)
.margin_top(8)
.margin_start(8)
.margin_end(8)
.transition_duration(0)
.build();
let search = Self(Rc::new(Inner {
revealer,
content,
entry,
status,
previous,
next,
match_case,
whole_words,
terminal: terminal.downgrade(),
committed: RefCell::new(None),
pending: RefCell::new(None),
}));
search.refresh();
search.0.entry.connect_changed({
let weak = Rc::downgrade(&search.0);
move |_| {
if let Some(inner) = weak.upgrade() {
Self(inner).refresh();
}
}
});
for option in [&search.0.match_case, &search.0.whole_words] {
option.connect_toggled({
let weak = Rc::downgrade(&search.0);
move |_| {
if let Some(inner) = weak.upgrade() {
Self(inner).refresh();
}
}
});
}
for (button, backwards) in [(&search.0.previous, true), (&search.0.next, false)] {
button.connect_clicked({
let weak = Rc::downgrade(&search.0);
move |_| {
if let Some(inner) = weak.upgrade() {
Self(inner).navigate(backwards);
}
}
});
}
close.connect_clicked({
let weak = Rc::downgrade(&search.0);
move |_| {
if let Some(inner) = weak.upgrade() {
Self(inner).close(true);
}
}
});
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
keys.connect_key_pressed({
let weak = Rc::downgrade(&search.0);
move |_, key, _, modifiers| {
if modifiers.intersects(
gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK
| gdk::ModifierType::META_MASK
| gdk::ModifierType::HYPER_MASK,
) || !matches!(key, gdk::Key::Return | gdk::Key::KP_Enter)
{
return glib::Propagation::Proceed;
}
if let Some(inner) = weak.upgrade() {
Self(inner).navigate(modifiers.contains(gdk::ModifierType::SHIFT_MASK));
}
glib::Propagation::Stop
}
});
search.0.entry.add_controller(keys);
search.0.revealer.connect_unmap({
let weak = Rc::downgrade(&search.0);
move |_| {
if let Some(inner) = weak.upgrade() {
Self(inner).close(false);
}
}
});
search
}
pub(crate) fn wrap(&self, terminal: &vte::Terminal) -> gtk::Overlay {
let overlay = gtk::Overlay::new();
overlay.set_hexpand(true);
overlay.set_vexpand(true);
overlay.set_child(Some(terminal));
overlay.add_overlay(&self.0.revealer);
overlay.set_measure_overlay(&self.0.revealer, false);
overlay.set_clip_overlay(&self.0.revealer, true);
overlay
}
pub(crate) fn is_open(&self) -> bool {
self.0.revealer.reveals_child()
}
pub(crate) fn has_focus(&self) -> bool {
self.0
.content
.root()
.and_then(|root| root.focus())
.is_some_and(|focus| focus == self.0.content || focus.is_ancestor(&self.0.content))
}
pub(crate) fn open(&self) {
self.0.content.set_sensitive(true);
self.0.revealer.set_reveal_child(true);
self.0.entry.grab_focus();
self.0.entry.select_region(0, -1);
}
pub(crate) fn close(&self, focus_terminal: bool) {
self.refresh();
self.0.content.set_sensitive(false);
self.0.revealer.set_reveal_child(false);
if focus_terminal {
if let Some(terminal) = self.0.terminal.upgrade() {
terminal.grab_focus();
}
}
}
fn cancel(&self) {
if let Some(source) = self.0.pending.borrow_mut().take() {
source.remove();
}
}
fn clear_pattern(&self) {
self.0.committed.borrow_mut().take();
if let Some(terminal) = self.0.terminal.upgrade() {
terminal.search_set_regex(None, 0);
}
}
fn pattern(&self) -> Result<Option<String>, &'static str> {
literal_pattern(
self.0.entry.text().as_str(),
self.0.match_case.is_active(),
self.0.whole_words.is_active(),
)
}
fn feedback(&self, text: &str, enabled: bool) {
self.0.status.set_text(text);
self.0.previous.set_sensitive(enabled);
self.0.next.set_sensitive(enabled);
}
fn refresh(&self) {
self.cancel();
self.clear_pattern();
match self.pattern() {
Ok(Some(_)) => self.feedback("Press Enter to search", true),
Ok(None) => self.feedback("", false),
Err(error) => self.feedback(error, false),
}
}
pub(crate) fn navigate(&self, backwards: bool) {
self.cancel();
if !self.is_open() {
return;
}
let pattern = match self.pattern() {
Ok(Some(pattern)) => pattern,
_ => {
self.refresh();
return;
}
};
self.feedback("Searching…", true);
let weak = Rc::downgrade(&self.0);
*self.0.pending.borrow_mut() = Some(glib::timeout_add_local_once(
Duration::from_millis(16),
move || {
let Some(inner) = weak.upgrade() else {
return;
};
inner.pending.borrow_mut().take();
let search = Self(inner);
let Some(terminal) = search.0.terminal.upgrade() else {
return;
};
if !search.is_open() || !terminal.is_mapped() {
return;
}
if search.0.committed.borrow().as_ref() != Some(&pattern) {
let Ok(regex) = vte::Regex::for_search(&pattern, REGEX_FLAGS) else {
search.clear_pattern();
search.feedback("Search unavailable", false);
return;
};
let _ = regex.jit(0x0000_0001 | 0x0000_0002);
terminal.search_set_regex(Some(®ex), 0);
terminal.search_set_wrap_around(true);
terminal.unselect_all();
*search.0.committed.borrow_mut() = Some(pattern);
}
let had_selection = terminal.has_selection();
let find = || {
if backwards {
terminal.search_find_previous()
} else {
terminal.search_find_next()
}
};
let mut found = find();
if !found && had_selection {
terminal.unselect_all();
found = find();
}
search.feedback(if found { "Match found" } else { "No matches" }, true);
},
));
}
}
fn literal_pattern(
query: &str,
match_case: bool,
whole_words: bool,
) -> Result<Option<String>, &'static str> {
if query.chars().take(MAX_QUERY_CHARS + 1).count() > MAX_QUERY_CHARS {
return Err("Search is limited to 1,024 characters");
}
if query.is_empty() {
return Ok(None);
}
let mut pattern = glib::Regex::escape_string(query).to_string();
if !match_case {
pattern = format!("(?i:{pattern})");
}
if whole_words {
pattern = format!(r"(?<![\p{{L}}\p{{N}}\p{{M}}_]){pattern}(?![\p{{L}}\p{{N}}\p{{M}}_])");
}
Ok(Some(pattern))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn settle() {
let until = std::time::Instant::now() + Duration::from_millis(80);
let context = glib::MainContext::default();
while std::time::Instant::now() < until {
while context.pending() {
context.iteration(false);
}
std::thread::sleep(Duration::from_millis(2));
}
}
pub(crate) fn exercise(search: &SearchBar, terminal: &vte::Terminal) {
terminal.feed(
"before\r\nC++.* [x]\r\nMixedCase\r\nwordSuffix\r\ne\u{301}cole\r\nafter\r\n"
.as_bytes(),
);
search.open();
settle();
let size = (terminal.column_count(), terminal.row_count());
let controllers = search.0.entry.observe_controllers();
let keys = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.filter_map(|controller| controller.downcast::<gtk::EventControllerKey>().ok())
.find(|keys| keys.propagation_phase() == gtk::PropagationPhase::Capture)
.unwrap();
let enter = |modifiers| {
keys.emit_by_name::<bool>("key-pressed", &[&gdk::Key::Return, &0_u32, &modifiers])
};
assert!(!enter(gdk::ModifierType::ALT_MASK));
assert!(!enter(gdk::ModifierType::CONTROL_MASK));
let run = |query: &str, expected: &str| {
search.0.entry.set_text(query);
assert!(
terminal.search_get_regex().is_none(),
"typing alone does not scan scrollback"
);
assert!(enter(gdk::ModifierType::empty()));
settle();
assert_eq!(search.0.status.text(), expected, "query: {query}");
};
run("C++.* [x]", "Match found");
for backwards in [false, true, false] {
if backwards {
assert!(enter(gdk::ModifierType::SHIFT_MASK));
} else {
search.0.next.emit_clicked();
}
settle();
assert_eq!(
search.0.status.text(),
"Match found",
"sole matches wrap both ways"
);
}
run("C.*", "No matches");
run("mixedcase", "No matches");
search.0.match_case.set_active(false);
run("mixedcase", "Match found");
search.0.whole_words.set_active(true);
run("word", "No matches");
run("wordSuffix", "Match found");
run("e", "No matches");
run("e\u{301}cole", "Match found");
search.0.entry.set_text(&"é".repeat(MAX_QUERY_CHARS + 1));
search.navigate(false);
assert_eq!(
search.0.status.text(),
"Search is limited to 1,024 characters"
);
assert!(!search.0.next.is_sensitive());
assert!(terminal.search_get_regex().is_none());
run("after", "Match found");
if let Some(path) = std::env::var_os("LIOSBAR_TEST_SCREENSHOT") {
let window = terminal.root().unwrap().downcast::<gtk::Window>().unwrap();
let snapshot = gtk::Snapshot::new();
gtk::WidgetPaintable::new(Some(&window)).snapshot(
&snapshot,
f64::from(window.width()),
f64::from(window.height()),
);
let node = snapshot.to_node().unwrap();
window
.renderer()
.unwrap()
.render_texture(&node, None)
.save_to_png(path)
.unwrap();
}
search.navigate(false);
search.close(true);
settle();
assert!(!search.is_open());
assert!(
terminal.search_get_regex().is_none(),
"closing cancels queued scans"
);
assert!(terminal.has_focus());
assert_eq!(
size,
(terminal.column_count(), terminal.row_count()),
"the overlay does not resize the PTY grid"
);
search.open();
assert_eq!(search.0.entry.text(), "after");
search.0.entry.set_text("MixedCase");
search.navigate(false);
}
pub(crate) fn assert_cancelled(search: &SearchBar, terminal: &vte::Terminal) {
settle();
assert!(!search.is_open());
assert!(search.0.pending.borrow().is_none());
assert!(terminal.search_get_regex().is_none());
assert_eq!(
search.0.entry.text(),
"MixedCase",
"a hidden session retains its own query"
);
}
}