From 601926d33651522af2e86e6799365ead1e775ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Wed, 18 Dec 2024 22:32:22 +0100 Subject: [PATCH 1/7] Create commmon CodeView component for raw text Since the search behaviour will be attached to an action associated with the text view, in order to avoid repeating the same work I'll just create a common component called CarteroCodeView and use it instead of GtkSourceView. Features of CarteroCodeView: * Automatic dark theme when dark mode. * Automatic unidirectional binding to accept the current settings. --- data/ui/code_export_pane.blp | 2 +- data/ui/raw_payload_pane.blp | 2 +- data/ui/response_panel.blp | 2 +- src/widgets/code_view.rs | 150 ++++++++++++++++++++++++++++++++ src/widgets/export_tab/code.rs | 97 ++------------------- src/widgets/mod.rs | 2 + src/widgets/request_body/raw.rs | 94 ++------------------ src/widgets/response_panel.rs | 76 +--------------- 8 files changed, 169 insertions(+), 256 deletions(-) create mode 100644 src/widgets/code_view.rs diff --git a/data/ui/code_export_pane.blp b/data/ui/code_export_pane.blp index 9e0a1d9..f200ed1 100644 --- a/data/ui/code_export_pane.blp +++ b/data/ui/code_export_pane.blp @@ -27,7 +27,7 @@ template $CarteroCodeExportPane: $CarteroBaseExportPane { vexpand: true; hexpand: true; - GtkSource.View view { + $CarteroCodeView view { styles [ "use-cartero-font" ] diff --git a/data/ui/raw_payload_pane.blp b/data/ui/raw_payload_pane.blp index 2b1e32b..59f3442 100644 --- a/data/ui/raw_payload_pane.blp +++ b/data/ui/raw_payload_pane.blp @@ -23,7 +23,7 @@ template $CarteroRawPayloadPane: $CarteroBasePayloadPane { hexpand: true; vexpand: true; - GtkSource.View view { + $CarteroCodeView view { styles [ "use-cartero-font" ] diff --git a/data/ui/response_panel.blp b/data/ui/response_panel.blp index 0bded03..79af4f5 100644 --- a/data/ui/response_panel.blp +++ b/data/ui/response_panel.blp @@ -51,7 +51,7 @@ template $CarteroResponsePanel: Adw.Bin { hexpand: true; vexpand: true; - GtkSource.View response_body { + $CarteroCodeView response_body { styles [ "use-cartero-font" ] diff --git a/src/widgets/code_view.rs b/src/widgets/code_view.rs new file mode 100644 index 0000000..463ccdd --- /dev/null +++ b/src/widgets/code_view.rs @@ -0,0 +1,150 @@ +// Copyright 2024 the Cartero authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + +use gtk::glib; + +mod imp { + use glib::object::Cast; + use glib::value::ToValue; + use gtk::gio::SettingsBindFlags; + use gtk::prelude::{SettingsExtManual, TextViewExt}; + use gtk::subclass::prelude::*; + use gtk::{glib, WrapMode}; + use sourceview5::prelude::BufferExt; + use sourceview5::subclass::view::ViewImpl; + use sourceview5::StyleSchemeManager; + + use crate::app::CarteroApplication; + + #[derive(Default)] + pub struct CodeView {} + + #[glib::object_subclass] + impl ObjectSubclass for CodeView { + const NAME: &'static str = "CarteroCodeView"; + type Type = super::CodeView; + type ParentType = sourceview5::View; + } + + impl ObjectImpl for CodeView { + fn constructed(&self) { + self.parent_constructed(); + self.init_settings(); + self.init_source_view_style(); + } + } + + impl WidgetImpl for CodeView {} + + impl TextViewImpl for CodeView {} + + impl ViewImpl for CodeView {} + + impl CodeView { + fn init_settings(&self) { + let app = CarteroApplication::get(); + let settings = app.settings(); + let obj = self.obj(); + + settings + .bind("body-wrap", &*obj, "wrap-mode") + .flags(SettingsBindFlags::GET) + .mapping(|variant, _| { + let enabled = variant.get::().expect("The variant is not a boolean"); + let mode = match enabled { + true => WrapMode::WordChar, + false => WrapMode::None, + }; + Some(mode.to_value()) + }) + .build(); + settings + .bind("show-line-numbers", &*obj, "show-line-numbers") + .flags(SettingsBindFlags::GET) + .build(); + settings + .bind("auto-indent", &*obj, "auto-indent") + .flags(SettingsBindFlags::GET) + .build(); + settings + .bind("indent-style", &*obj, "insert-spaces-instead-of-tabs") + .flags(SettingsBindFlags::GET) + .mapping(|variant, _| { + let mode = variant + .get::() + .expect("The variant is not a string"); + let use_spaces = mode == "spaces"; + Some(use_spaces.to_value()) + }) + .build(); + settings + .bind("tab-width", &*obj, "tab-width") + .flags(SettingsBindFlags::GET) + .mapping(|variant, _| { + let width = variant.get::().unwrap_or("4".into()); + let value = width.parse::().unwrap_or(4); + Some(value.to_value()) + }) + .build(); + settings + .bind("tab-width", &*obj, "indent-width") + .flags(SettingsBindFlags::GET) + .mapping(|variant, _| { + let width = variant.get::().unwrap_or("4".into()); + let value = width.parse::().unwrap_or(4); + Some(value.to_value()) + }) + .build(); + } + + fn update_source_view_style(&self) { + let obj = self.obj(); + let dark_mode = adw::StyleManager::default().is_dark(); + let color_theme = if dark_mode { "Adwaita-dark" } else { "Adwaita" }; + let theme = StyleSchemeManager::default().scheme(color_theme); + let buffer = obj.buffer().downcast::().unwrap(); + match theme { + Some(theme) => { + buffer.set_style_scheme(Some(&theme)); + buffer.set_highlight_syntax(true); + } + None => { + buffer.set_highlight_syntax(false); + } + } + } + + fn init_source_view_style(&self) { + self.update_source_view_style(); + adw::StyleManager::default().connect_dark_notify( + glib::clone!(@weak self as panel => move |_| { + panel.update_source_view_style(); + }), + ); + let obj = self.obj(); + obj.connect_buffer_notify(glib::clone!(@weak self as panel => move |_| { + panel.update_source_view_style(); + })); + } + } +} + +glib::wrapper! { + pub struct CodeView(ObjectSubclass) + @extends gtk::Widget, gtk::TextView, sourceview5::View, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Scrollable; +} diff --git a/src/widgets/export_tab/code.rs b/src/widgets/export_tab/code.rs index cd0d270..9162fc5 100644 --- a/src/widgets/export_tab/code.rs +++ b/src/widgets/export_tab/code.rs @@ -31,13 +31,13 @@ mod imp { use glib::Properties; use gtk::gdk::Display; use gtk::subclass::prelude::*; - use gtk::{gio::SettingsBindFlags, CompositeTemplate}; - use gtk::{prelude::*, template_callbacks, Button, WrapMode}; + use gtk::CompositeTemplate; + use gtk::{prelude::*, template_callbacks, Button}; + use sourceview5::Buffer; use sourceview5::{prelude::*, LanguageManager}; - use sourceview5::{Buffer, StyleSchemeManager, View}; use crate::app::CarteroApplication; - use crate::widgets::{BaseExportPane, BaseExportPaneImpl, ExportType}; + use crate::widgets::{BaseExportPane, BaseExportPaneImpl, CodeView, ExportType}; use crate::win::CarteroWindow; #[derive(Default, CompositeTemplate, Properties)] @@ -45,7 +45,7 @@ mod imp { #[template(resource = "/es/danirod/Cartero/code_export_pane.ui")] pub struct CodeExportPane { #[template_child] - view: TemplateChild, + view: TemplateChild, #[template_child] buffer: TemplateChild, @@ -80,12 +80,6 @@ mod imp { static SIGNALS: OnceLock> = OnceLock::new(); SIGNALS.get_or_init(|| vec![Signal::builder("changed").build()]) } - - fn constructed(&self) { - self.parent_constructed(); - self.init_settings(); - self.init_source_view_style(); - } } impl WidgetImpl for CodeExportPane {} @@ -148,87 +142,6 @@ mod imp { self.buffer.set_language(language.as_ref()); } - - fn init_settings(&self) { - let app = CarteroApplication::get(); - let settings = app.settings(); - - settings - .bind("body-wrap", &*self.view, "wrap-mode") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let enabled = variant.get::().expect("The variant is not a boolean"); - let mode = match enabled { - true => WrapMode::Word, - false => WrapMode::None, - }; - Some(mode.to_value()) - }) - .build(); - - settings - .bind("show-line-numbers", &*self.view, "show-line-numbers") - .flags(SettingsBindFlags::GET) - .build(); - settings - .bind("auto-indent", &*self.view, "auto-indent") - .flags(SettingsBindFlags::GET) - .build(); - settings - .bind("indent-style", &*self.view, "insert-spaces-instead-of-tabs") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let mode = variant - .get::() - .expect("The variant is not a string"); - let use_spaces = mode == "spaces"; - Some(use_spaces.to_value()) - }) - .build(); - settings - .bind("tab-width", &*self.view, "tab-width") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let width = variant.get::().unwrap_or("4".into()); - let value = width.parse::().unwrap_or(4); - Some(value.to_value()) - }) - .build(); - settings - .bind("tab-width", &*self.view, "indent-width") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let width = variant.get::().unwrap_or("4".into()); - let value = width.parse::().unwrap_or(4); - Some(value.to_value()) - }) - .build(); - } - - fn update_source_view_style(&self) { - let dark_mode = adw::StyleManager::default().is_dark(); - let color_theme = if dark_mode { "Adwaita-dark" } else { "Adwaita" }; - let theme = StyleSchemeManager::default().scheme(color_theme); - - match theme { - Some(theme) => { - self.buffer.set_style_scheme(Some(&theme)); - self.buffer.set_highlight_syntax(true); - } - None => { - self.buffer.set_highlight_syntax(false); - } - } - } - - fn init_source_view_style(&self) { - self.update_source_view_style(); - adw::StyleManager::default().connect_dark_notify( - glib::clone!(@weak self as panel => move |_| { - panel.update_source_view_style(); - }), - ); - } } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index c308592..971434d 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -15,6 +15,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later +mod code_view; mod endpoint_pane; mod export_tab; mod file_dialogs; @@ -27,6 +28,7 @@ mod response_headers; mod response_panel; mod save_dialog; +pub use code_view::CodeView; pub use endpoint_pane::EndpointPane; pub use export_tab::*; pub use file_dialogs::*; diff --git a/src/widgets/request_body/raw.rs b/src/widgets/request_body/raw.rs index 5e1b9c0..c3f02ab 100644 --- a/src/widgets/request_body/raw.rs +++ b/src/widgets/request_body/raw.rs @@ -28,21 +28,20 @@ mod imp { use adw::subclass::bin::BinImpl; use glib::subclass::{InitializingObject, Signal}; use glib::Properties; + use gtk::prelude::*; use gtk::subclass::prelude::*; - use gtk::{gio::SettingsBindFlags, CompositeTemplate}; - use gtk::{prelude::*, WrapMode}; + use gtk::CompositeTemplate; + use sourceview5::Buffer; use sourceview5::{prelude::*, LanguageManager}; - use sourceview5::{Buffer, StyleSchemeManager, View}; - use crate::app::CarteroApplication; - use crate::widgets::{BasePayloadPane, BasePayloadPaneImpl, PayloadType}; + use crate::widgets::{BasePayloadPane, BasePayloadPaneImpl, CodeView, PayloadType}; #[derive(Default, CompositeTemplate, Properties)] #[properties(wrapper_type = super::RawPayloadPane)] #[template(resource = "/es/danirod/Cartero/raw_payload_pane.ui")] pub struct RawPayloadPane { #[template_child] - view: TemplateChild, + view: TemplateChild, #[template_child] buffer: TemplateChild, @@ -76,8 +75,6 @@ mod imp { fn constructed(&self) { self.parent_constructed(); - self.init_settings(); - self.init_source_view_style(); self.buffer .connect_changed(glib::clone!(@weak self as pane => move |_| { @@ -130,87 +127,6 @@ mod imp { None => self.buffer.set_language(None), } } - - fn init_settings(&self) { - let app = CarteroApplication::get(); - let settings = app.settings(); - - settings - .bind("body-wrap", &*self.view, "wrap-mode") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let enabled = variant.get::().expect("The variant is not a boolean"); - let mode = match enabled { - true => WrapMode::Word, - false => WrapMode::None, - }; - Some(mode.to_value()) - }) - .build(); - - settings - .bind("show-line-numbers", &*self.view, "show-line-numbers") - .flags(SettingsBindFlags::GET) - .build(); - settings - .bind("auto-indent", &*self.view, "auto-indent") - .flags(SettingsBindFlags::GET) - .build(); - settings - .bind("indent-style", &*self.view, "insert-spaces-instead-of-tabs") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let mode = variant - .get::() - .expect("The variant is not a string"); - let use_spaces = mode == "spaces"; - Some(use_spaces.to_value()) - }) - .build(); - settings - .bind("tab-width", &*self.view, "tab-width") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let width = variant.get::().unwrap_or("4".into()); - let value = width.parse::().unwrap_or(4); - Some(value.to_value()) - }) - .build(); - settings - .bind("tab-width", &*self.view, "indent-width") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let width = variant.get::().unwrap_or("4".into()); - let value = width.parse::().unwrap_or(4); - Some(value.to_value()) - }) - .build(); - } - - fn update_source_view_style(&self) { - let dark_mode = adw::StyleManager::default().is_dark(); - let color_theme = if dark_mode { "Adwaita-dark" } else { "Adwaita" }; - let theme = StyleSchemeManager::default().scheme(color_theme); - - match theme { - Some(theme) => { - self.buffer.set_style_scheme(Some(&theme)); - self.buffer.set_highlight_syntax(true); - } - None => { - self.buffer.set_highlight_syntax(false); - } - } - } - - fn init_source_view_style(&self) { - self.update_source_view_style(); - adw::StyleManager::default().connect_dark_notify( - glib::clone!(@weak self as panel => move |_| { - panel.update_source_view_style(); - }), - ); - } } } diff --git a/src/widgets/response_panel.rs b/src/widgets/response_panel.rs index 1e0dd9c..748971a 100644 --- a/src/widgets/response_panel.rs +++ b/src/widgets/response_panel.rs @@ -33,23 +33,18 @@ use glib::subclass::types::ObjectSubclassIsExt; mod imp { use std::cell::RefCell; + use crate::widgets::{CodeView, ResponseHeaders}; use adw::prelude::*; use adw::subclass::bin::BinImpl; use glib::object::Cast; use glib::subclass::InitializingObject; use glib::Properties; - use gtk::gio::SettingsBindFlags; use gtk::subclass::prelude::*; use gtk::{ subclass::widget::{CompositeTemplateClass, CompositeTemplateInitializingExt, WidgetImpl}, Box, CompositeTemplate, Label, TemplateChild, }; - use gtk::{Spinner, Stack, WrapMode}; - use sourceview5::prelude::BufferExt; - use sourceview5::StyleSchemeManager; - - use crate::app::CarteroApplication; - use crate::widgets::ResponseHeaders; + use gtk::{Spinner, Stack}; #[derive(CompositeTemplate, Default, Properties)] #[properties(wrapper_type = super::ResponsePanel)] @@ -60,7 +55,7 @@ mod imp { #[template_child] pub response_headers: TemplateChild, #[template_child] - pub response_body: TemplateChild, + pub response_body: TemplateChild, #[template_child] pub response_meta: TemplateChild, #[template_child] @@ -94,76 +89,13 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for ResponsePanel { - fn constructed(&self) { - self.parent_constructed(); - - self.init_settings(); - self.init_source_view_style(); - } - } + impl ObjectImpl for ResponsePanel {} impl WidgetImpl for ResponsePanel {} impl BinImpl for ResponsePanel {} impl ResponsePanel { - fn init_settings(&self) { - let app = CarteroApplication::get(); - let settings = app.settings(); - - settings - .bind("body-wrap", &*self.response_body, "wrap-mode") - .flags(SettingsBindFlags::GET) - .mapping(|variant, _| { - let enabled = variant.get::().expect("The variant is not a boolean"); - let mode = match enabled { - true => WrapMode::WordChar, - false => WrapMode::None, - }; - Some(mode.to_value()) - }) - .build(); - settings - .bind( - "show-line-numbers", - &*self.response_body, - "show-line-numbers", - ) - .flags(SettingsBindFlags::GET) - .build(); - } - - fn update_source_view_style(&self) { - let dark_mode = adw::StyleManager::default().is_dark(); - let color_theme = if dark_mode { "Adwaita-dark" } else { "Adwaita" }; - let theme = StyleSchemeManager::default().scheme(color_theme); - - let buffer = self - .response_body - .buffer() - .downcast::() - .unwrap(); - match theme { - Some(theme) => { - buffer.set_style_scheme(Some(&theme)); - buffer.set_highlight_syntax(true); - } - None => { - buffer.set_highlight_syntax(false); - } - } - } - - fn init_source_view_style(&self) { - self.update_source_view_style(); - adw::StyleManager::default().connect_dark_notify( - glib::clone!(@weak self as panel => move |_| { - panel.update_source_view_style(); - }), - ); - } - fn spinning(&self) -> bool { self.metadata_stack .visible_child() From a91e3457d38cf532a99bac63b9ba87fc0b47ba01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Thu, 19 Dec 2024 01:45:23 +0100 Subject: [PATCH 2/7] Search bar checkpoint After this commit, the search bar can be used in the raw payload pane. However, the buttons only print to stdout information about what button or action you made, still pending to connect to a real search system. --- data/cartero.gresource.xml | 3 +- data/meson.build | 1 + data/style.css | 8 +- data/ui/raw_payload_pane.blp | 38 ++++++--- data/ui/search_box.blp | 59 ++++++++++++++ src/widgets/code_view.rs | 27 +++++++ src/widgets/mod.rs | 2 + src/widgets/request_body/raw.rs | 32 +++++++- src/widgets/search_box.rs | 132 ++++++++++++++++++++++++++++++++ 9 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 data/ui/search_box.blp create mode 100644 src/widgets/search_box.rs diff --git a/data/cartero.gresource.xml b/data/cartero.gresource.xml index 53831bc..a67207b 100644 --- a/data/cartero.gresource.xml +++ b/data/cartero.gresource.xml @@ -3,7 +3,6 @@ style.css gtk/help_overlay.ui - ui/endpoint_pane.ui ui/formdata_payload_pane.ui ui/key_value_pane.ui @@ -18,9 +17,9 @@ ui/response_headers.ui ui/response_panel.ui ui/save_dialog.ui + ui/search_box.ui ui/settings_dialog.ui ui/urlencoded_payload_pane.ui - icons/scalable/actions/horizontal-arrows-symbolic.svg icons/scalable/actions/tab-new-symbolic.svg icons/scalable/apps/es.danirod.Cartero.Devel.svg diff --git a/data/meson.build b/data/meson.build index 1db006d..2f2ed7d 100644 --- a/data/meson.build +++ b/data/meson.build @@ -31,6 +31,7 @@ blueprint_files = [ 'ui/response_headers.blp', 'ui/response_panel.blp', 'ui/save_dialog.blp', + 'ui/search_box.blp', 'ui/settings_dialog.blp', 'ui/urlencoded_payload_pane.blp', ] diff --git a/data/style.css b/data/style.css index d23ad66..ded10ae 100644 --- a/data/style.css +++ b/data/style.css @@ -11,4 +11,10 @@ .inline-linked button { border-top-right-radius: 0; border-bottom-right-radius: 0; -} \ No newline at end of file +} + +/* Style for search bar elements. */ +.searchbar { + background: var(--headerbar-bg-color); + border-bottom-left-radius: 10px; +} diff --git a/data/ui/raw_payload_pane.blp b/data/ui/raw_payload_pane.blp index 59f3442..cef05da 100644 --- a/data/ui/raw_payload_pane.blp +++ b/data/ui/raw_payload_pane.blp @@ -23,18 +23,34 @@ template $CarteroRawPayloadPane: $CarteroBasePayloadPane { hexpand: true; vexpand: true; - $CarteroCodeView view { - styles [ - "use-cartero-font" - ] + Gtk.Overlay { + $CarteroCodeView view { + styles [ + "use-cartero-font" + ] - top-margin: 10; - bottom-margin: 10; - left-margin: 10; - right-margin: 10; - smart-backspace: true; - monospace: true; - buffer: buffer; + top-margin: 10; + bottom-margin: 10; + left-margin: 10; + right-margin: 10; + smart-backspace: true; + monospace: true; + buffer: buffer; + search-requested => $on_search_requested() swapped; + } + + [overlay] + $CarteroSearchBox search { + visible: false; + halign: end; + valign: start; + hexpand: false; + vexpand: false; + previous => $on_search_previous() swapped; + next => $on_search_next() swapped; + close => $on_search_close() swapped; + search => $on_search_activate() swapped; + } } } } diff --git a/data/ui/search_box.blp b/data/ui/search_box.blp new file mode 100644 index 0000000..9cd194c --- /dev/null +++ b/data/ui/search_box.blp @@ -0,0 +1,59 @@ +/* + * Copyright 2024 the Cartero authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +// SPDX-License-Identifier: GPL-3.0-or-later +using Gtk 4.0; + +template $CarteroSearchBox: Gtk.Box { + styles [ + "searchbar", + "toolbar" + ] + + Gtk.Box { + styles [ + "linked" + ] + + Gtk.Entry search_content { + primary-icon-name: "system-search-symbolic"; + primary-icon-sensitive: false; + max-width-chars: 30; + placeholder-text: _("Search"); + tooltip-text: _("Search"); + activate => $on_text_activate() swapped; + changed => $on_text_changed() swapped; + } + + Gtk.Button search_previous { + action-name: "search.previous"; + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous result"); + } + + Gtk.Button search_next { + action-name: "search.next"; + icon-name: "go-down-symbolic"; + tooltip-text: _("Next result"); + } + } + + Gtk.Button search_close { + action-name: "search.close"; + icon-name: "window-close-symbolic"; + tooltip-text: _("Close search"); + } +} diff --git a/src/widgets/code_view.rs b/src/widgets/code_view.rs index 463ccdd..601c667 100644 --- a/src/widgets/code_view.rs +++ b/src/widgets/code_view.rs @@ -15,11 +15,16 @@ // // SPDX-License-Identifier: GPL-3.0-or-later +use glib::object::ObjectExt; use gtk::glib; mod imp { + use std::sync::OnceLock; + use glib::object::Cast; + use glib::subclass::Signal; use glib::value::ToValue; + use gtk::gdk; use gtk::gio::SettingsBindFlags; use gtk::prelude::{SettingsExtManual, TextViewExt}; use gtk::subclass::prelude::*; @@ -38,9 +43,25 @@ mod imp { const NAME: &'static str = "CarteroCodeView"; type Type = super::CodeView; type ParentType = sourceview5::View; + + fn class_init(klass: &mut Self::Class) { + klass.add_binding_action( + gdk::Key::F, + gdk::ModifierType::CONTROL_MASK, + "codeview.search", + ); + klass.install_action("codeview.search", None, |widget, _, _| { + widget.start_search(); + }); + } } impl ObjectImpl for CodeView { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("search-requested").build()]) + } + fn constructed(&self) { self.parent_constructed(); self.init_settings(); @@ -148,3 +169,9 @@ glib::wrapper! { @extends gtk::Widget, gtk::TextView, sourceview5::View, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Scrollable; } + +impl CodeView { + pub fn start_search(&self) { + self.emit_by_name::<()>("search-requested", &[]); + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 971434d..7d6354b 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -27,6 +27,7 @@ mod request_body; mod response_headers; mod response_panel; mod save_dialog; +mod search_box; pub use code_view::CodeView; pub use endpoint_pane::EndpointPane; @@ -40,3 +41,4 @@ pub use request_body::*; pub use response_headers::ResponseHeaders; pub use response_panel::ResponsePanel; pub use save_dialog::SaveDialog; +pub use search_box::SearchBox; diff --git a/src/widgets/request_body/raw.rs b/src/widgets/request_body/raw.rs index c3f02ab..78ca8d3 100644 --- a/src/widgets/request_body/raw.rs +++ b/src/widgets/request_body/raw.rs @@ -34,7 +34,7 @@ mod imp { use sourceview5::Buffer; use sourceview5::{prelude::*, LanguageManager}; - use crate::widgets::{BasePayloadPane, BasePayloadPaneImpl, CodeView, PayloadType}; + use crate::widgets::{BasePayloadPane, BasePayloadPaneImpl, CodeView, PayloadType, SearchBox}; #[derive(Default, CompositeTemplate, Properties)] #[properties(wrapper_type = super::RawPayloadPane)] @@ -46,6 +46,9 @@ mod imp { #[template_child] buffer: TemplateChild, + #[template_child] + search: TemplateChild, + #[property(get = Self::format, set = Self::set_format, builder(PayloadType::default()))] _format: RefCell, } @@ -59,6 +62,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { klass.bind_template(); + klass.bind_template_callbacks(); } fn instance_init(obj: &InitializingObject) { @@ -89,6 +93,7 @@ mod imp { impl BasePayloadPaneImpl for RawPayloadPane {} + #[gtk::template_callbacks] impl RawPayloadPane { pub(super) fn payload(&self) -> Vec { let (start, end) = self.buffer.bounds(); @@ -127,6 +132,31 @@ mod imp { None => self.buffer.set_language(None), } } + + #[template_callback] + fn on_search_requested(&self) { + self.search.set_visible(true); + } + + #[template_callback] + fn on_search_previous(&self) { + println!("move previous"); + } + + #[template_callback] + fn on_search_next(&self) { + println!("move next"); + } + + #[template_callback] + fn on_search_close(&self) { + self.search.set_visible(false); + } + + #[template_callback] + fn on_search_activate(&self, value: &str) { + println!("change search to {value}"); + } } } diff --git a/src/widgets/search_box.rs b/src/widgets/search_box.rs new file mode 100644 index 0000000..3086100 --- /dev/null +++ b/src/widgets/search_box.rs @@ -0,0 +1,132 @@ +// Copyright 2024 the Cartero authors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + +use gtk::glib; + +mod imp { + use std::sync::OnceLock; + + use glib::object::ObjectExt; + use glib::subclass::{InitializingObject, Signal}; + use glib::types::StaticType; + use gtk::gio::{ActionEntry, SimpleActionGroup}; + use gtk::glib; + use gtk::prelude::{ActionMapExtManual, EditableExt, WidgetExt}; + use gtk::subclass::prelude::*; + use gtk::{CompositeTemplate, TemplateChild}; + + #[derive(CompositeTemplate, Default)] + #[template(resource = "/es/danirod/Cartero/search_box.ui")] + pub struct SearchBox { + #[template_child] + search_content: TemplateChild, + + #[template_child] + search_previous: TemplateChild, + + #[template_child] + search_next: TemplateChild, + + #[template_child] + search_close: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SearchBox { + const NAME: &'static str = "CarteroSearchBox"; + type Type = super::SearchBox; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SearchBox { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("previous").build(), + Signal::builder("next").build(), + Signal::builder("close").build(), + Signal::builder("search") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + fn constructed(&self) { + self.parent_constructed(); + self.init_actions(); + } + } + + impl WidgetImpl for SearchBox {} + + impl BoxImpl for SearchBox {} + + #[gtk::template_callbacks] + impl SearchBox { + fn init_actions(&self) { + let obj = self.obj(); + let action_previous = ActionEntry::builder("previous") + .activate(glib::clone!(@weak obj => move |_, _, _| { + obj.emit_by_name::<()>("previous", &[]); + })) + .build(); + let action_next = ActionEntry::builder("next") + .activate(glib::clone!(@weak obj => move |_, _, _| { + obj.emit_by_name::<()>("next", &[]); + })) + .build(); + let action_close = ActionEntry::builder("close") + .activate(glib::clone!(@weak obj => move |_, _, _| { + obj.emit_by_name::<()>("close", &[]); + })) + .build(); + let group = SimpleActionGroup::new(); + group.add_action_entries([action_previous, action_next, action_close]); + obj.insert_action_group("search", Some(&group)); + } + + #[template_callback] + fn on_text_changed(&self, entry: >k::Entry) { + let text = entry.text(); + let obj = self.obj(); + obj.emit_by_name::<()>("search", &[&text]); + } + + #[template_callback] + fn on_text_activate(&self) { + let obj = self.obj(); + obj.emit_by_name::<()>("next", &[]); + } + } +} + +glib::wrapper! { + pub struct SearchBox(ObjectSubclass) + @extends gtk::Widget, gtk::Box, + @implements gtk::Accessible, gtk::Actionable, gtk::Buildable; +} From 8847a80e1fc4935425e99b0b5835c93b50733d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Fri, 20 Dec 2024 01:35:18 +0100 Subject: [PATCH 3/7] Provide search backend for the raw payload pane --- data/ui/raw_payload_pane.blp | 38 +++++++---- src/widgets/code_view.rs | 8 ++- src/widgets/request_body/raw.rs | 36 +++++----- src/widgets/search_box.rs | 117 +++++++++++++++++++++++++------- 4 files changed, 141 insertions(+), 58 deletions(-) diff --git a/data/ui/raw_payload_pane.blp b/data/ui/raw_payload_pane.blp index cef05da..a998409 100644 --- a/data/ui/raw_payload_pane.blp +++ b/data/ui/raw_payload_pane.blp @@ -19,11 +19,11 @@ using Gtk 4.0; using GtkSource 5; template $CarteroRawPayloadPane: $CarteroBasePayloadPane { - ScrolledWindow { - hexpand: true; - vexpand: true; + Gtk.Overlay { + ScrolledWindow { + hexpand: true; + vexpand: true; - Gtk.Overlay { $CarteroCodeView view { styles [ "use-cartero-font" @@ -38,21 +38,33 @@ template $CarteroRawPayloadPane: $CarteroBasePayloadPane { buffer: buffer; search-requested => $on_search_requested() swapped; } + } + + [overlay] + Gtk.Revealer search_revealer { + visible: false; + reveal-child: false; + halign: end; + valign: start; + hexpand: false; + vexpand: false; - [overlay] $CarteroSearchBox search { - visible: false; - halign: end; - valign: start; - hexpand: false; - vexpand: false; - previous => $on_search_previous() swapped; - next => $on_search_next() swapped; + editable: view; + search-context: search_context; close => $on_search_close() swapped; - search => $on_search_activate() swapped; } } } } GtkSource.Buffer buffer {} + +GtkSource.SearchContext search_context { + buffer: buffer; + highlight: true; + + settings: GtkSource.SearchSettings { + wrap-around: true; + }; +} diff --git a/src/widgets/code_view.rs b/src/widgets/code_view.rs index 601c667..6f087c0 100644 --- a/src/widgets/code_view.rs +++ b/src/widgets/code_view.rs @@ -15,7 +15,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -use glib::object::ObjectExt; +use glib::{object::ObjectExt, Object}; use gtk::glib; mod imp { @@ -175,3 +175,9 @@ impl CodeView { self.emit_by_name::<()>("search-requested", &[]); } } + +impl Default for CodeView { + fn default() -> Self { + Object::builder().build() + } +} diff --git a/src/widgets/request_body/raw.rs b/src/widgets/request_body/raw.rs index 78ca8d3..8fc5c16 100644 --- a/src/widgets/request_body/raw.rs +++ b/src/widgets/request_body/raw.rs @@ -28,11 +28,11 @@ mod imp { use adw::subclass::bin::BinImpl; use glib::subclass::{InitializingObject, Signal}; use glib::Properties; - use gtk::prelude::*; use gtk::subclass::prelude::*; use gtk::CompositeTemplate; - use sourceview5::Buffer; + use gtk::{prelude::*, Revealer}; use sourceview5::{prelude::*, LanguageManager}; + use sourceview5::{Buffer, SearchContext}; use crate::widgets::{BasePayloadPane, BasePayloadPaneImpl, CodeView, PayloadType, SearchBox}; @@ -49,6 +49,12 @@ mod imp { #[template_child] search: TemplateChild, + #[template_child] + search_revealer: TemplateChild, + + #[template_child] + search_context: TemplateChild, + #[property(get = Self::format, set = Self::set_format, builder(PayloadType::default()))] _format: RefCell, } @@ -135,27 +141,19 @@ mod imp { #[template_callback] fn on_search_requested(&self) { - self.search.set_visible(true); - } - - #[template_callback] - fn on_search_previous(&self) { - println!("move previous"); - } - - #[template_callback] - fn on_search_next(&self) { - println!("move next"); + if !self.search_revealer.reveals_child() { + self.search_revealer.set_visible(true); + self.search_revealer.set_reveal_child(true); + } + self.search.focus(); } #[template_callback] fn on_search_close(&self) { - self.search.set_visible(false); - } - - #[template_callback] - fn on_search_activate(&self, value: &str) { - println!("change search to {value}"); + self.search_revealer.set_reveal_child(false); + self.search_revealer.set_visible(false); + self.search_context.settings().set_search_text(None); + self.view.grab_focus(); } } } diff --git a/src/widgets/search_box.rs b/src/widgets/search_box.rs index 3086100..89b3052 100644 --- a/src/widgets/search_box.rs +++ b/src/widgets/search_box.rs @@ -15,25 +15,38 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -use gtk::glib; +use glib::subclass::types::ObjectSubclassIsExt; +use gtk::{glib, prelude::WidgetExt}; mod imp { + use std::cell::RefCell; use std::sync::OnceLock; use glib::object::ObjectExt; use glib::subclass::{InitializingObject, Signal}; - use glib::types::StaticType; + use glib::Properties; use gtk::gio::{ActionEntry, SimpleActionGroup}; - use gtk::glib; - use gtk::prelude::{ActionMapExtManual, EditableExt, WidgetExt}; + use gtk::prelude::{ActionMapExtManual, EditableExt, TextBufferExt, TextViewExt, WidgetExt}; use gtk::subclass::prelude::*; + use gtk::{gdk, glib, TextIter}; use gtk::{CompositeTemplate, TemplateChild}; + use sourceview5::prelude::SearchSettingsExt; + use sourceview5::SearchContext; - #[derive(CompositeTemplate, Default)] + use crate::widgets::CodeView; + + #[derive(CompositeTemplate, Default, Properties)] #[template(resource = "/es/danirod/Cartero/search_box.ui")] + #[properties(wrapper_type = super::SearchBox)] pub struct SearchBox { + #[property(get, set)] + editable: RefCell, + + #[property(get, set)] + search_context: RefCell, + #[template_child] - search_content: TemplateChild, + pub(super) search_content: TemplateChild, #[template_child] search_previous: TemplateChild, @@ -54,6 +67,9 @@ mod imp { fn class_init(klass: &mut Self::Class) { klass.bind_template(); klass.bind_template_callbacks(); + + klass.add_binding_action(gdk::Key::F, gdk::ModifierType::CONTROL_MASK, "search.focus"); + klass.add_binding_action(gdk::Key::Escape, gdk::ModifierType::empty(), "search.close"); } fn instance_init(obj: &InitializingObject) { @@ -61,19 +77,11 @@ mod imp { } } + #[glib::derived_properties] impl ObjectImpl for SearchBox { fn signals() -> &'static [Signal] { static SIGNALS: OnceLock> = OnceLock::new(); - SIGNALS.get_or_init(|| { - vec![ - Signal::builder("previous").build(), - Signal::builder("next").build(), - Signal::builder("close").build(), - Signal::builder("search") - .param_types([String::static_type()]) - .build(), - ] - }) + SIGNALS.get_or_init(|| vec![Signal::builder("close").build()]) } fn constructed(&self) { @@ -91,13 +99,13 @@ mod imp { fn init_actions(&self) { let obj = self.obj(); let action_previous = ActionEntry::builder("previous") - .activate(glib::clone!(@weak obj => move |_, _, _| { - obj.emit_by_name::<()>("previous", &[]); + .activate(glib::clone!(@weak self as imp => move |_, _, _| { + imp.search_backward(); })) .build(); let action_next = ActionEntry::builder("next") - .activate(glib::clone!(@weak obj => move |_, _, _| { - obj.emit_by_name::<()>("next", &[]); + .activate(glib::clone!(@weak self as imp => move |_, _, _| { + imp.search_forward(); })) .build(); let action_close = ActionEntry::builder("close") @@ -105,22 +113,74 @@ mod imp { obj.emit_by_name::<()>("close", &[]); })) .build(); + let action_focus = ActionEntry::builder("focus") + .activate(glib::clone!(@weak obj => move |_, _, _| { + obj.focus(); + })) + .build(); let group = SimpleActionGroup::new(); - group.add_action_entries([action_previous, action_next, action_close]); + group.add_action_entries([action_previous, action_next, action_close, action_focus]); obj.insert_action_group("search", Some(&group)); } + fn editable_iter(&self, forward: bool) -> TextIter { + let editable = self.editable.borrow(); + let buffer = editable.buffer(); + match buffer.selection_bounds() { + Some((start, end)) => { + if forward { + end + } else { + start + } + } + None => { + let offset = buffer.cursor_position(); + buffer.iter_at_offset(offset) + } + } + } + + fn search_forward(&self) { + let iter = self.editable_iter(true); + let editable = self.editable.borrow(); + let buffer = editable.buffer(); + let search_context = self.search_context.borrow(); + + if let Some((start, end, _wrapped)) = search_context.forward(&iter) { + buffer.select_range(&start, &end); + editable.scroll_mark_onscreen(&buffer.get_insert()); + } + } + + fn search_backward(&self) { + let iter = self.editable_iter(false); + let editable = self.editable.borrow(); + let search_context = self.search_context.borrow(); + let buffer = editable.buffer(); + + if let Some((start, end, _wrapped)) = search_context.backward(&iter) { + buffer.select_range(&start, &end); + editable.scroll_mark_onscreen(&buffer.get_insert()); + } + } + #[template_callback] fn on_text_changed(&self, entry: >k::Entry) { let text = entry.text(); - let obj = self.obj(); - obj.emit_by_name::<()>("search", &[&text]); + let search_context = self.search_context.borrow(); + search_context.settings().set_search_text(Some(&text)); + + // Make an initial search to see if there are results. + let iter = self.editable_iter(true); + let found = search_context.forward(&iter).is_some(); + self.search_next.set_sensitive(found); + self.search_previous.set_sensitive(found); } #[template_callback] fn on_text_activate(&self) { - let obj = self.obj(); - obj.emit_by_name::<()>("next", &[]); + self.search_forward(); } } } @@ -130,3 +190,10 @@ glib::wrapper! { @extends gtk::Widget, gtk::Box, @implements gtk::Accessible, gtk::Actionable, gtk::Buildable; } + +impl SearchBox { + pub fn focus(&self) { + let imp = self.imp(); + imp.search_content.grab_focus(); + } +} From 34073af5b75aac9e572a7d8a8508544bcb6baa12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Fri, 20 Dec 2024 20:49:35 +0100 Subject: [PATCH 4/7] Fill the search box with whatever was selected --- src/widgets/request_body/raw.rs | 12 ++++++++++++ src/widgets/search_box.rs | 23 +++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/widgets/request_body/raw.rs b/src/widgets/request_body/raw.rs index 8fc5c16..6326c82 100644 --- a/src/widgets/request_body/raw.rs +++ b/src/widgets/request_body/raw.rs @@ -139,12 +139,24 @@ mod imp { } } + fn get_selected_text(&self) -> Option { + if self.buffer.has_selection() { + if let Some((start, end)) = self.buffer.selection_bounds() { + let text = self.buffer.slice(&start, &end, false); + return Some(text.into()); + } + } + None + } + #[template_callback] fn on_search_requested(&self) { if !self.search_revealer.reveals_child() { self.search_revealer.set_visible(true); self.search_revealer.set_reveal_child(true); } + let text = self.get_selected_text(); + self.search.init_search(text.as_deref()); self.search.focus(); } diff --git a/src/widgets/search_box.rs b/src/widgets/search_box.rs index 89b3052..72cbd4c 100644 --- a/src/widgets/search_box.rs +++ b/src/widgets/search_box.rs @@ -16,7 +16,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later use glib::subclass::types::ObjectSubclassIsExt; -use gtk::{glib, prelude::WidgetExt}; +use gtk::{ + glib, + prelude::{EditableExt, WidgetExt}, +}; +use sourceview5::prelude::SearchSettingsExt; mod imp { use std::cell::RefCell; @@ -43,7 +47,7 @@ mod imp { editable: RefCell, #[property(get, set)] - search_context: RefCell, + pub(super) search_context: RefCell, #[template_child] pub(super) search_content: TemplateChild, @@ -196,4 +200,19 @@ impl SearchBox { let imp = self.imp(); imp.search_content.grab_focus(); } + + pub fn init_search(&self, text: Option<&str>) { + dbg!(text); + let imp = self.imp(); + let content = &*imp.search_content; + let context = imp.search_context.borrow(); + + if let Some(text) = text { + content.set_text(text); + } + + let search_text: String = content.text().into(); + dbg!(&search_text); + context.settings().set_search_text(Some(&search_text)); + } } From 43710cf88f9c8bc1377587e3dd1d2e874890cbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Fri, 20 Dec 2024 22:43:12 +0100 Subject: [PATCH 5/7] Add ocurrences counter --- Cargo.lock | 3 +- Cargo.toml | 1 + data/ui/search_box.blp | 48 +++++++-- src/i18n.rs | 219 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/widgets/search_box.rs | 56 +++++++++- 6 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 422e4f5..189d4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -145,6 +145,7 @@ dependencies = [ "gtk4", "isahc", "libadwaita", + "regex", "serde", "serde_json", "serde_urlencoded", diff --git a/Cargo.toml b/Cargo.toml index b4258e2..bda01e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ gettext-rs = { version = "0.7.2", features = ["gettext-system"] } glib = "0.19.3" gtk = { package = "gtk4", version = "0.8.2", features = ["v4_12"] } isahc = "1.7.2" +regex = "1.11.1" serde = { version = "1.0.198", features = ["derive"] } serde_json = "1.0.120" serde_urlencoded = "0.7.1" diff --git a/data/ui/search_box.blp b/data/ui/search_box.blp index 9cd194c..a5cf11d 100644 --- a/data/ui/search_box.blp +++ b/data/ui/search_box.blp @@ -24,20 +24,40 @@ template $CarteroSearchBox: Gtk.Box { ] Gtk.Box { - styles [ - "linked" - ] + css-name: "entry"; + width-request: 300; + + Gtk.Image { + icon-name: "system-search-symbolic"; + } - Gtk.Entry search_content { - primary-icon-name: "system-search-symbolic"; - primary-icon-sensitive: false; - max-width-chars: 30; + Gtk.Text search_content { + styles [ + "flat" + ] + + max-width-chars: 10; + width-chars: 10; + hexpand: true; + vexpand: true; placeholder-text: _("Search"); tooltip-text: _("Search"); activate => $on_text_activate() swapped; changed => $on_text_changed() swapped; } + Gtk.Label search_results { + label: ""; + xalign: 1; + opacity: 0.5; + } + } + + Gtk.Box { + styles [ + "linked" + ] + Gtk.Button search_previous { action-name: "search.previous"; icon-name: "go-up-symbolic"; @@ -51,9 +71,15 @@ template $CarteroSearchBox: Gtk.Box { } } - Gtk.Button search_close { - action-name: "search.close"; - icon-name: "window-close-symbolic"; - tooltip-text: _("Close search"); + Gtk.Box { + styles [ + "linked" + ] + + Gtk.Button search_close { + action-name: "search.close"; + icon-name: "window-close-symbolic"; + tooltip-text: _("Close search"); + } } } diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..036ab95 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,219 @@ +// i18n.rs +// +// Copyright 2020 Christopher Davis +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later + +use gettextrs::gettext; +use gettextrs::ngettext; +use gettextrs::npgettext; +use gettextrs::pgettext; +use regex::Captures; +use regex::Regex; + +#[allow(dead_code)] +fn freplace(input: String, args: &[&str]) -> String { + let mut parts = input.split("{}"); + let mut output = parts.next().unwrap_or_default().to_string(); + for (p, a) in parts.zip(args.iter()) { + output += &(a.to_string() + &p.to_string()); + } + output +} + +#[allow(dead_code)] +fn kreplace(input: String, kwargs: &[(&str, &str)]) -> String { + let mut s = input; + for (k, v) in kwargs { + if let Ok(re) = Regex::new(&format!("\\{{{}\\}}", k)) { + s = re + .replace_all(&s, |_: &Captures<'_>| v.to_string()) + .to_string(); + } + } + + s +} + +// Simple translations functions + +#[allow(dead_code)] +pub fn i18n(format: &str) -> String { + gettext(format) +} + +#[allow(dead_code)] +pub fn i18n_f(format: &str, args: &[&str]) -> String { + let s = gettext(format); + freplace(s, args) +} + +#[allow(dead_code)] +pub fn i18n_k(format: &str, kwargs: &[(&str, &str)]) -> String { + let s = gettext(format); + kreplace(s, kwargs) +} + +// Singular and plural translations functions + +#[allow(dead_code)] +pub fn ni18n(single: &str, multiple: &str, number: u32) -> String { + ngettext(single, multiple, number) +} + +#[allow(dead_code)] +pub fn ni18n_f(single: &str, multiple: &str, number: u32, args: &[&str]) -> String { + let s = ngettext(single, multiple, number); + freplace(s, args) +} + +#[allow(dead_code)] +pub fn ni18n_k(single: &str, multiple: &str, number: u32, kwargs: &[(&str, &str)]) -> String { + let s = ngettext(single, multiple, number); + kreplace(s, kwargs) +} + +// Translations with context functions + +#[allow(dead_code)] +pub fn pi18n(ctx: &str, format: &str) -> String { + pgettext(ctx, format) +} + +#[allow(dead_code)] +pub fn pi18n_f(ctx: &str, format: &str, args: &[&str]) -> String { + let s = pgettext(ctx, format); + freplace(s, args) +} + +#[allow(dead_code)] +pub fn pi18n_k(ctx: &str, format: &str, kwargs: &[(&str, &str)]) -> String { + let s = pgettext(ctx, format); + kreplace(s, kwargs) +} + +// Singular and plural with context + +#[allow(dead_code)] +pub fn pni18n(ctx: &str, single: &str, multiple: &str, number: u32) -> String { + npgettext(ctx, single, multiple, number) +} + +#[allow(dead_code)] +pub fn pni18n_f(ctx: &str, single: &str, multiple: &str, number: u32, args: &[&str]) -> String { + let s = npgettext(ctx, single, multiple, number); + freplace(s, args) +} + +#[allow(dead_code)] +pub fn pni18n_k( + ctx: &str, + single: &str, + multiple: &str, + number: u32, + kwargs: &[(&str, &str)], +) -> String { + let s = npgettext(ctx, single, multiple, number); + kreplace(s, kwargs) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_i18n() { + let out = i18n("translate1"); + assert_eq!(out, "translate1"); + + let out = ni18n("translate1", "translate multi", 1); + assert_eq!(out, "translate1"); + let out = ni18n("translate1", "translate multi", 2); + assert_eq!(out, "translate multi"); + } + + #[test] + fn test_i18n_f() { + let out = i18n_f("{} param", &["one"]); + assert_eq!(out, "one param"); + + let out = i18n_f("middle {} param", &["one"]); + assert_eq!(out, "middle one param"); + + let out = i18n_f("end {}", &["one"]); + assert_eq!(out, "end one"); + + let out = i18n_f("multiple {} and {}", &["one", "two"]); + assert_eq!(out, "multiple one and two"); + + let out = ni18n_f("singular {} and {}", "plural {} and {}", 2, &["one", "two"]); + assert_eq!(out, "plural one and two"); + let out = ni18n_f("singular {} and {}", "plural {} and {}", 1, &["one", "two"]); + assert_eq!(out, "singular one and two"); + } + + #[test] + fn test_i18n_k() { + let out = i18n_k("{one} param", &[("one", "one")]); + assert_eq!(out, "one param"); + + let out = i18n_k("middle {one} param", &[("one", "one")]); + assert_eq!(out, "middle one param"); + + let out = i18n_k("end {one}", &[("one", "one")]); + assert_eq!(out, "end one"); + + let out = i18n_k("multiple {one} and {two}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple 1 and two"); + + let out = i18n_k("multiple {two} and {one}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple two and 1"); + + let out = i18n_k("multiple {one} and {one}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple 1 and 1"); + + let out = ni18n_k( + "singular {one} and {two}", + "plural {one} and {two}", + 1, + &[("one", "1"), ("two", "two")], + ); + assert_eq!(out, "singular 1 and two"); + let out = ni18n_k( + "singular {one} and {two}", + "plural {one} and {two}", + 2, + &[("one", "1"), ("two", "two")], + ); + assert_eq!(out, "plural 1 and two"); + } + + #[test] + fn test_pi18n() { + let out = pi18n("This is the context", "translate1"); + assert_eq!(out, "translate1"); + + let out = pni18n("context", "translate1", "translate multi", 1); + assert_eq!(out, "translate1"); + let out = pni18n("The context string", "translate1", "translate multi", 2); + assert_eq!(out, "translate multi"); + + let out = pi18n_f("Context for translation", "{} param", &["one"]); + assert_eq!(out, "one param"); + + let out = pi18n_k("context", "{one} param", &[("one", "one")]); + assert_eq!(out, "one param"); + } +} diff --git a/src/main.rs b/src/main.rs index 3bee7de..a35febf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod app; mod client; mod error; mod file; +mod i18n; mod widgets; #[rustfmt::skip] mod config; diff --git a/src/widgets/search_box.rs b/src/widgets/search_box.rs index 72cbd4c..4a1b229 100644 --- a/src/widgets/search_box.rs +++ b/src/widgets/search_box.rs @@ -37,6 +37,7 @@ mod imp { use sourceview5::prelude::SearchSettingsExt; use sourceview5::SearchContext; + use crate::i18n::i18n_f; use crate::widgets::CodeView; #[derive(CompositeTemplate, Default, Properties)] @@ -50,7 +51,7 @@ mod imp { pub(super) search_context: RefCell, #[template_child] - pub(super) search_content: TemplateChild, + pub(super) search_content: TemplateChild, #[template_child] search_previous: TemplateChild, @@ -60,6 +61,9 @@ mod imp { #[template_child] search_close: TemplateChild, + + #[template_child] + search_results: TemplateChild, } #[glib::object_subclass] @@ -125,6 +129,15 @@ mod imp { let group = SimpleActionGroup::new(); group.add_action_entries([action_previous, action_next, action_close, action_focus]); obj.insert_action_group("search", Some(&group)); + + obj.connect_search_context_notify(glib::clone!(@weak self as imp => move |_| { + let context = imp.search_context.borrow(); + context.connect_occurrences_count_notify( + glib::clone!(@weak imp => move |_| { + imp.update_search_ocurrences(); + }), + ); + })); } fn editable_iter(&self, forward: bool) -> TextIter { @@ -155,6 +168,8 @@ mod imp { buffer.select_range(&start, &end); editable.scroll_mark_onscreen(&buffer.get_insert()); } + + self.update_search_ocurrences(); } fn search_backward(&self) { @@ -167,10 +182,12 @@ mod imp { buffer.select_range(&start, &end); editable.scroll_mark_onscreen(&buffer.get_insert()); } + + self.update_search_ocurrences(); } #[template_callback] - fn on_text_changed(&self, entry: >k::Entry) { + fn on_text_changed(&self, entry: >k::Text) { let text = entry.text(); let search_context = self.search_context.borrow(); search_context.settings().set_search_text(Some(&text)); @@ -180,12 +197,45 @@ mod imp { let found = search_context.forward(&iter).is_some(); self.search_next.set_sensitive(found); self.search_previous.set_sensitive(found); + + self.update_search_ocurrences(); } #[template_callback] fn on_text_activate(&self) { self.search_forward(); } + + fn update_search_ocurrences(&self) { + let editable = self.editable.borrow(); + let buffer = editable.buffer(); + let context = self.search_context.borrow(); + + let total = context.occurrences_count(); + let current = match buffer.selection_bounds() { + Some((start, end)) => context.occurrence_position(&start, &end), + None => -1, + }; + + if total >= 1 && current >= 1 { + let total = format!("{total}"); + let current = format!("{current}"); + + // TRANSLATORS: this string is used to build the search box ocurrences count; the first + // placeholder is the current ocurrence index, the second one is the total. + let label = i18n_f("{} of {}", &[¤t, &total]); + self.search_results.set_label(&label); + } else if total >= 1 { + let total = format!("{total}"); + + // TRANSLATORS: this string is used to build the search box ocurrences count when only + // the number of total ocurrences is known. + let label = i18n_f("{} results", &[&total]); + self.search_results.set_label(&label); + } else { + self.search_results.set_label(""); + } + } } } @@ -202,7 +252,6 @@ impl SearchBox { } pub fn init_search(&self, text: Option<&str>) { - dbg!(text); let imp = self.imp(); let content = &*imp.search_content; let context = imp.search_context.borrow(); @@ -212,7 +261,6 @@ impl SearchBox { } let search_text: String = content.text().into(); - dbg!(&search_text); context.settings().set_search_text(Some(&search_text)); } } From 3249d41438c949f2212a9579734c9c9299ae3f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=20Rodr=C3=ADguez?= Date: Fri, 20 Dec 2024 23:09:52 +0100 Subject: [PATCH 6/7] Finish the search into the other components --- data/ui/code_export_pane.blp | 96 ++++++++++++++++++++++------------ data/ui/response_panel.blp | 63 ++++++++++++++++------ src/widgets/export_tab/code.rs | 44 ++++++++++++++-- src/widgets/response_panel.rs | 45 +++++++++++++++- 4 files changed, 192 insertions(+), 56 deletions(-) diff --git a/data/ui/code_export_pane.blp b/data/ui/code_export_pane.blp index f200ed1..3ed54ae 100644 --- a/data/ui/code_export_pane.blp +++ b/data/ui/code_export_pane.blp @@ -19,52 +19,80 @@ using Gtk 4.0; using GtkSource 5; template $CarteroCodeExportPane: $CarteroBaseExportPane { - ScrolledWindow { - hexpand: true; - vexpand: true; - - Gtk.Overlay { - vexpand: true; + Gtk.Overlay { + ScrolledWindow { hexpand: true; + vexpand: true; - $CarteroCodeView view { - styles [ - "use-cartero-font" - ] + Gtk.Overlay { + vexpand: true; + hexpand: true; - left-margin: 10; - right-margin: 10; - top-margin: 10; - bottom-margin: 10; - smart-backspace: true; - monospace: true; - buffer: buffer; - editable: false; - } + $CarteroCodeView view { + styles [ + "use-cartero-font" + ] - [overlay] - Gtk.Button copy_button { - valign: end; - halign: end; - margin-end: 10; - margin-bottom: 10; - clicked => $on_copy_button_clicked() swapped; + left-margin: 10; + right-margin: 10; + top-margin: 10; + bottom-margin: 10; + smart-backspace: true; + monospace: true; + buffer: buffer; + editable: false; + search-requested => $on_search_requested() swapped; + } - Gtk.Box { - orientation: horizontal; - spacing: 6; + [overlay] + Gtk.Button copy_button { + valign: end; + halign: end; + margin-end: 10; + margin-bottom: 10; + clicked => $on_copy_button_clicked() swapped; - Image { - icon-name: "edit-copy-symbolic"; - } + Gtk.Box { + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "edit-copy-symbolic"; + } - Label { - label: _("Copy"); + Label { + label: _("Copy"); + } } } } } + + [overlay] + Gtk.Revealer search_revealer { + visible: false; + reveal-child: false; + halign: end; + valign: start; + hexpand: false; + vexpand: false; + + $CarteroSearchBox search { + editable: view; + search-context: search_context; + close => $on_search_close() swapped; + } + } } } GtkSource.Buffer buffer {} + +GtkSource.SearchContext search_context { + buffer: buffer; + highlight: true; + + settings: GtkSource.SearchSettings { + wrap-around: true; + }; +} diff --git a/data/ui/response_panel.blp b/data/ui/response_panel.blp index 79af4f5..911a7fb 100644 --- a/data/ui/response_panel.blp +++ b/data/ui/response_panel.blp @@ -47,24 +47,42 @@ template $CarteroResponsePanel: Adw.Bin { label: _("Body"); }; - child: ScrolledWindow { - hexpand: true; - vexpand: true; - - $CarteroCodeView response_body { - styles [ - "use-cartero-font" - ] - - top-margin: 10; - bottom-margin: 10; - left-margin: 10; - right-margin: 10; - smart-backspace: true; - monospace: true; - editable: false; + child: Gtk.Overlay { + ScrolledWindow { + hexpand: true; + vexpand: true; + + $CarteroCodeView response_body { + styles [ + "use-cartero-font" + ] + + top-margin: 10; + bottom-margin: 10; + left-margin: 10; + right-margin: 10; + smart-backspace: true; + monospace: true; + editable: false; + buffer: buffer; + search-requested => $on_search_requested() swapped; + } + } - buffer: GtkSource.Buffer {}; + [overlay] + Gtk.Revealer search_revealer { + visible: false; + reveal-child: false; + halign: end; + valign: start; + hexpand: false; + vexpand: false; + + $CarteroSearchBox search { + editable: response_body; + search-context: search_context; + close => $on_search_close() swapped; + } } }; } @@ -123,3 +141,14 @@ template $CarteroResponsePanel: Adw.Bin { } } } + +GtkSource.Buffer buffer {} + +GtkSource.SearchContext search_context { + buffer: buffer; + highlight: true; + + settings: GtkSource.SearchSettings { + wrap-around: true; + }; +} diff --git a/src/widgets/export_tab/code.rs b/src/widgets/export_tab/code.rs index 9162fc5..76cc20f 100644 --- a/src/widgets/export_tab/code.rs +++ b/src/widgets/export_tab/code.rs @@ -31,13 +31,13 @@ mod imp { use glib::Properties; use gtk::gdk::Display; use gtk::subclass::prelude::*; - use gtk::CompositeTemplate; use gtk::{prelude::*, template_callbacks, Button}; - use sourceview5::Buffer; + use gtk::{CompositeTemplate, Revealer}; use sourceview5::{prelude::*, LanguageManager}; + use sourceview5::{Buffer, SearchContext}; use crate::app::CarteroApplication; - use crate::widgets::{BaseExportPane, BaseExportPaneImpl, CodeView, ExportType}; + use crate::widgets::{BaseExportPane, BaseExportPaneImpl, CodeView, ExportType, SearchBox}; use crate::win::CarteroWindow; #[derive(Default, CompositeTemplate, Properties)] @@ -53,6 +53,15 @@ mod imp { #[template_child] copy_button: TemplateChild