diff --git a/Cargo.lock b/Cargo.lock index bcae09b..4417a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,7 @@ dependencies = [ "gtk-blueprint", "gtk4", "libadwaita", + "sourceview5", ] [[package]] @@ -1616,6 +1617,41 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "sourceview5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/build-aux/cargo-sources.json b/build-aux/cargo-sources.json index a26fc07..3939bea 100644 --- a/build-aux/cargo-sources.json +++ b/build-aux/cargo-sources.json @@ -2127,6 +2127,32 @@ "dest": "cargo/vendor/smallvec-1.13.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sourceview5/sourceview5-0.9.1.crate", + "sha256": "f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e", + "dest": "cargo/vendor/sourceview5-0.9.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e\", \"files\": {}}", + "dest": "cargo/vendor/sourceview5-0.9.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sourceview5-sys/sourceview5-sys-0.9.0.crate", + "sha256": "4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff", + "dest": "cargo/vendor/sourceview5-sys-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff\", \"files\": {}}", + "dest": "cargo/vendor/sourceview5-sys-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/gnome/Cargo.toml b/gnome/Cargo.toml index 50c8854..d0d07d5 100644 --- a/gnome/Cargo.toml +++ b/gnome/Cargo.toml @@ -10,6 +10,7 @@ gtk = { version = "0.9.2", package = "gtk4", features = ["blueprint", "v4_16"] } flume = "0.11.0" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } gtk-blueprint = "0.2.0" +sourceview5 = { version = "0.9.1", features = ["gtk_v4_12", "v5_12"] } [dependencies.cosmic-config] git = "https://github.com/pop-os/libcosmic.git" diff --git a/gnome/src/main.rs b/gnome/src/main.rs index 750258e..3b69985 100644 --- a/gnome/src/main.rs +++ b/gnome/src/main.rs @@ -12,6 +12,10 @@ const APP_ID: &str = env!("APP_ID"); fn main() { i18n::setup_gettext(); + let _ = gtk::init(); + let _ = adw::init(); + sourceview5::init(); + let application = adw::Application::builder() .application_id(APP_ID) .flags(ApplicationFlags::HANDLES_OPEN) diff --git a/gnome/src/search/result.rs b/gnome/src/search/result.rs index ffa0e8c..b0b4754 100644 --- a/gnome/src/search/result.rs +++ b/gnome/src/search/result.rs @@ -13,6 +13,12 @@ glib::wrapper! { pub struct SearchResult(ObjectSubclass); } +impl Default for SearchResult { + fn default() -> Self { + glib::Object::new() + } +} + impl SearchResult { pub fn new( file: impl Into, @@ -48,7 +54,8 @@ impl SearchResult { }; glib::Object::builder() - .property("file", file) + .property("relative_path", file) + .property("absolute_path", absolute_file.as_ref()) .property("uri", uri) .property("line", line) .property("content", content.as_ref()) @@ -96,7 +103,9 @@ mod imp { #[properties(wrapper_type = super::SearchResult)] pub struct SearchResult { #[property(get, set)] - file: RefCell, + relative_path: RefCell, + #[property(get, set)] + absolute_path: RefCell, #[property(get, set)] uri: RefCell, #[property(get, set)] diff --git a/gnome/src/styles.css b/gnome/src/styles.css index b99cb11..f819e3a 100644 --- a/gnome/src/styles.css +++ b/gnome/src/styles.css @@ -1,4 +1,7 @@ -.cg-banner { - background-color: var(--accent-bg-color); - color: var(--accent-fg-color); +.view { + background-color: #00000000; +} + +.view gutter { + background-color: #00000000; } diff --git a/gnome/src/ui/mod.rs b/gnome/src/ui/mod.rs index d8d84f7..c0e373f 100644 --- a/gnome/src/ui/mod.rs +++ b/gnome/src/ui/mod.rs @@ -9,3 +9,5 @@ pub use error_window::ErrorWindow; mod search_window; pub use search_window::SearchWindow; + +mod preview; diff --git a/gnome/src/ui/preview/mod.rs b/gnome/src/ui/preview/mod.rs new file mode 100644 index 0000000..751775f --- /dev/null +++ b/gnome/src/ui/preview/mod.rs @@ -0,0 +1,2 @@ +mod plain_preview; +pub use plain_preview::PlainPreview; diff --git a/gnome/src/ui/preview/plain_preview.blp b/gnome/src/ui/preview/plain_preview.blp new file mode 100644 index 0000000..c364a71 --- /dev/null +++ b/gnome/src/ui/preview/plain_preview.blp @@ -0,0 +1,39 @@ +using Gtk 4.0; +using Adw 1; +using GtkSource 5; + +template $ClapgrepPlainPreview: Widget { + layout-manager: Gtk.BinLayout {}; + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle title { + title: _("Content Preview"); + }; + } + + Stack views { + StackPage no_preview { + child: Adw.StatusPage { + title: _("No Preview Available"); + description: _("Try clicking on on of the result lines."); + icon-name: "x-office-document-symbolic"; + }; + } + + StackPage some_preview { + child: ScrolledWindow { + child: GtkSource.View text_view { + vexpand: true; + editable: false; + show-line-numbers: true; + highlight-current-line: true; + }; + }; + } + } + } +} diff --git a/gnome/src/ui/preview/plain_preview.rs b/gnome/src/ui/preview/plain_preview.rs new file mode 100644 index 0000000..be7672d --- /dev/null +++ b/gnome/src/ui/preview/plain_preview.rs @@ -0,0 +1,152 @@ +use gtk::glib::{self, Object}; + +use crate::search::SearchResult; + +glib::wrapper! { + pub struct PlainPreview(ObjectSubclass) + @extends gtk::Widget; +} + +impl PlainPreview { + pub fn new(result: &SearchResult) -> Self { + Object::builder().property("result", result).build() + } +} + +mod imp { + use crate::search::SearchResult; + use adw::subclass::prelude::*; + use gettextrs::gettext; + use glib::subclass::InitializingObject; + use gtk::{glib, prelude::*, CompositeTemplate}; + use sourceview5::prelude::*; + use std::{cell::RefCell, fs, time::Duration}; + + #[derive(CompositeTemplate, glib::Properties, Default)] + #[template(file = "src/ui/preview/plain_preview.blp")] + #[properties(wrapper_type = super::PlainPreview)] + pub struct PlainPreview { + #[property(get, set)] + pub result: RefCell, + + #[template_child] + pub title: TemplateChild, + #[template_child] + pub text_view: TemplateChild, + + #[template_child] + pub views: TemplateChild, + #[template_child] + pub no_preview: TemplateChild, + #[template_child] + pub some_preview: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlainPreview { + const NAME: &'static str = "ClapgrepPlainPreview"; + type Type = super::PlainPreview; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl PlainPreview { + fn buffer(&self) -> sourceview5::Buffer { + self.text_view + .buffer() + .downcast::() + .unwrap() + } + + fn update_preview(&self) { + let result = self.result.borrow(); + let file = result.absolute_path(); + + if !file.exists() { + return; + } + + if let Ok(full_text) = fs::read_to_string(&file) { + let buffer = self.buffer(); + buffer.set_text(&full_text); + + // Setup syntax highlighting + let lm = sourceview5::LanguageManager::default(); + let language = lm.guess_language(Some(&file), None); + buffer.set_language(language.as_ref()); + self.text_view.set_monospace(language.is_some()); + + // Place cursor on result line. + let mut cursor_position = buffer.start_iter(); + cursor_position.forward_lines((result.line() - 1) as i32); + buffer.place_cursor(&cursor_position); + + // Set title to file name. + let file_name = file.file_name().unwrap().to_string_lossy(); + self.title.set_title(file_name.as_ref()); + + // Scroll to result line after 100ms. + // + // The delay is needed because scroll_to_iter only works + // once the line hights have been calculated in an idle handler. + let text_view = self.text_view.clone(); + glib::timeout_add_local_once(Duration::from_millis(100), move || { + text_view.scroll_to_iter(&mut cursor_position, 0.0, true, 0.0, 0.3); + }); + + self.views.set_visible_child(&self.some_preview.child()); + } else { + self.title.set_title(&gettext("Content Preview")); + self.views.set_visible_child(&self.no_preview.child()); + } + } + + fn setup_style(&self) { + let text_view_buffer = self.buffer(); + + let asm = adw::StyleManager::default(); + let sm = sourceview5::StyleSchemeManager::default(); + + let light_style = sm.scheme("Adwaita").unwrap(); + let dark_style = sm.scheme("Adwaita-dark").unwrap(); + + let setter = move |asm: &adw::StyleManager| { + let current_style = if asm.is_dark() { + &dark_style + } else { + &light_style + }; + + text_view_buffer.set_style_scheme(Some(current_style)); + }; + + setter(&asm); + asm.connect_dark_notify(setter); + } + } + + #[glib::derived_properties] + impl ObjectImpl for PlainPreview { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + self.setup_style(); + + obj.connect_result_notify(|obj| { + obj.imp().update_preview(); + }); + } + } + + impl WidgetImpl for PlainPreview {} +} diff --git a/gnome/src/ui/search_window/imp.rs b/gnome/src/ui/search_window/imp.rs index 5740e3b..f9210b3 100644 --- a/gnome/src/ui/search_window/imp.rs +++ b/gnome/src/ui/search_window/imp.rs @@ -1,4 +1,9 @@ -use crate::{config::Config, i18n::gettext_f, search::SearchModel, ui::ErrorWindow}; +use crate::{ + config::Config, + i18n::gettext_f, + search::{SearchModel, SearchResult}, + ui::{preview::PlainPreview, ErrorWindow}, +}; use adw::subclass::prelude::*; use clapgrep_core::{SearchEngine, SearchFlags, SearchMessage, SearchParameters}; use gettextrs::gettext; @@ -75,6 +80,12 @@ pub struct SearchWindow { pub results_page: TemplateChild, #[template_child] pub split_view: TemplateChild, + #[template_child] + pub inner_split_view: TemplateChild, + #[template_child] + pub preview_navigation_page: TemplateChild, + #[template_child] + pub preview: TemplateChild, pub engine: SearchEngine, pub config: Config, @@ -153,6 +164,16 @@ impl SearchWindow { let error_window = ErrorWindow::new(&self.obj()); error_window.present(); } + + #[template_callback] + fn on_result_activated(&self, position: u32) { + if let Some(result) = self.results.item(position) { + let result = result.downcast::().unwrap(); + self.preview.set_result(&result); + self.preview_navigation_page.set_visible(true); + self.inner_split_view.set_show_content(true); + } + } } impl SearchWindow { diff --git a/gnome/src/ui/search_window/search_window.blp b/gnome/src/ui/search_window/search_window.blp index 63a9702..9b20c95 100644 --- a/gnome/src/ui/search_window/search_window.blp +++ b/gnome/src/ui/search_window/search_window.blp @@ -6,10 +6,19 @@ template $ClapgrepSearchWindow: Adw.ApplicationWindow { width-request: 300; Adw.Breakpoint { - condition ("max-width: 800") + condition ("max-width: 1600sp") + + setters { + inner_split_view.collapsed: true; + } + } + + Adw.Breakpoint { + condition ("max-width: 800sp") setters { split_view.collapsed: true; + inner_split_view.collapsed: true; } } @@ -25,7 +34,14 @@ template $ClapgrepSearchWindow: Adw.ApplicationWindow { top-bar-style: flat; [top] - Adw.HeaderBar {} + Adw.HeaderBar { + [end] + MenuButton button_menu { + menu-model: menu_app; + icon-name: "open-menu-symbolic"; + primary: true; + } + } ScrolledWindow { child: Viewport { @@ -118,136 +134,145 @@ template $ClapgrepSearchWindow: Adw.ApplicationWindow { content: Adw.NavigationPage { title: _("Search Results"); - child: Adw.ToolbarView { - top-bar-style: flat; - - [top] - Adw.HeaderBar { - [end] - MenuButton button_menu { - menu-model: menu_app; - icon-name: "open-menu-symbolic"; - primary: true; - } - } + child: Adw.NavigationSplitView inner_split_view { + sidebar-width-fraction: 0.5; + max-sidebar-width: 9999; - Stack results_stack { - StackPage no_search_page { - name: "no_search"; + sidebar: Adw.NavigationPage results_navigation_page { + title: _("Search Results"); - child: Adw.StatusPage { - title: _("No Search Yet"); - description: _("Try to start a search"); - icon-name: "system-search-symbolic"; - }; - } + child: Adw.ToolbarView { + top-bar-style: flat; - StackPage no_results_page { - name: "no_results"; + [top] + Adw.HeaderBar {} - child: Adw.StatusPage { - title: _("No Results"); - icon-name: "system-search-symbolic"; + Stack results_stack { + StackPage no_search_page { + name: "no_search"; - child: Label { - wrap: true; - use-markup: true; - label: _("You might want to try changing your search pattern, activating document search, or changing to a different directory"); - }; - }; - } + child: Adw.StatusPage { + title: _("No Search Yet"); + description: _("Try to start a search"); + icon-name: "system-search-symbolic"; + }; + } - StackPage results_page { - name: "results"; + StackPage no_results_page { + name: "no_results"; - child: Box { - orientation: vertical; + child: Adw.StatusPage { + title: _("No Results"); + icon-name: "system-search-symbolic"; - Adw.Banner { - revealed: bind template.search_progress_visible; - title: bind template.search_progress_notification; - button-label: bind template.search_progress_action; - button-clicked => $on_search_progress_action() swapped; + child: Label { + wrap: true; + use-markup: true; + label: _("You might want to try changing your search pattern, activating document search, or changing to a different directory"); + }; + }; } - Adw.Banner { - revealed: bind template.has_errors; - title: bind template.search_errors_notification; - button-label: _("Show Errors"); - button-clicked => $on_show_errors() swapped; + StackPage results_page { + name: "results"; - styles [ - "error" - ] - } + child: Box { + orientation: vertical; - ScrolledWindow { - vexpand: true; + Adw.Banner { + revealed: bind template.search_progress_visible; + title: bind template.search_progress_notification; + button-label: bind template.search_progress_action; + button-clicked => $on_search_progress_action() swapped; + } - child: ListView { - single-click-activate: true; + Adw.Banner { + revealed: bind template.has_errors; + title: bind template.search_errors_notification; + button-label: _("Show Errors"); + button-clicked => $on_show_errors() swapped; - model: NoSelection { - model: bind template.results; - }; + styles [ + "error" + ] + } - header-factory: BuilderListItemFactory { - template ListHeader { - child: LinkButton { - margin-start: 16; - halign: start; - label: bind template.item as <$ClapgrepSearchResult>.file; - uri: bind template.item as <$ClapgrepSearchResult>.uri; - - styles [ - "heading" - ] - }; - } - }; + ScrolledWindow { + vexpand: true; - factory: BuilderListItemFactory { - template ListItem { - child: Box { - orientation: horizontal; - margin-top: 2; - margin-start: 16; - margin-end: 16; - margin-bottom: 2; - - Label { - xalign: 1.0; - width-request: 40; - label: bind template.item as <$ClapgrepSearchResult>.line; - - styles [ - "monospace" - ] - } + child: ListView { + single-click-activate: true; + activate => $on_result_activated() swapped; - Label { - label: ": "; + model: NoSelection { + model: bind template.results; + }; - styles [ - "monospace" - ] + header-factory: BuilderListItemFactory { + template ListHeader { + child: LinkButton { + margin-start: 16; + halign: start; + label: bind template.item as <$ClapgrepSearchResult>.relative_path; + uri: bind template.item as <$ClapgrepSearchResult>.uri; + + styles [ + "heading" + ] + }; } + }; - Label { - label: bind template.item as <$ClapgrepSearchResult>.content; - - styles [ - "monospace" - ] + factory: BuilderListItemFactory { + template ListItem { + child: Box { + orientation: horizontal; + margin-top: 2; + margin-start: 16; + margin-end: 16; + margin-bottom: 2; + + Label { + xalign: 1.0; + width-request: 40; + label: bind template.item as <$ClapgrepSearchResult>.line; + + styles [ + "monospace" + ] + } + + Label { + label: ": "; + + styles [ + "monospace" + ] + } + + Label { + label: bind template.item as <$ClapgrepSearchResult>.content; + + styles [ + "monospace" + ] + } + }; } }; - } - }; + }; + } }; } - }; - } - } + } + }; + }; + + content: Adw.NavigationPage preview_navigation_page { + title: _("Content Preview"); + + child: $ClapgrepPlainPreview preview {}; + }; }; }; };