use std::{
cell::{Cell, RefCell},
collections::BTreeMap,
};
use gettextrs::pgettext;
use glib::WeakRef;
use gtk::{glib, prelude::*, subclass::prelude::*};
use regex::Regex;
use tracing::{debug, info, warn};
use crate::{format2, systemd_gui::is_dark, upgrade, widget};
use super::TextSearchBar;
const SEARCH_HIGHLIGHT: &str = "search_highlight";
const SEARCH_HIGHLIGHT_SELECTED: &str = "search_highlight_selected";
#[derive(Default, gtk::CompositeTemplate)]
#[template(resource = "/io/github/plrigaux/sysd-manager/text_find.ui")]
pub struct TextSearchBarImp {
#[template_child]
pub(super) search_entry: TemplateChild<gtk::SearchEntry>,
#[template_child]
case_sensitive_toggle_button: TemplateChild<gtk::ToggleButton>,
#[template_child]
match_whole_word: TemplateChild<gtk::ToggleButton>,
#[template_child]
regex_toggle_button: TemplateChild<gtk::ToggleButton>,
#[template_child]
previous_match_button: TemplateChild<gtk::Button>,
#[template_child]
next_match_button: TemplateChild<gtk::Button>,
#[template_child]
search_result_label: TemplateChild<gtk::Label>,
text_view: WeakRef<gtk::TextView>,
iter_select: Cell<Option<(gtk::TextIter, gtk::TextIter)>>,
finds: RefCell<BTreeMap<i32, i32>>,
regex: RefCell<Option<Regex>>,
}
#[gtk::template_callbacks]
impl TextSearchBarImp {
#[template_callback]
fn search_entry_changed(&self, search_entry: >k::SearchEntry) {
let entry_text: glib::GString = search_entry.text();
debug!("Search text changed: {}", entry_text);
self.new_find_in_text();
}
#[template_callback]
fn on_case_sensitive_toggled(&self, _toggle_button: >k::ToggleButton) {
self.new_find_in_text();
}
#[template_callback]
fn on_regex_toggled(&self, _toggle_button: >k::ToggleButton) {
self.new_find_in_text();
}
#[template_callback]
fn on_previous_match_clicked(&self, _button: >k::Button) {
self.previous_match_clicked();
}
#[template_callback]
fn on_next_match_clicked(&self, _button: >k::Button) {
self.next_match_clicked();
}
}
impl TextSearchBarImp {
fn previous_match_clicked(&self) {
let text_view = upgrade!(self.text_view);
let text_view = text_view;
let buff = text_view.buffer();
let tag_table = buff.tag_table();
let Some(tag) = tag_table.lookup(SEARCH_HIGHLIGHT) else {
warn!("No tag search highlight");
return;
};
let mut end_iter = self.get_iter(&text_view, &buff, false);
if !end_iter.backward_to_tag_toggle(Some(&tag)) {
info!("iter can't find tag highlight begin");
end_iter = buff.end_iter();
if !end_iter.backward_to_tag_toggle(Some(&tag)) {
warn!("iter can't find tag highlight begin from end");
return;
}
}
let mut start_iter = end_iter;
if !start_iter.backward_to_tag_toggle(Some(&tag)) {
warn!("iter can't find tag highlight end");
return;
}
self.apply_hl_tag(text_view, buff, tag_table, start_iter, end_iter);
}
fn apply_hl_tag(
&self,
text_view: gtk::TextView,
buff: gtk::TextBuffer,
tag_table: gtk::TextTagTable,
mut start_iter: gtk::TextIter,
end_iter: gtk::TextIter,
) {
let tag_select = if let Some(tag_select) = tag_table.lookup(SEARCH_HIGHLIGHT_SELECTED) {
let start = buff.start_iter();
let end = buff.end_iter();
buff.remove_tag(&tag_select, &start, &end);
tag_select
} else {
info!("is_dark {}", is_dark());
let color = if is_dark() { "#f7d742" } else { "#e5d255" };
let tag_select = gtk::TextTag::builder()
.name(SEARCH_HIGHLIGHT_SELECTED)
.background(color)
.build();
if is_dark() {
tag_select.set_foreground(Some("#000000"));
}
tag_table.add(&tag_select);
tag_select
};
buff.apply_tag(&tag_select, &start_iter, &end_iter);
text_view.scroll_to_iter(&mut start_iter, 0.2, false, 0.0, 0.0);
self.iter_select.set(Some((start_iter, end_iter)));
let finds = self.finds.borrow();
let idx = finds.get(&start_iter.offset()).unwrap_or(&-1);
let search_result = format2!(&pgettext("text_find", "{} of {}"), idx, finds.len());
self.search_result_label.set_label(&search_result);
}
fn next_match_clicked(&self) {
let text_view = upgrade!(self.text_view);
let buff = text_view.buffer();
let tag_table = buff.tag_table();
let Some(tag) = tag_table.lookup(SEARCH_HIGHLIGHT) else {
warn!("No tag search highlight");
return;
};
let mut start_iter = self.get_iter(&text_view, &buff, true);
if !start_iter.forward_to_tag_toggle(Some(&tag)) {
debug!("iter can't find tag highlight begin");
start_iter = buff.start_iter();
let found = start_iter.forward_to_tag_toggle(Some(&tag));
if !found {
warn!("iter can't find tag highlight begin from start");
return;
}
}
let mut end_iter = start_iter;
if !end_iter.forward_to_tag_toggle(Some(&tag)) {
warn!("iter can't find tag highlight end");
return;
}
self.apply_hl_tag(text_view, buff, tag_table, start_iter, end_iter);
}
fn get_iter(
&self,
text_view: >k::TextView,
buff: >k::TextBuffer,
is_next: bool,
) -> gtk::TextIter {
if let Some((start_iter, end_iter)) = self.iter_select.get() {
if is_next { end_iter } else { start_iter }
} else {
let cursor_pos = buff.cursor_position();
let cursor_visible = text_view.is_cursor_visible();
debug!("cur pos {cursor_pos} vis {cursor_visible}");
let mut start_iter = buff.start_iter();
start_iter.forward_chars(cursor_pos);
if !start_iter.forward_cursor_position() {
start_iter = buff.start_iter();
}
start_iter
}
}
pub(crate) fn set_text_view(&self, text_view: >k::TextView) {
self.text_view.set(Some(text_view));
}
pub(super) fn new_find_in_text(&self) {
let entry_text = self.search_entry.text();
if entry_text.is_empty() {
self.clear_tags();
return;
}
let text_view = upgrade!(self.text_view);
let buff = text_view.buffer();
let start = buff.start_iter();
let end = buff.end_iter();
let tag_table = buff.tag_table();
let tag = if let Some(tag) = tag_table.lookup(SEARCH_HIGHLIGHT) {
buff.remove_tag(&tag, &start, &end);
if let Some(tag_hl) = tag_table.lookup(SEARCH_HIGHLIGHT_SELECTED) {
buff.remove_tag(&tag_hl, &start, &end);
}
tag
} else {
Self::new_tag(&tag_table)
};
self.set_regex(&entry_text);
self.finds.borrow_mut().clear();
self.apply_tag(&buff, start, end, &tag);
}
fn new_tag(tag_table: >k::TextTagTable) -> gtk::TextTag {
let color = if is_dark() { "#8a7826" } else { "#f8e45c" };
let tag = gtk::TextTag::builder()
.name(SEARCH_HIGHLIGHT)
.background(color)
.build();
tag_table.add(&tag);
tag
}
pub(super) fn new_added_text(
&self,
buff: >k::TextBuffer,
start_iter: gtk::TextIter,
end_iter: gtk::TextIter,
) {
let tag_table = buff.tag_table();
let tag = if let Some(tag) = tag_table.lookup(SEARCH_HIGHLIGHT) {
tag
} else {
Self::new_tag(&tag_table)
};
self.apply_tag(buff, start_iter, end_iter, &tag);
}
fn apply_tag(
&self,
buff: >k::TextBuffer,
start_iter: gtk::TextIter,
end_iter: gtk::TextIter,
tag: >k::TextTag,
) {
let text = buff.text(&start_iter, &end_iter, true);
let mut char_start: i32 = start_iter.offset();
let mut byte_start = 0;
let borrow = self.regex.borrow();
let Some(re) = borrow.as_ref() else {
warn!("Find in text has no pattern");
return;
};
let mut finds = self.finds.borrow_mut();
let mut match_num = finds.len() as i32;
for re_match in re.find_iter(&text) {
let match_start = re_match.start();
char_start += text[byte_start..match_start].chars().count() as i32;
let re_match_end = re_match.end();
let char_end = char_start + text[match_start..re_match_end].chars().count() as i32;
let match_start = buff.iter_at_offset(char_start);
let match_end = buff.iter_at_offset(char_end);
buff.apply_tag(tag, &match_start, &match_end);
match_num += 1;
finds.insert(char_start, match_num);
byte_start = re_match_end;
char_start = char_end;
}
let hints = format2!(&pgettext("text_find", "{} of {}"), 0, finds.len());
self.search_result_label.set_label(&hints);
self.prev_next_senstivity(match_num);
}
fn set_regex(&self, entry_text: &glib::GString) {
let mut pattern = String::with_capacity(entry_text.len() + 15);
if !self.case_sensitive_toggle_button.is_active() {
pattern.push_str("(?i)");
}
if self.match_whole_word.is_active() {
pattern.push_str("\\b");
}
if self.regex_toggle_button.is_active() {
pattern.push_str(entry_text);
} else {
for c in entry_text.chars() {
if matches!(c, '(' | ')' | '\\' | '*' | '[' | ']' | '.') {
pattern.push('\\');
}
pattern.push(c);
}
}
if self.match_whole_word.is_active() {
pattern.push_str("\\b");
}
let re = match Regex::new(&pattern) {
Ok(re) => {
self.search_entry.remove_css_class("error");
re
}
Err(err) => {
warn!("Invalid regex: {}", err);
self.prev_next_senstivity(0);
self.search_entry.add_css_class("error");
return;
}
};
self.regex.replace(Some(re));
}
fn prev_next_senstivity(&self, match_num: i32) {
let sensitive = match_num > 0;
self.previous_match_button.set_sensitive(sensitive);
self.next_match_button.set_sensitive(sensitive);
}
fn clear_index(&self) {
self.iter_select.set(None);
self.prev_next_senstivity(0);
self.search_result_label.set_label("");
self.finds.borrow_mut().clear();
self.regex.replace(None);
self.finds.borrow_mut().clear();
}
pub(super) fn clear_tags(&self) {
self.clear_index();
let text_view = upgrade!(self.text_view);
let buff = text_view.buffer();
let tag_table = buff.tag_table();
if let Some(tag) = tag_table.lookup(SEARCH_HIGHLIGHT) {
let start = buff.start_iter();
let end = buff.end_iter();
buff.remove_tag(&tag, &start, &end);
if let Some(tag_hl) = tag_table.lookup(SEARCH_HIGHLIGHT_SELECTED) {
buff.remove_tag(&tag_hl, &start, &end);
}
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TextSearchBarImp {
const NAME: &'static str = "TextFind";
type Type = TextSearchBar;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for TextSearchBarImp {
fn constructed(&self) {
self.parent_constructed();
let event_controller = widget::clear_on_escape();
self.search_entry.add_controller(event_controller);
}
}
impl WidgetImpl for TextSearchBarImp {}
impl BoxImpl for TextSearchBarImp {}