diff --git a/data/meson.build b/data/meson.build index d6912b7..074472f 100644 --- a/data/meson.build +++ b/data/meson.build @@ -24,3 +24,9 @@ install_data( rename: meson.project_name() + '.metainfo.xml', install_dir: join_paths(get_option('datadir'), 'metainfo'), ) + +install_data( + 'slate.gschema.xml', + install_dir: get_option('datadir') / 'glib-2.0' / 'schemas', + rename: meson.project_name() + '.gschema.xml' +) \ No newline at end of file diff --git a/data/slate.gschema.xml b/data/slate.gschema.xml new file mode 100644 index 0000000..3d06fbe --- /dev/null +++ b/data/slate.gschema.xml @@ -0,0 +1,10 @@ + + + + + false + Whether to match case in search + Used by the search function to remember whether the user wanted to match case + + + \ No newline at end of file diff --git a/meson.build b/meson.build index fd07101..f4b6f96 100644 --- a/meson.build +++ b/meson.build @@ -50,6 +50,7 @@ subdir('data') subdir('po') gnome.post_install( + glib_compile_schemas: true, gtk_update_icon_cache: true, update_desktop_database: true ) diff --git a/po/POTFILES b/po/POTFILES index 5b1199c..4f5eab3 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,3 +1,4 @@ src/Application.vala src/Window.vala -src/Utils.vala \ No newline at end of file +src/Utils.vala +src/SearchButton.vala \ No newline at end of file diff --git a/src/Application.vala b/src/Application.vala index 9e91f07..c25fd91 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -63,6 +63,18 @@ public class Application : Gtk.Application { window.on_save_as (); }); + SimpleAction toggle_search_action = new SimpleAction ("search", null); + set_accels_for_action ("app.search", {"f"}); + add_action (toggle_search_action); + toggle_search_action.activate.connect (() => { + unowned var window = this.get_active_window () as AppWindow; + if (window == null) { + return; + } + + window.search.search_menu.activate (); + }); + SimpleAction quit_action = new SimpleAction ("quit", null); set_accels_for_action ("app.quit", {"q"}); add_action (quit_action); diff --git a/src/SearchButton.vala b/src/SearchButton.vala new file mode 100644 index 0000000..ab75eef --- /dev/null +++ b/src/SearchButton.vala @@ -0,0 +1,225 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 William Kelso + */ + +public class SearchButton : Granite.Bin { + public Gtk.TextView textview {get; construct;} + public Gtk.TextBuffer buffer {get; construct;} + + public Gtk.MenuButton search_menu; + Gtk.Entry entry_search; + Gtk.ToggleButton toggle_match; + Gtk.Button previous; + Gtk.Button next; + Gtk.Revealer revealer_not_found; + + Gtk.TextSearchFlags flags { + get { + if (toggle_match.active) { + return Gtk.TextSearchFlags.VISIBLE_ONLY; + } + return Gtk.TextSearchFlags.CASE_INSENSITIVE; + } + set { + toggle_match.active = (value == Gtk.TextSearchFlags.VISIBLE_ONLY); + } + } + + public SearchButton (Gtk.TextView textview) { + Object (textview: textview, + buffer: textview.buffer); + } + + construct { + search_menu = new Gtk.MenuButton () { + icon_name = "system-search", + tooltip_markup = Granite.markup_accel_tooltip ( + {"f"}, + _("Search") + ) + }; + + var search_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0); + search_box.add_css_class (Granite.STYLE_CLASS_LINKED); + + entry_search = new Gtk.Entry () { + placeholder_text = _("Enter search term"), + primary_icon_name = "system-search-symbolic", + secondary_icon_tooltip_markup = Granite.markup_accel_tooltip ( + {"l"}, + _("Clear text") + ) + }; + + previous = new Gtk.Button.from_icon_name ("go-up-symbolic") { + tooltip_markup = Granite.markup_accel_tooltip ( + {"Return"}, + _("Search for an earlier match") + ) + }; + + next = new Gtk.Button.from_icon_name ("go-down-symbolic") { + tooltip_markup = Granite.markup_accel_tooltip ( + {"Return"}, + _("Search for a later match") + ) + }; + + toggle_match = new Gtk.ToggleButton () { + icon_name = "font-select-symbolic", + tooltip_text = _("Match case") + }; + + search_box.append (entry_search); + search_box.append (previous); + search_box.append (next); + search_box.append (toggle_match); + + var label_not_found = new Gtk.Label (_("Search term could not be found")); + label_not_found.add_css_class (Granite.STYLE_CLASS_ERROR); + + revealer_not_found = new Gtk.Revealer () { + child = label_not_found, + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + reveal_child = false + }; + + var popover_box = new Gtk.Box (VERTICAL, 5) { + margin_start = 10, + margin_end = 10, + margin_top = 10, + margin_bottom = 5 + }; + + popover_box.append (search_box); + popover_box.append (revealer_not_found); + + var popover = new Gtk.Popover () { + child = popover_box + }; + + search_menu.popover = popover; + child = search_menu; + + // We use a keypress controller to capture Shift+Enter + var keypress_controller = new Gtk.EventControllerKey (); + entry_search.add_controller (keypress_controller); + + + /* ---------------- CONNECTS AND BINDS ---------------- */ + keypress_controller.key_pressed.connect (on_key_press_event); + + entry_search.changed.connect (on_entry_changed); + entry_search.icon_release.connect (on_clear_clicked); + + previous.clicked.connect (() => {search_text (false);}); + next.clicked.connect (() => {search_text (true);}); + entry_search.activate.connect (() => {search_text (true);}); + + popover.show.connect (() => {entry_search.grab_focus ();}); + + var settings = new GLib.Settings ("io.github.wpkelso.slate"); + settings.bind ("match-case", + toggle_match, "active", + GLib.SettingsBindFlags.DEFAULT); + } + + private void on_entry_changed () { + if (entry_search.text_length > 0) { + entry_search.secondary_icon_name = "edit-clear-symbolic"; + + } else { + entry_search.secondary_icon_name = ""; + } + } + + private void on_clear_clicked () { + entry_search.text = ""; + } + + private void search_text (bool? forward = true) { + if (entry_search.text == "") { + return; + }; + + Gtk.TextIter start_buffer, end_buffer; + buffer.get_bounds (out start_buffer, out end_buffer); + + // Selection_bounds can leave the variables untouched, which can lead to a crash + Gtk.TextIter start_selection = start_buffer; + Gtk.TextIter end_selection = start_buffer; + buffer.get_selection_bounds (out start_selection, out end_selection); + + Gtk.TextIter match_start = start_selection; + Gtk.TextIter match_end = end_selection; + bool found_match = false; + + if (forward) { + + // If the cursor is at the end (like on app start or when the user is typing), we may want to search from the start + // Gated by Forward so the user can still go backward from the end if they want to + if (start_selection.is_end ()) { + buffer.place_cursor (start_buffer); + start_selection = start_buffer; + end_selection = start_buffer; + } + + //We have to check quick n' dirty beforehand because forward/backward_search prefers to crash the app than return false + //Also we have to account checking depending on case sensitiveness + //TODO: Fix this workaround + var remaining_text = buffer.get_slice (end_selection, end_buffer, true); + var text = entry_search.text; + if (flags == Gtk.TextSearchFlags.CASE_INSENSITIVE) { + text = text.casefold (); + remaining_text = remaining_text.casefold (); + } + + if (text in remaining_text) { + found_match = end_selection.forward_search (text, flags, + out match_start, out match_end, end_buffer); + } + + } else { + //We have to check quick n' dirty beforehand because forward/backward_search prefers to crash the app than return false + //Also we have to account checking depending on case sensitiveness + //TODO: Fix this workaround + var remaining_text = buffer.get_slice (start_buffer, start_selection, true); + var text = entry_search.text; + if (flags == Gtk.TextSearchFlags.CASE_INSENSITIVE) { + text = text.casefold (); + remaining_text = remaining_text.casefold (); + } + + if (text in remaining_text) { + found_match = start_selection.backward_search (text, flags, + out match_start, out match_end, start_buffer); + } + } + + debug ("Cursor: " + buffer.cursor_position.to_string ()); + debug ("Start and end selection: " + start_selection.get_offset ().to_string () + "|" + end_selection.get_offset ().to_string ()); + debug ("Found: " + found_match.to_string () + " at? " + match_start.get_offset ().to_string () + "|" + match_end.get_offset ().to_string ()); + + if (found_match) { + buffer.select_range (match_start, match_end); + textview.scroll_to_iter (match_start, 0, false, 0.5f, 0.5f); + revealer_not_found.reveal_child = false; + + } else { + revealer_not_found.reveal_child = true; + } + } + + public bool on_key_press_event (uint keyval, uint keycode, Gdk.ModifierType state) { + if (keyval == Gdk.Key.Return && state == Gdk.ModifierType.SHIFT_MASK) { + search_text (false); + } + + if (keyval == Gdk.Key.l && state == Gdk.ModifierType.CONTROL_MASK) { + on_clear_clicked (); + } + + return Gdk.EVENT_PROPAGATE; + } +} diff --git a/src/Window.vala b/src/Window.vala index 60e1481..3926192 100644 --- a/src/Window.vala +++ b/src/Window.vala @@ -7,6 +7,7 @@ public class AppWindow : Gtk.Window { public File file { get; set; } private Gtk.TextBuffer buf; private Gtk.HeaderBar header; + public SearchButton search; public string file_name { get; set; } // Add a debounce so we aren't writing the entire buffer every character input @@ -65,6 +66,13 @@ public class AppWindow : Gtk.Window { buf = text_view.buffer; buf.text = ""; + // TODO: use Granite.Box (HORIZONTAL, HALF) when granite-7.7.0 is released + var toolbar_box = new Gtk.Box (HORIZONTAL, 8); + + search = new SearchButton (text_view); + toolbar_box.append (search); + + header.pack_end (toolbar_box); var scrolled_view = new Gtk.ScrolledWindow () { child = text_view, @@ -83,6 +91,8 @@ public class AppWindow : Gtk.Window { open_file (file); + text_view.grab_focus (); + debug ("Connecting signals"); // Signal callbacks are heavily derived from similar operations in // elementary/code diff --git a/src/meson.build b/src/meson.build index f5f77ac..aafbb2d 100644 --- a/src/meson.build +++ b/src/meson.build @@ -2,4 +2,5 @@ sources += files( 'Window.vala', 'Application.vala', 'Utils.vala', + 'SearchButton.vala', )