From daf54fea65fcb2da5a91619752a3e1e0a6d4ffc8 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 04:39:16 -0500 Subject: [PATCH 01/62] Inital commit for implementing editing view: * Edit view is created as a navigation page and opened from the setttings of the mediaView page. * Add initial layout for the editing bar at the bottom. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 148 +++++++++++++++++++++++++++++++ furios_gallery/gallery_window.py | 27 +++++- furios_gallery/media_view.py | 10 +++ furios_gallery/ui.py | 18 ++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 furios_gallery/edit_view.py diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py new file mode 100644 index 0000000..64301da --- /dev/null +++ b/furios_gallery/edit_view.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi, os +gi.require_version('Adw', '1') +gi.require_version('Gtk', '4.0') +gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") + +from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene +from .ui import (create_edit_view_main_box, create_edit_view_overlay) + +class EditView(Adw.NavigationPage): + def __init__(self, app, media_path: str): + super().__init__(title="Edit") + self.app = app + self.media_path = media_path + + self.setup_content() + + def setup_content(self): + self.main_box = create_edit_view_main_box() + + self.overlay = create_edit_view_overlay() + + viewer = self.setup_picture_to_edit(self.media_path) + viewer.set_hexpand(True) + viewer.set_vexpand(True) + + self.main_box.append(viewer) + + self.setup_editing_tools_bar() + + self.overlay.set_child(self.main_box) + self.set_child(self.overlay) + self.set_can_pop(False) + + def build_media_widget(self, media_path: str) -> Gtk.Widget: + # Only images for now + if not media_path.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): + # Returning a placeholder label (or raise) for now + label = Gtk.Label(label="Unsupported media type (images only for now).") + label.set_halign(Gtk.Align.CENTER) + label.set_valign(Gtk.Align.CENTER) + label.set_hexpand(True) + label.set_vexpand(True) + return label + + scrolled_win = Gtk.ScrolledWindow() + scrolled_win.set_hexpand(True) + scrolled_win.set_vexpand(True) + scrolled_win.set_halign(Gtk.Align.FILL) + scrolled_win.set_valign(Gtk.Align.FILL) + + zoomable_image = ImageViewerWidget(media_path, self.app, scrolled_win) + zoomable_image.set_hexpand(True) + zoomable_image.set_vexpand(True) + zoomable_image.set_halign(Gtk.Align.CENTER) + zoomable_image.set_valign(Gtk.Align.CENTER) + + scrolled_win.set_child(zoomable_image) + zoomable_image.init_gestures() + + return scrolled_win + + def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: + if not media_path or not os.path.exists(media_path): + empty = Gtk.Label(label="No media found.") + empty.set_hexpand(True) + empty.set_vexpand(True) + empty.set_halign(Gtk.Align.CENTER) + empty.set_valign(Gtk.Align.CENTER) + return empty + + try: + texture = Gdk.Texture.new_from_filename(media_path) + except GLib.Error: + error = Gtk.Label(label="Failed to load image.") + error.set_hexpand(True) + error.set_vexpand(True) + error.set_halign(Gtk.Align.CENTER) + error.set_valign(Gtk.Align.CENTER) + return error + + pic = Gtk.Picture.new_for_paintable(texture) + pic.set_content_fit(Gtk.ContentFit.CONTAIN) + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_halign(Gtk.Align.CENTER) + pic.set_valign(Gtk.Align.CENTER) + + return pic + + def setup_editing_tools_bar(self): + def on_crop_clicked(_btn): + return + + def on_filters_clicked(_btn): + return + + def on_fine_tunes_clicked(_btn): + return + + def on_drawing_clicked(_btn): + return + + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + bar.set_hexpand(True) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_margin_start(12) + bar.set_margin_end(12) + bar.set_margin_bottom(12) + bar.set_margin_top(6) + + bar.add_css_class("toolbar") + bar.add_css_class("osd") + + def _icon_button(icon_name: str, tooltip: str, handler): + btn = Gtk.Button() + btn.set_has_frame(False) + btn.set_tooltip_text(tooltip) + + img = Gtk.Image.new_from_icon_name(icon_name) + img.set_pixel_size(22) + btn.set_child(img) + + btn.connect("clicked", handler) + return btn + + crop_btn = _icon_button("zoom-fit-best", "Crop", on_crop_clicked) + filters_btn = _icon_button("color-select", "Filters", on_filters_clicked) + fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", on_fine_tunes_clicked) + drawing_btn = _icon_button("document-edit", "Drawing", on_drawing_clicked) + + for b in (crop_btn, filters_btn, fine_tunes_btn, drawing_btn): + b.set_hexpand(True) + b.set_halign(Gtk.Align.CENTER) + bar.append(b) + + self.overlay.add_overlay(bar) + bar.set_can_target(True) + + + diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 94d43ec..90326a6 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -7,6 +7,7 @@ # Jesús Higueras # Luis Garcia +import os import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') @@ -14,9 +15,9 @@ from gi.repository import Gtk, Adw, GLib from os.path import expanduser from pathlib import Path -import os from .media_view import MediaView +from .edit_view import EditView from .grid_view import GridView from .albums_view import Albums from .thumbnail_generator import ThumbnailGenerator @@ -154,6 +155,15 @@ def on_navigation_changed(self, navigation_view, page=None): self.media_options_btn.set_visible(False) self.info_btn.set_visible(False) self.return_btn.set_visible(True) + elif visible_page.get_title() == "Edit": + # Edit view header + curr_file = f"Editing {os.path.basename(self.media_paths[self.current_index])}" + self.header.set_title_widget(Adw.WindowTitle(title=curr_file)) + self.create_album_btn.set_visible(False) + self.delete_media_btn.set_visible(False) + self.media_options_btn.set_visible(False) + self.info_btn.set_visible(False) + self.return_btn.set_visible(True) else: # Grid view header self.header.set_title_widget(Adw.WindowTitle(title=self.current_album)) @@ -223,6 +233,16 @@ def create_media_view_page(self): media_view.set_tag("mediaView") return media_view + def create_edit_media_view_page(self, curr_pix_buff): + edit_view = EditView(self, curr_pix_buff) + edit_view.set_halign(Gtk.Align.FILL) + edit_view.set_valign(Gtk.Align.FILL) + edit_view.set_hexpand(True) + edit_view.set_vexpand(True) + edit_view.set_name("editView-square") + edit_view.set_tag("editView") + return edit_view + def create_grid_view_page(self, album_name=None): media_grid_view = GridView(self, self.thumbnails) media_grid_view.set_halign(Gtk.Align.FILL) @@ -251,6 +271,11 @@ def open_media_at_index(self, media_index): media_page = self.create_media_view_page() self.navigation_view.push(media_page) + def open_media_edit(self, media_index: int, media_path: str): + self.current_index = media_index + edit_page = self.create_edit_media_view_page(media_path) + self.navigation_view.push(edit_page) + def create_album(self, button): dialog, entry = create_album_create_dialog(self) diff --git a/furios_gallery/media_view.py b/furios_gallery/media_view.py index 824d5b0..49a3283 100644 --- a/furios_gallery/media_view.py +++ b/furios_gallery/media_view.py @@ -80,6 +80,16 @@ def open_menu_popup(self, btn): remove_from_album_btn = create_option_button("Remove from Album", self.delete_from_album, dialog) media_options.append(remove_from_album_btn) + edit_medit_btn = create_option_button( + "Edit Media", + lambda _btn: ( + dialog.close(), + self.app.open_media_edit(self.app.current_index, self.app.media_paths[self.app.current_index]) + ) + ) + + media_options.append(edit_medit_btn) + close_media_options_btn = create_option_button("Cancel", self.on_close_media_options, dialog) media_options.append(close_media_options_btn) diff --git a/furios_gallery/ui.py b/furios_gallery/ui.py index 317cd2b..0f72bc1 100644 --- a/furios_gallery/ui.py +++ b/furios_gallery/ui.py @@ -190,6 +190,24 @@ def setup_grid_view_css(): display = Gdk.Display.get_default() Gtk.StyleContext.add_provider_for_display(display, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) +def create_edit_view_main_box() -> Gtk.Box: + """Create main content box for edit view.""" + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_box.set_halign(Gtk.Align.FILL) + main_box.set_valign(Gtk.Align.FILL) + main_box.set_hexpand(True) + main_box.set_vexpand(True) + return main_box + +def create_edit_view_overlay() -> Gtk.Overlay: + """Create overlay for edit view.""" + overlay = Gtk.Overlay() + overlay.set_halign(Gtk.Align.FILL) + overlay.set_valign(Gtk.Align.FILL) + overlay.set_hexpand(True) + overlay.set_vexpand(True) + return overlay + def create_media_view_main_box() -> Gtk.Box: """Create main content box for media view.""" main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) From 7cd722be3bca56def9cf08b9e2606ac6c146b33e Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 05:34:59 -0500 Subject: [PATCH 02/62] Change File Name: Initial Commit to change file name. TBD: Update the database and refresh media_view and grid_view. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 47 ++++++++++++++++++++++++ furios_gallery/gallery_window.py | 48 +++++++++++++++++++++++-- furios_gallery/ui.py | 53 ++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 furios_gallery/furios_media_tools.py diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py new file mode 100644 index 0000000..4ffaca7 --- /dev/null +++ b/furios_gallery/furios_media_tools.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import os + +class FuriOSMediaTools: + @staticmethod + def _basename_without_ext(path: str) -> str: + base = os.path.basename(path) + name, _ext = os.path.splitext(base) + return name + + @staticmethod + def change_file_name(src_path: str, new_base_name: str) -> tuple[bool, str]: + if not src_path or not os.path.exists(src_path): + return False, "Source file does not exist." + + new_base_name = new_base_name.strip() + + if not new_base_name: + return False, "File name cannot be empty." + if "/" in new_base_name or "\x00" in new_base_name: + return False, "Invalid characters in file name." + if new_base_name in (".", ".."): + return False, "Invalid file name." + + directory = os.path.dirname(src_path) + old_base, ext = os.path.splitext(os.path.basename(src_path)) + + # Enforce: no extension change + if "." in new_base_name: + return False, "Do not include a file extension." + + new_path = os.path.join(directory, new_base_name + ext) + + if os.path.exists(new_path): + return False, "A file with that name already exists." + + try: + os.rename(src_path, new_path) + #TBD: Update any media database, refresh header, refresh carousel and gridview. + return True, new_path + except OSError as e: + return False, str(e) \ No newline at end of file diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 90326a6..910956e 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -10,12 +10,14 @@ import os import gi gi.require_version('Gtk', '4.0') +gi.require_version('Gdk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, GLib +from gi.repository import Gtk, Gdk, Adw, GLib from os.path import expanduser from pathlib import Path +from .furios_media_tools import FuriOSMediaTools from .media_view import MediaView from .edit_view import EditView from .grid_view import GridView @@ -32,7 +34,7 @@ create_gallery_header, create_album_button, create_info_button, create_media_options_button, create_delete_media_button, create_return_button, create_main_window_layout, create_album_create_dialog, create_selection_header_bar, create_delete_confirmation_dialog, - create_map_page, clear_flowbox + create_map_page, clear_flowbox, create_change_file_name_button, create_rename_dialog ) class GalleryWindow(Adw.ApplicationWindow): @@ -87,6 +89,10 @@ def __init__(self, *args, **kwargs): self.return_btn = create_return_button(self.on_return_clicked) self.header.pack_start(self.return_btn) + # Create change Name button (initially hidden) + self.create_change_name_btn = create_change_file_name_button(self.change_file_name) + self.header.pack_start(self.create_change_name_btn) + # Create initial albums page self.initial_albums_page = self.create_albums_page() self.navigation_view.add(self.initial_albums_page) @@ -134,6 +140,7 @@ def on_navigation_changed(self, navigation_view, page=None): if isinstance(visible_page, MediaView): # Media view header self.header.set_title_widget(Adw.WindowTitle(title="Media")) + self.create_change_name_btn.set_visible(True) self.create_album_btn.set_visible(False) self.delete_media_btn.set_visible(True) self.media_options_btn.set_visible(True) @@ -142,6 +149,7 @@ def on_navigation_changed(self, navigation_view, page=None): elif visible_page.get_title() == "Albums": # Album view header self.header.set_title_widget(Adw.WindowTitle(title="Gallery")) + self.create_change_name_btn.set_visible(False) self.create_album_btn.set_visible(True) self.delete_media_btn.set_visible(True) self.media_options_btn.set_visible(False) @@ -150,6 +158,7 @@ def on_navigation_changed(self, navigation_view, page=None): elif visible_page.get_title() == "Location": # Map view header self.header.set_title_widget(Adw.WindowTitle(title=self.current_album)) + self.create_change_name_btn.set_visible(False) self.create_album_btn.set_visible(False) self.delete_media_btn.set_visible(False) self.media_options_btn.set_visible(False) @@ -159,6 +168,7 @@ def on_navigation_changed(self, navigation_view, page=None): # Edit view header curr_file = f"Editing {os.path.basename(self.media_paths[self.current_index])}" self.header.set_title_widget(Adw.WindowTitle(title=curr_file)) + self.create_change_name_btn.set_visible(True) self.create_album_btn.set_visible(False) self.delete_media_btn.set_visible(False) self.media_options_btn.set_visible(False) @@ -463,3 +473,37 @@ def on_delete_albums(self, dialog, response): current_page.flowbox.set_selection_mode(Gtk.SelectionMode.SINGLE) dialog.destroy() + + def change_file_name(self, _button): + curr_file_path = self.media_paths[self.current_index] + initial = FuriOSMediaTools._basename_without_ext(curr_file_path) + + dlg, entry, error_label, rename_btn, cancel_btn = create_rename_dialog( + self.get_root(), + initial + ) + + entry.connect("changed", lambda *_: error_label.set_visible(False)) + + def do_rename(): + success, text = FuriOSMediaTools.change_file_name( + curr_file_path, + entry.get_text().strip() + ) + if not success: + error_label.set_text(text) + error_label.set_visible(True) + entry.grab_focus() + entry.select_region(0, -1) + return + + self.media_paths[self.current_index] = text + print(f"File renamed to: {text}") + dlg.close() + + rename_btn.connect("clicked", lambda _b: do_rename()) + cancel_btn.connect("clicked", lambda _b: dlg.close()) + + dlg.present(self.get_root()) + entry.grab_focus() + entry.select_region(0, -1) diff --git a/furios_gallery/ui.py b/furios_gallery/ui.py index 0f72bc1..659e819 100644 --- a/furios_gallery/ui.py +++ b/furios_gallery/ui.py @@ -26,6 +26,13 @@ def create_album_button(callback: Callable) -> Gtk.Button: button.connect("clicked", callback) return button +def create_change_file_name_button(callback: Callable) -> Gtk.Button: + """Create change file name button for header.""" + button = Gtk.Button(icon_name="text-editor-symbolic") + button.connect("clicked", callback) + button.set_visible(False) + return button + def create_info_button(callback: Callable) -> Gtk.Button: """Create info button for header.""" button = Gtk.Button(icon_name="help-about-symbolic") @@ -85,6 +92,52 @@ def create_albums_flowbox(selection_callback: Callable, update_callback: Callabl flowbox.connect("selected-children-changed", update_callback) return flowbox +def create_rename_dialog(parent, initial_name: str): + entry = Gtk.Entry() + entry.set_hexpand(True) + entry.set_text(initial_name) + entry.set_activates_default(True) + + hint = Gtk.Label(label="Only the file name will change, not the extension.") + hint.set_halign(Gtk.Align.START) + hint.set_wrap(True) + hint.add_css_class("dim-label") + + error_label = Gtk.Label() + error_label.set_halign(Gtk.Align.START) + error_label.set_wrap(True) + error_label.set_visible(False) + error_label.add_css_class("error") + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + content.set_margin_top(12) + content.set_margin_bottom(12) + content.set_margin_start(12) + content.set_margin_end(12) + content.append(entry) + content.append(hint) + content.append(error_label) + + cancel_btn = Gtk.Button(label="Cancel") + rename_btn = Gtk.Button(label="Rename") + rename_btn.add_css_class("suggested-action") + + entry.connect("activate", lambda _e: rename_btn.emit("clicked")) + + header = Adw.HeaderBar() + header.pack_start(cancel_btn) + header.pack_end(rename_btn) + + toolbar = Adw.ToolbarView() + toolbar.add_top_bar(header) + toolbar.set_content(content) + + dlg = Adw.Dialog() + dlg.set_title("Rename file") + dlg.set_child(toolbar) + + return dlg, entry, error_label, rename_btn, cancel_btn + def create_album_item(album_name: str, thumbnail_path: str = None) -> Gtk.FlowBoxChild: """Create an album item for the flowbox.""" flowbox_child = Gtk.FlowBoxChild() From 1bfec07dbaa8e2b23af2dadca8d08bf9fa614e0f Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 05:51:52 -0500 Subject: [PATCH 03/62] change file name: I forgot that database has a watcher, it updates itself already. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 1 - furios_gallery/gallery_window.py | 1 - 2 files changed, 2 deletions(-) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 4ffaca7..590bfe3 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -41,7 +41,6 @@ def change_file_name(src_path: str, new_base_name: str) -> tuple[bool, str]: try: os.rename(src_path, new_path) - #TBD: Update any media database, refresh header, refresh carousel and gridview. return True, new_path except OSError as e: return False, str(e) \ No newline at end of file diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 910956e..0bdc9a9 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -498,7 +498,6 @@ def do_rename(): return self.media_paths[self.current_index] = text - print(f"File renamed to: {text}") dlg.close() rename_btn.connect("clicked", lambda _b: do_rename()) From a09fe3d2d2de57947536a949e760f7a0e5216dbd Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 21:38:00 -0500 Subject: [PATCH 04/62] EditView: Load an ImageWidget within the EditView's overlay. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 107 ++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 64301da..47111ee 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -10,6 +10,8 @@ gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") +from .crop_overlay import CropOverlay +from .image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene from .ui import (create_edit_view_main_box, create_edit_view_overlay) @@ -18,53 +20,69 @@ def __init__(self, app, media_path: str): super().__init__(title="Edit") self.app = app self.media_path = media_path - + self.texture: Gdk.Texture | None = None + self.picture: Gtk.ScrolledWindow | None = None + self.crop_overlay = None self.setup_content() def setup_content(self): self.main_box = create_edit_view_main_box() - self.overlay = create_edit_view_overlay() viewer = self.setup_picture_to_edit(self.media_path) - viewer.set_hexpand(True) - viewer.set_vexpand(True) + # self.image_overlay.set_child(viewer) self.main_box.append(viewer) + # bottom tools bar on OUTER overlay (so it stays visible) self.setup_editing_tools_bar() + # Set the image viewer as the child of the main box. self.overlay.set_child(self.main_box) + + # Set Content for Navigation Page. self.set_child(self.overlay) + + # Disable gesture navigation self.set_can_pop(False) - def build_media_widget(self, media_path: str) -> Gtk.Widget: - # Only images for now - if not media_path.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): - # Returning a placeholder label (or raise) for now - label = Gtk.Label(label="Unsupported media type (images only for now).") - label.set_halign(Gtk.Align.CENTER) - label.set_valign(Gtk.Align.CENTER) - label.set_hexpand(True) - label.set_vexpand(True) - return label - - scrolled_win = Gtk.ScrolledWindow() - scrolled_win.set_hexpand(True) - scrolled_win.set_vexpand(True) - scrolled_win.set_halign(Gtk.Align.FILL) - scrolled_win.set_valign(Gtk.Align.FILL) - - zoomable_image = ImageViewerWidget(media_path, self.app, scrolled_win) + def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: + if not media_path or not os.path.exists(media_path): + empty = Gtk.Label(label="No media found.") + empty.set_hexpand(True) + empty.set_vexpand(True) + empty.set_halign(Gtk.Align.CENTER) + empty.set_valign(Gtk.Align.CENTER) + return empty + + try: + self.texture = Gdk.Texture.new_from_filename(media_path) + except GLib.Error: + error = Gtk.Label(label="Failed to load image.") + error.set_hexpand(True) + error.set_vexpand(True) + error.set_halign(Gtk.Align.CENTER) + error.set_valign(Gtk.Align.CENTER) + return error + + scrolled = Gtk.ScrolledWindow() + scrolled.set_hexpand(True) + scrolled.set_vexpand(True) + scrolled.set_halign(Gtk.Align.FILL) + scrolled.set_valign(Gtk.Align.FILL) + + zoomable_image = ImageViewerWidget(media_path, self.app, scrolled) zoomable_image.set_hexpand(True) zoomable_image.set_vexpand(True) zoomable_image.set_halign(Gtk.Align.CENTER) zoomable_image.set_valign(Gtk.Align.CENTER) - scrolled_win.set_child(zoomable_image) + scrolled.set_child(zoomable_image) zoomable_image.init_gestures() - return scrolled_win + self.picture = scrolled + + return scrolled def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: if not media_path or not os.path.exists(media_path): @@ -76,7 +94,7 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: return empty try: - texture = Gdk.Texture.new_from_filename(media_path) + self.texture = Gdk.Texture.new_from_filename(media_path) except GLib.Error: error = Gtk.Label(label="Failed to load image.") error.set_hexpand(True) @@ -85,18 +103,38 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: error.set_valign(Gtk.Align.CENTER) return error - pic = Gtk.Picture.new_for_paintable(texture) - pic.set_content_fit(Gtk.ContentFit.CONTAIN) - pic.set_hexpand(True) - pic.set_vexpand(True) - pic.set_halign(Gtk.Align.CENTER) - pic.set_valign(Gtk.Align.CENTER) + scrolled = Gtk.ScrolledWindow() + scrolled.set_hexpand(True) + scrolled.set_vexpand(True) + scrolled.set_halign(Gtk.Align.FILL) + scrolled.set_valign(Gtk.Align.FILL) + + zoomable_image = ImageViewerWidget(media_path, self.app, scrolled) + zoomable_image.set_hexpand(True) + zoomable_image.set_vexpand(True) + zoomable_image.set_halign(Gtk.Align.CENTER) + zoomable_image.set_valign(Gtk.Align.CENTER) - return pic + scrolled.set_child(zoomable_image) + zoomable_image.init_gestures() + + self.picture = scrolled + + return scrolled def setup_editing_tools_bar(self): def on_crop_clicked(_btn): - return + if not self.texture or not self.picture: + return + + if self.crop_overlay is None: + self.crop_overlay = CropOverlay(self.picture, self.texture) + + self.image_overlay.add_overlay(self.crop_overlay) + self.crop_overlay.queue_draw() + else: + self.image_overlay.remove_overlay(self.crop_overlay) + self.crop_overlay = None def on_filters_clicked(_btn): return @@ -143,6 +181,3 @@ def _icon_button(icon_name: str, tooltip: str, handler): self.overlay.add_overlay(bar) bar.set_can_target(True) - - - From 400b58645d491f478bc9b92b939c3403e81d0977 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 22:08:11 -0500 Subject: [PATCH 05/62] image_viewer_widget: Allow disabling and enabling of zoom gesture on the widget. Signed-off-by: Joaquin Philco --- furios_gallery/image_viewer_widget.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/furios_gallery/image_viewer_widget.py b/furios_gallery/image_viewer_widget.py index 7a6f2fd..5e40f1c 100644 --- a/furios_gallery/image_viewer_widget.py +++ b/furios_gallery/image_viewer_widget.py @@ -21,12 +21,19 @@ def __init__(self, path, win, scrolled_win, *args, **kwargs): self.min_scale = 0 self.scale = 1.0 self.scale_at_start = 1.0 + self.zoom_enabled = True self.scrolled_win = scrolled_win self.win = win # Calculate the initial scale to fit the image within the window self.calculate_initial_scale() + def set_zoom_enabled(self, enabled: bool): + self.zoom_enabled = enabled + if not enabled and getattr(self, "zoom_gesture", None): + # drop any in-progress gesture cleanly + self.zoom_gesture.reset() + def calculate_initial_scale(self): win_width = self.win.get_width() win_height = self.win.get_height() @@ -61,9 +68,13 @@ def init_gestures(self): self.scrolled_win.add_controller(self.zoom_gesture) def on_zoom_begin(self, gesture, sequence): + if not self.zoom_enabled: + return self.scale_at_start = self.scale def on_zoom(self, gesture, scale_delta): + if not self.zoom_enabled: + return zoom_factor = (scale_delta * self.scale_at_start) / self.scale self.queue_resize() From 56be030467669e38169ec497b4666a59621d7d1f Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 22:13:30 -0500 Subject: [PATCH 06/62] image_viewer_widget: Implement reseting of the viewed picture. Signed-off-by: Joaquin Philco --- furios_gallery/image_viewer_widget.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/furios_gallery/image_viewer_widget.py b/furios_gallery/image_viewer_widget.py index 5e40f1c..8a6d759 100644 --- a/furios_gallery/image_viewer_widget.py +++ b/furios_gallery/image_viewer_widget.py @@ -28,6 +28,28 @@ def __init__(self, path, win, scrolled_win, *args, **kwargs): # Calculate the initial scale to fit the image within the window self.calculate_initial_scale() + def reset_view_fit(self, center=True): + self.calculate_initial_scale() + self.scale_at_start = self.scale + + hadj = self.scrolled_win.get_hadjustment() + vadj = self.scrolled_win.get_vadjustment() + if not hadj or not vadj: + return + + if not center: + hadj.set_value(0.0) + vadj.set_value(0.0) + return + + hx = max(0.0, (hadj.get_upper() - hadj.get_page_size()) / 2.0) + vy = max(0.0, (vadj.get_upper() - vadj.get_page_size()) / 2.0) + hadj.set_value(hx) + vadj.set_value(vy) + + self.queue_resize() + self.queue_draw() + def set_zoom_enabled(self, enabled: bool): self.zoom_enabled = enabled if not enabled and getattr(self, "zoom_gesture", None): From 54ecf5a1dd1e76fe7e22c0ffcc01a2508d895261 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 23:38:48 -0500 Subject: [PATCH 07/62] crop_overlay: Implement overlay widget that acts as a simple crop ui for the user. Its very bare bones right now, it resets everytime you click outside of the image, but, a win is a win. * The actual saving to storage and cropping is yet to be added, this ony GTK4 so far. Signed-off-by: Joaquin Philco --- furios_gallery/crop_overlay.py | 232 +++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 furios_gallery/crop_overlay.py diff --git a/furios_gallery/crop_overlay.py b/furios_gallery/crop_overlay.py new file mode 100644 index 0000000..6a5d9f8 --- /dev/null +++ b/furios_gallery/crop_overlay.py @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gtk, Gdk, Graphene, Gsk + +class CropOverlay(Gtk.Widget): + HANDLE = 12 + HIT = 18 + MIN_SIZE = 32 + + def __init__(self, picture_widget: Gtk.Widget, texture: Gdk.Texture): + super().__init__() + self.picture_widget = picture_widget + self.texture = texture + + self.initial_h = self.texture.get_height() + self.initial_w = self.texture.get_width() + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_halign(Gtk.Align.FILL) + self.set_valign(Gtk.Align.FILL) + self.set_can_target(True) + + self.rect = [0.0, 0.0, 0.0, 0.0] + + self.mode = None + self.drag_start = (0.0, 0.0) + self.rect_start = None + + drag = Gtk.GestureDrag.new() + drag.connect("drag-begin", self.on_drag_begin) + drag.connect("drag-update", self.on_drag_update) + drag.connect("drag-end", self.on_drag_end) + self.add_controller(drag) + + def image_rect_in_widget(self) -> tuple[float, float, float, float]: + w = float(self.get_allocated_width()) + h = float(self.get_allocated_height()) + + tw = float(self.texture.get_width()) + th = float(self.texture.get_height()) + + if w <= 0 or h <= 0 or tw <= 0 or th <= 0: + return (0.0, 0.0, w, h) + + scale = min(w / tw, h / th) + iw = tw * scale + ih = th * scale + ix = (w - iw) * 0.5 + iy = (h - ih) * 0.5 + return (ix, iy, iw, ih) + + def clamp_rect_to_image(self, rect): + ix, iy, iw, ih = self.image_rect_in_widget() + x, y, w, h = rect + + w = max(w, self.MIN_SIZE) + h = max(h, self.MIN_SIZE) + + w = min(w, iw) + h = min(h, ih) + + x = max(ix, min(x, ix + iw - w)) + y = max(iy, min(y, iy + ih - h)) + + return [x, y, w, h] + + def init_default_rect(self): + ix, iy, iw, ih = self.image_rect_in_widget() + w = iw * 0.6 + h = ih * 0.6 + x = ix + (iw - w) * 0.5 + y = iy + (ih - h) * 0.5 + self.rect = self.clamp_rect_to_image([x, y, w, h]) + self.queue_draw() + + def handle_points(self): + x, y, w, h = self.rect + return { + "nw": (x, y), + "ne": (x + w, y), + "sw": (x, y + h), + "se": (x + w, y + h), + } + + def hit_test(self, px: float, py: float) -> str | None: + for name, (hx, hy) in self.handle_points().items(): + if (px - hx) ** 2 + (py - hy) ** 2 <= (self.HIT ** 2): + return name + + x, y, w, h = self.rect + if x <= px <= x + w and y <= py <= y + h: + return "move" + return None + + def snapshot_circle(self, snapshot: Gtk.Snapshot, cx: float, cy: float, r: float, color: Gdk.RGBA): + rect = Graphene.Rect().init(cx - r, cy - r, 2*r, 2*r) + + rr = Gsk.RoundedRect() + rad = Graphene.Size() + rad.init(r, r) + + rr.init(rect, rad, rad, rad, rad) + + snapshot.push_rounded_clip(rr) + snapshot.append_color(color, rect) + snapshot.pop() + + def do_snapshot(self, snapshot: Gtk.Snapshot): + w = float(self.get_allocated_width()) + h = float(self.get_allocated_height()) + + if self.rect[2] <= 0 or self.rect[3] <= 0: + self.init_default_rect() + + x, y, rw, rh = self.rect + + dim = Gdk.RGBA(); dim.parse("rgba(0,0,0,0.45)") + white = Gdk.RGBA(); white.parse("rgba(255,255,255,0.90)") + + # dim around crop rect + snapshot.append_color(dim, Graphene.Rect().init(0, 0, w, y)) + snapshot.append_color(dim, Graphene.Rect().init(0, y + rh, w, max(0.0, h - (y + rh)))) + snapshot.append_color(dim, Graphene.Rect().init(0, y, x, rh)) + snapshot.append_color(dim, Graphene.Rect().init(x + rw, y, max(0.0, w - (x + rw)), rh)) + + # border + t = 2.0 + snapshot.append_color(white, Graphene.Rect().init(x, y, rw, t)) + snapshot.append_color(white, Graphene.Rect().init(x, y + rh - t, rw, t)) + snapshot.append_color(white, Graphene.Rect().init(x, y, t, rh)) + snapshot.append_color(white, Graphene.Rect().init(x + rw - t, y, t, rh)) + + # circular handles + fill = Gdk.RGBA(); fill.parse("rgba(255,255,255,0.25)") + stroke = Gdk.RGBA(); stroke.parse("rgba(255,255,255,0.95)") + + r_outer = float(self.HANDLE) * 0.5 + r_inner = max(1.0, r_outer - 2.0) + + for _, (hx, hy) in self.handle_points().items(): + self.snapshot_circle(snapshot, hx, hy, r_outer, stroke) + self.snapshot_circle(snapshot, hx, hy, r_inner, fill) + + def on_drag_begin(self, gesture, start_x, start_y): + if self.rect[2] <= 0 or self.rect[3] <= 0: + self.init_default_rect() + + mode = self.hit_test(float(start_x), float(start_y)) + if mode is None: + ix, iy, iw, ih = self.image_rect_in_widget() + if not (ix <= start_x <= ix + iw and iy <= start_y <= iy + ih): + self.mode = None + return + self.rect = self.clamp_rect_to_image([start_x - 80, start_y - 80, 160, 160]) + self.queue_draw() + mode = "move" + + self.mode = mode + self.drag_start = (float(start_x), float(start_y)) + self.rect_start = self.rect.copy() + + def on_drag_update(self, gesture, offset_x, offset_y): + if not self.mode or self.rect_start is None: + return + + dx = float(offset_x) + dy = float(offset_y) + + x0, y0, w0, h0 = self.rect_start + + if self.mode == "move": + new_rect = [x0 + dx, y0 + dy, w0, h0] + else: + x, y, w, h = x0, y0, w0, h0 + + if self.mode == "nw": + x = x0 + dx + y = y0 + dy + w = (x0 + w0) - x + h = (y0 + h0) - y + elif self.mode == "ne": + y = y0 + dy + w = w0 + dx + h = (y0 + h0) - y + elif self.mode == "sw": + x = x0 + dx + w = (x0 + w0) - x + h = h0 + dy + elif self.mode == "se": + w = w0 + dx + h = h0 + dy + + new_rect = [x, y, w, h] + + self.rect = self.clamp_rect_to_image(new_rect) + self.queue_draw() + + def on_drag_end(self, *_args): + self.mode = None + self.rect_start = None + + def get_crop_in_image_pixels(self) -> tuple[int, int, int, int]: + ix, iy, iw, ih = self.image_rect_in_widget() + x, y, w, h = self.rect + + nx = (x - ix) / iw + ny = (y - iy) / ih + nw = w / iw + nh = h / ih + + tw = self.texture.get_width() + th = self.texture.get_height() + + x_px = int(nx * tw) + y_px = int(ny * th) + w_px = int(nw * tw) + h_px = int(nh * th) + + x_px = max(0, min(x_px, tw - 1)) + y_px = max(0, min(y_px, th - 1)) + w_px = max(1, min(w_px, tw - x_px)) + h_px = max(1, min(h_px, th - y_px)) + + return (x_px, y_px, w_px, h_px) From 3447217b356339feb46314b76df3cd94ded92be8 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 25 Jan 2026 23:39:28 -0500 Subject: [PATCH 08/62] edit_view: Implement the crop overlay widget when crop button pressed. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 152 +++++++++++++++++++++++------------- 1 file changed, 99 insertions(+), 53 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 47111ee..3b8dfdf 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -20,6 +20,7 @@ def __init__(self, app, media_path: str): super().__init__(title="Edit") self.app = app self.media_path = media_path + self.zoomable_image = None self.texture: Gdk.Texture | None = None self.picture: Gtk.ScrolledWindow | None = None self.crop_overlay = None @@ -31,7 +32,7 @@ def setup_content(self): viewer = self.setup_picture_to_edit(self.media_path) - # self.image_overlay.set_child(viewer) + # self.overlay.set_child(viewer) self.main_box.append(viewer) # bottom tools bar on OUTER overlay (so it stays visible) @@ -71,78 +72,54 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: scrolled.set_halign(Gtk.Align.FILL) scrolled.set_valign(Gtk.Align.FILL) - zoomable_image = ImageViewerWidget(media_path, self.app, scrolled) - zoomable_image.set_hexpand(True) - zoomable_image.set_vexpand(True) - zoomable_image.set_halign(Gtk.Align.CENTER) - zoomable_image.set_valign(Gtk.Align.CENTER) + self.zoomable_image = ImageViewerWidget(media_path, self.app, scrolled) + self.zoomable_image.set_hexpand(True) + self.zoomable_image.set_vexpand(True) + self.zoomable_image.set_halign(Gtk.Align.CENTER) + self.zoomable_image.set_valign(Gtk.Align.CENTER) - scrolled.set_child(zoomable_image) - zoomable_image.init_gestures() + scrolled.set_child(self.zoomable_image) + self.zoomable_image.init_gestures() self.picture = scrolled return scrolled - def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: - if not media_path or not os.path.exists(media_path): - empty = Gtk.Label(label="No media found.") - empty.set_hexpand(True) - empty.set_vexpand(True) - empty.set_halign(Gtk.Align.CENTER) - empty.set_valign(Gtk.Align.CENTER) - return empty - - try: - self.texture = Gdk.Texture.new_from_filename(media_path) - except GLib.Error: - error = Gtk.Label(label="Failed to load image.") - error.set_hexpand(True) - error.set_vexpand(True) - error.set_halign(Gtk.Align.CENTER) - error.set_valign(Gtk.Align.CENTER) - return error - - scrolled = Gtk.ScrolledWindow() - scrolled.set_hexpand(True) - scrolled.set_vexpand(True) - scrolled.set_halign(Gtk.Align.FILL) - scrolled.set_valign(Gtk.Align.FILL) - - zoomable_image = ImageViewerWidget(media_path, self.app, scrolled) - zoomable_image.set_hexpand(True) - zoomable_image.set_vexpand(True) - zoomable_image.set_halign(Gtk.Align.CENTER) - zoomable_image.set_valign(Gtk.Align.CENTER) - - scrolled.set_child(zoomable_image) - zoomable_image.init_gestures() - - self.picture = scrolled - - return scrolled + ''' + * Editing Bar * + ''' def setup_editing_tools_bar(self): - def on_crop_clicked(_btn): + def on_crop_clicked(btn): if not self.texture or not self.picture: return + # Return image to original size + self.zoomable_image.reset_view_fit() + + # Disable zoom + self.zoomable_image.set_zoom_enabled(not self.zoomable_image.zoom_enabled) + + # Hide the edit bar + self.set_edit_bar_visible(False) + if self.crop_overlay is None: self.crop_overlay = CropOverlay(self.picture, self.texture) - - self.image_overlay.add_overlay(self.crop_overlay) - self.crop_overlay.queue_draw() + self.overlay.add_overlay(self.crop_overlay) + self.show_crop_bar() else: - self.image_overlay.remove_overlay(self.crop_overlay) + self.overlay.remove_overlay(self.crop_overlay) self.crop_overlay = None + self.hide_crop_bar() + self.set_edit_bar_visible(True) - def on_filters_clicked(_btn): + def on_filters_clicked(btn): return - def on_fine_tunes_clicked(_btn): + def on_fine_tunes_clicked(btn): return - def on_drawing_clicked(_btn): + def on_drawing_clicked(btn): return bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) @@ -181,3 +158,72 @@ def _icon_button(icon_name: str, tooltip: str, handler): self.overlay.add_overlay(bar) bar.set_can_target(True) + self.edit_bar = bar + + def set_edit_bar_visible(self, visible: bool): + if getattr(self, "edit_bar", None): + self.edit_bar.set_visible(visible) + self.edit_bar.set_can_target(visible) + + ''' + * Crop Feature * + ''' + def show_crop_bar(self): + if getattr(self, "crop_bar", None): + return + + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_hexpand(True) + bar.set_margin_start(12) + bar.set_margin_end(12) + bar.set_margin_bottom(12) + bar.set_margin_top(6) + + bar.add_css_class("osd") + bar.add_css_class("toolbar") + + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.set_halign(Gtk.Align.FILL) + cancel.connect("clicked", self.on_crop_cancel_clicked) + + crop = Gtk.Button(label="Crop") + crop.set_hexpand(True) + crop.set_halign(Gtk.Align.FILL) + crop.add_css_class("suggested-action") + crop.connect("clicked", self.on_crop_apply_clicked) + + bar.append(cancel) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(crop) + + self.crop_bar = bar + self.overlay.add_overlay(bar) + bar.set_can_target(True) + + def hide_crop_bar(self): + if getattr(self, "crop_bar", None): + self.overlay.remove_overlay(self.crop_bar) + self.crop_bar = None + + def on_crop_cancel_clicked(self, btn): + if self.crop_overlay: + self.overlay.remove_overlay(self.crop_overlay) + self.crop_overlay = None + self.hide_crop_bar() + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + def on_crop_apply_clicked(self, btn): + if not self.crop_overlay: + return + x, y, w, h = self.crop_overlay.get_crop_in_image_pixels() + print("CROP:", x, y, w, h) + + # TODO: perform crop, update texture, replace viewer content, etc. + + self.on_crop_cancel_clicked(btn) + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) \ No newline at end of file From 4f2a2939a54dd572c3c862aac333edb48290695e Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 26 Jan 2026 00:17:46 -0500 Subject: [PATCH 09/62] FuriOSMediaTools: Implement cropping function for pictures only so far. * Use high-quality (95%) JPEG encoding since JPEG is lossy and byte-for-byte preservation is not possible. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 89 +++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 590bfe3..8d22396 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -4,8 +4,11 @@ # Authors: # Joaquin Philco -import os +import gi, os +gi.require_version("GdkPixbuf", "2.0") + +from gi.repository import GdkPixbuf class FuriOSMediaTools: @staticmethod def _basename_without_ext(path: str) -> str: @@ -43,4 +46,86 @@ def change_file_name(src_path: str, new_base_name: str) -> tuple[bool, str]: os.rename(src_path, new_path) return True, new_path except OSError as e: - return False, str(e) \ No newline at end of file + return False, str(e) + + @staticmethod + def crop_image_to_disk( + image_path: str, + x: int, y: int, w: int, h: int, + *, + overwrite: bool = False, + out_path: str | None = None, + suffix: str = "_cropped", + ) -> str: + if not os.path.exists(image_path): + raise FileNotFoundError(image_path) + + pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path) + img_w = pixbuf.get_width() + img_h = pixbuf.get_height() + + # Clamp crop rect + x = max(0, min(int(x), img_w - 1)) + y = max(0, min(int(y), img_h - 1)) + w = max(1, min(int(w), img_w - x)) + h = max(1, min(int(h), img_h - y)) + + cropped = pixbuf.new_subpixbuf(x, y, w, h).copy() + + out_path = FuriOSMediaTools.compute_output_path( + image_path, + overwrite=overwrite, + out_path=out_path, + suffix=suffix, + ) + + ext = os.path.splitext(out_path)[1].lower() + + if ext in (".jpg", ".jpeg"): + fmt = "jpeg" + keys = ["quality"] + values = ["95"] # Lets use 95% quality fidelity for jpeg since we cant guarantee byte to byte cuz its jpeg. + elif ext == ".png": + fmt = "png" + keys, values = [], [] + elif ext in (".tif", ".tiff"): + fmt = "tiff" + keys, values = [], [] + elif ext == ".bmp": + fmt = "bmp" + keys, values = [], [] + else: + # fallback to PNG + fmt = "png" + keys, values = [], [] + if not out_path.lower().endswith(".png"): + out_path = os.path.splitext(out_path)[0] + ".png" + + if overwrite: + tmp = out_path + ".tmp" + cropped.savev(tmp, fmt, keys, values) + os.replace(tmp, out_path) + else: + cropped.savev(out_path, fmt, keys, values) + + return out_path + + @staticmethod + def compute_output_path( + image_path: str, + *, + overwrite: bool = False, + out_path: str | None = None, + suffix: str = "_cropped", + ) -> str: + if out_path: + return out_path + + if overwrite: + return image_path + + base_dir = os.path.dirname(image_path) + name = os.path.basename(image_path) + stem, ext = os.path.splitext(name) + + return os.path.join(base_dir, f"{stem}{suffix}{ext}") From ccf6cb503973906209993dfe25a800303ec1340f Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 26 Jan 2026 00:22:53 -0500 Subject: [PATCH 10/62] edit_view: Add popup that lets the user decide between saving a copy or overwritting the current image. * Use the cropping function from media tools. * TODO: Refresh edit view, media view, grid view (probably usng a signal when changed in database will help me avoid the other multiple signal path." Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 60 +++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 3b8dfdf..ba0e040 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -11,6 +11,7 @@ gi.require_version("GdkPixbuf", "2.0") from .crop_overlay import CropOverlay +from .furios_media_tools import FuriOSMediaTools from .image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene from .ui import (create_edit_view_main_box, create_edit_view_overlay) @@ -219,11 +220,60 @@ def on_crop_cancel_clicked(self, btn): def on_crop_apply_clicked(self, btn): if not self.crop_overlay: return + x, y, w, h = self.crop_overlay.get_crop_in_image_pixels() - print("CROP:", x, y, w, h) - # TODO: perform crop, update texture, replace viewer content, etc. + dialog = Adw.MessageDialog.new( + self.get_root(), + "Save cropped image?", + "Do you want to overwrite the original file or save a new copy?" + ) - self.on_crop_cancel_clicked(btn) - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) \ No newline at end of file + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + def _on_response(dlg, response_id: str): + if response_id == "cancel": + dlg.close() + return + + overwrite = (response_id == "overwrite") + + out_path = FuriOSMediaTools.compute_output_path( + self.media_path, + overwrite=overwrite, + out_path=None, + suffix="_cropped", + ) + + try: + written_path = FuriOSMediaTools.crop_image_to_disk( + self.media_path, + x, y, w, h, + overwrite=overwrite, + out_path=out_path, + suffix="_cropped", + jpeg_quality=95, + ) + print("Cropped written to:", written_path) + + # TODO: Refresh all the viewers with the new cropped image + + except Exception as e: + print("Crop failed:", e) + + self.on_crop_cancel_clicked(btn) + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + dlg.close() + + dialog.connect("response", _on_response) + dialog.present() From eb4261aec31ce6f816d828e669a504b9e3e58d02 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 26 Jan 2026 00:39:16 -0500 Subject: [PATCH 11/62] Reset edit viewer picture once cropped. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index ba0e040..96674ba 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -259,12 +259,16 @@ def _on_response(dlg, response_id: str): x, y, w, h, overwrite=overwrite, out_path=out_path, - suffix="_cropped", - jpeg_quality=95, + suffix="_cropped" ) print("Cropped written to:", written_path) + # Remove old viewer + if self.picture: + self.main_box.remove(self.picture) - # TODO: Refresh all the viewers with the new cropped image + # Create and add new viewer + self.picture = self.setup_picture_to_edit(written_path) + self.main_box.append(self.picture) except Exception as e: print("Crop failed:", e) From f5a403e423b725a12f9c228644f4c47fdc1d7897 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 26 Jan 2026 02:21:46 -0500 Subject: [PATCH 12/62] DrawOverlay: Simple widget with stroke controls using Gsk.Path and Gsk.Stroke. Signed-off-by: Joaquin Philco --- furios_gallery/draw_overlay.py | 172 +++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 furios_gallery/draw_overlay.py diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/draw_overlay.py new file mode 100644 index 0000000..f1ef049 --- /dev/null +++ b/furios_gallery/draw_overlay.py @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gtk, Gdk, Gsk + +class DrawOverlay(Gtk.Widget): + def __init__( + self, + picture_widget: Gtk.Widget, + texture: Gdk.Texture, + *, + clamp_to_image: bool = True, + line_width: float = 4.0, + color_rgba: str = "rgba(0, 140, 255, 0.95)", + min_point_dist: float = 1.5, + ): + super().__init__() + self.picture_widget = picture_widget + self.texture = texture + + self.clamp_to_image = clamp_to_image + self.line_width = float(line_width) + self.min_point_dist2 = float(min_point_dist) ** 2 + + self.color = Gdk.RGBA() + self.color.parse(color_rgba) + + # Each stroke is: {"pts": [(x,y), ...], "width": float, "color": Gdk.RGBA} + self.strokes: list[dict] = [] + self.current_pts: list[tuple[float, float]] | None = None + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_halign(Gtk.Align.FILL) + self.set_valign(Gtk.Align.FILL) + self.set_can_target(True) + + drag = Gtk.GestureDrag.new() + drag.connect("drag-begin", self.on_drag_begin) + drag.connect("drag-update", self.on_drag_update) + drag.connect("drag-end", self.on_drag_end) + self.add_controller(drag) + + ''' + * Public Helpers * + ''' + def set_color(self, rgba: Gdk.RGBA): + self.color = rgba.copy() if hasattr(rgba, "copy") else rgba + self.queue_draw() + + def set_line_width(self, width: float): + self.line_width = float(max(1.0, width)) + self.queue_draw() + + def clear(self): + self.strokes.clear() + self.current_pts = None + self.queue_draw() + + def undo_last_stroke(self): + if self.strokes: + self.strokes.pop() + self.queue_draw() + + def image_rect_in_widget(self) -> tuple[float, float, float, float]: + w = float(self.get_allocated_width()) + h = float(self.get_allocated_height()) + + tw = float(self.texture.get_width()) + th = float(self.texture.get_height()) + + if w <= 0 or h <= 0 or tw <= 0 or th <= 0: + return (0.0, 0.0, w, h) + + scale = min(w / tw, h / th) + iw = tw * scale + ih = th * scale + ix = (w - iw) * 0.5 + iy = (h - ih) * 0.5 + return (ix, iy, iw, ih) + + ''' + * Snapshot Rendering * + ''' + def do_snapshot(self, snapshot: Gtk.Snapshot): + for s in self.strokes: + self.snapshot_stroke(snapshot, s["pts"], s["width"], s["color"]) + + if self.current_pts and len(self.current_pts) >= 2: + self.snapshot_stroke(snapshot, self.current_pts, self.line_width, self.color) + + def snapshot_stroke(self, snapshot: Gtk.Snapshot, + pts: list[tuple[float, float]], + width: float, + color: Gdk.RGBA): + if len(pts) < 2: + return + + path = Gsk.PathBuilder.new() + x0, y0 = pts[0] + path.move_to(x0, y0) + for (x, y) in pts[1:]: + path.line_to(x, y) + + gsk_path = path.to_path() + + stroke = Gsk.Stroke.new(float(width)) + stroke.set_line_cap(Gsk.LineCap.ROUND) + stroke.set_line_join(Gsk.LineJoin.ROUND) + + snapshot.append_stroke(gsk_path, stroke, color) + + ''' + * Events * + ''' + def inside_image(self, x: float, y: float) -> bool: + if not self.clamp_to_image: + return True + ix, iy, iw, ih = self.image_rect_in_widget() + return (ix <= x <= ix + iw) and (iy <= y <= iy + ih) + + def on_drag_begin(self, gesture: Gtk.GestureDrag, start_x: float, start_y: float): + x = float(start_x) + y = float(start_y) + + if not self.inside_image(x, y): + self.current_pts = None + return + + self.current_pts = [(x, y)] + self.queue_draw() + + def on_drag_update(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float): + if not self.current_pts: + return + + x0, y0 = self.current_pts[0] + x = x0 + float(offset_x) + y = y0 + float(offset_y) + + if not self.inside_image(x, y): + return + + lx, ly = self.current_pts[-1] + dx = x - lx + dy = y - ly + if (dx * dx + dy * dy) < self.min_point_dist2: + return + + self.current_pts.append((x, y)) + self.queue_draw() + + def on_drag_end(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float): + if not self.current_pts: + return + + if len(self.current_pts) >= 2: + # capture style NOW so future changes don't affect this stroke + self.strokes.append({ + "pts": self.current_pts, + "width": float(self.line_width), + "color": self.color.copy() if hasattr(self.color, "copy") else self.color, + }) + + self.current_pts = None + self.queue_draw() From 89140d096d86652d75d08b892043184de3937423 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 26 Jan 2026 02:23:50 -0500 Subject: [PATCH 13/62] edit_view: Use DrawOverlay in edit view with its own custom control bar that has stroke color and size options. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 188 +++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 96674ba..afda7b6 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -10,6 +10,7 @@ gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") +from .draw_overlay import DrawOverlay from .crop_overlay import CropOverlay from .furios_media_tools import FuriOSMediaTools from .image_viewer_widget import ImageViewerWidget @@ -121,7 +122,21 @@ def on_fine_tunes_clicked(btn): return def on_drawing_clicked(btn): - return + if not self.texture or not self.picture: + return + + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) + + if getattr(self, "draw_overlay", None): + self.overlay.remove_overlay(self.draw_overlay) + self.draw_overlay = None + + self.draw_overlay = DrawOverlay(self.picture, self.texture, clamp_to_image=True) + self.overlay.add_overlay(self.draw_overlay) + self.draw_overlay.queue_draw() + self.show_drawing_bar() bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) bar.set_hexpand(True) @@ -281,3 +296,174 @@ def _on_response(dlg, response_id: str): dialog.connect("response", _on_response) dialog.present() + + ''' + * Drawing Feature * + ''' + def show_drawing_bar(self): + if getattr(self, "drawing_bar", None): + return + + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_hexpand(True) + bar.set_margin_start(5) + bar.set_margin_end(5) + bar.set_margin_bottom(12) + bar.set_margin_top(6) + + bar.add_css_class("osd") + bar.add_css_class("toolbar") + bar.set_can_target(True) + + # Cancel button + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.set_halign(Gtk.Align.FILL) + cancel.connect("clicked", self.on_drawing_cancel_clicked) + + # Color popover button + color_menu = Gtk.MenuButton() + color_menu.set_tooltip_text("Stroke color") + color_menu.set_valign(Gtk.Align.CENTER) + color_menu.set_has_frame(True) + + color_icon = Gtk.Image.new_from_icon_name("color-select") + color_icon.set_pixel_size(18) + color_menu.set_child(color_icon) + + color_pop = Gtk.Popover() + color_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + color_box.set_margin_top(10) + color_box.set_margin_bottom(10) + color_box.set_margin_start(10) + color_box.set_margin_end(10) + + color_label = Gtk.Label(label="Stroke color") + color_label.set_halign(Gtk.Align.START) + + color_dialog = Gtk.ColorDialog() + color_btn = Gtk.ColorDialogButton.new(color_dialog) + + if getattr(self, "draw_overlay", None): + color_btn.set_rgba(self.draw_overlay.color) + + def _on_color_changed(btn, _pspec=None): + if not getattr(self, "draw_overlay", None): + return + self.draw_overlay.set_color(btn.get_rgba()) + + color_btn.connect("notify::rgba", _on_color_changed) + + color_box.append(color_label) + color_box.append(color_btn) + color_pop.set_child(color_box) + color_menu.set_popover(color_pop) + + # Size popover button + size_btn = Gtk.MenuButton() + size_btn.set_tooltip_text("Stroke size") + size_btn.set_valign(Gtk.Align.CENTER) + size_btn.set_has_frame(True) + + size_icon = Gtk.Image.new_from_icon_name("find-location") + size_icon.set_pixel_size(18) + size_btn.set_child(size_icon) + + size_pop = Gtk.Popover() + size_pop.set_has_arrow(True) + + size_pop.set_size_request(220, -1) + + size_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + size_box.set_margin_top(10) + size_box.set_margin_bottom(10) + size_box.set_margin_start(12) + size_box.set_margin_end(12) + size_box.set_hexpand(True) + + size_label = Gtk.Label(label="Size") + size_label.set_valign(Gtk.Align.CENTER) + size_label.add_css_class("dim-label") + + adj = Gtk.Adjustment(value=4.0, lower=1.0, upper=100.0, step_increment=1.0, page_increment=2.0) + + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_draw_value(True) + scale.set_digits(0) + scale.set_valign(Gtk.Align.CENTER) + scale.set_hexpand(True) + + scale.set_size_request(150, -1) + + if getattr(self, "draw_overlay", None): + adj.set_value(float(self.draw_overlay.line_width)) + + def _on_size_changed(s): + if getattr(self, "draw_overlay", None): + self.draw_overlay.set_line_width(s.get_value()) + + scale.connect("value-changed", _on_size_changed) + + size_box.append(size_label) + size_box.append(scale) + + size_pop.set_child(size_box) + size_btn.set_popover(size_pop) + + # Delete / Undo last stroke button + undo_btn = Gtk.MenuButton() + undo_btn.set_has_frame(True) + undo_btn.set_valign(Gtk.Align.CENTER) + undo_btn.set_tooltip_text("Undo last stroke") + + undo_icon = Gtk.Image.new_from_icon_name("edit-undo-symbolic") + undo_icon.set_pixel_size(18) + undo_btn.set_child(undo_icon) + + gesture = Gtk.GestureClick.new() + gesture.connect("pressed", lambda *_: self.draw_overlay and self.draw_overlay.undo_last_stroke()) + undo_btn.add_controller(gesture) + + # Done Button + done = Gtk.Button(label="Done") + done.set_hexpand(True) + done.set_halign(Gtk.Align.FILL) + done.add_css_class("suggested-action") + done.connect("clicked", self.on_drawing_done_clicked) + + # Layout + bar.append(cancel) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(color_menu) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(size_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(undo_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(done) + + self.drawing_bar = bar + self.overlay.add_overlay(bar) + + def on_drawing_cancel_clicked(self, _btn): + if getattr(self, "draw_overlay", None): + self.overlay.remove_overlay(self.draw_overlay) + self.draw_overlay = None + + if getattr(self, "drawing_bar", None): + self.overlay.remove_overlay(self.drawing_bar) + self.drawing_bar = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + def on_drawing_done_clicked(self, _btn): + # TODO: Actually save the strokes into the image + if getattr(self, "drawing_bar", None): + self.overlay.remove_overlay(self.drawing_bar) + self.drawing_bar = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) From 05206f3713ff289efdf19b67318d8fb8630fe82c Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 27 Jan 2026 15:51:34 -0500 Subject: [PATCH 14/62] draw_overlay: GSK path strokes are in widget coordinates, not on image coordinates. Therefore, implement a simple coordinate frame change helper. Signed-off-by: Joaquin Philco --- furios_gallery/draw_overlay.py | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/draw_overlay.py index f1ef049..55ab11b 100644 --- a/furios_gallery/draw_overlay.py +++ b/furios_gallery/draw_overlay.py @@ -170,3 +170,45 @@ def on_drag_end(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float self.current_pts = None self.queue_draw() + + ''' + * Coordinate Transform Helpers * + ''' + def image_scale_in_widget(self) -> float: + # widget pixels per image pixel (same in x/y for "contain") + _, _, iw, _ = self.image_rect_in_widget() + tw = float(self.texture.get_width()) + return (iw / tw) if tw else 1.0 + + # Since we are using Gsk Path builder and this one uses widget coordinates, we need to transform into image coordiiantes: + def widget_to_image(self, xw: float, yw: float) -> tuple[float, float] | None: + ix, iy, iw, ih = self.image_rect_in_widget() + tw = float(self.texture.get_width()) + th = float(self.texture.get_height()) + + if iw <= 0 or ih <= 0 or tw <= 0 or th <= 0: + return None + + if not (ix <= xw <= ix + iw and iy <= yw <= iy + ih): + return None + + # normalized within displayed image rect + u = (xw - ix) / iw + v = (yw - iy) / ih + + # convert to texture pixels + xi = u * tw + yi = v * th + return (xi, yi) + + def image_to_widget(self, xi: float, yi: float) -> tuple[float, float]: + ix, iy, iw, ih = self.image_rect_in_widget() + tw = float(self.texture.get_width()) + th = float(self.texture.get_height()) + + u = xi / tw if tw else 0.0 + v = yi / th if th else 0.0 + + xw = ix + u * iw + yw = iy + v * ih + return (xw, yw) From b0c258a8075840e480b567b0d7be63730b8d5a0d Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 27 Jan 2026 16:09:02 -0500 Subject: [PATCH 15/62] DrawOverlay: Apply coords transformations when storing strokes on gestures. Signed-off-by: Joaquin Philco --- furios_gallery/draw_overlay.py | 51 ++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/draw_overlay.py index 55ab11b..9474aee 100644 --- a/furios_gallery/draw_overlay.py +++ b/furios_gallery/draw_overlay.py @@ -102,10 +102,13 @@ def snapshot_stroke(self, snapshot: Gtk.Snapshot, if len(pts) < 2: return + # convert IMAGE pts -> WIDGET pts for drawing + wpts = [self.image_to_widget(xi, yi) for (xi, yi) in pts] + path = Gsk.PathBuilder.new() - x0, y0 = pts[0] + x0, y0 = wpts[0] path.move_to(x0, y0) - for (x, y) in pts[1:]: + for (x, y) in wpts[1:]: path.line_to(x, y) gsk_path = path.to_path() @@ -126,34 +129,52 @@ def inside_image(self, x: float, y: float) -> bool: return (ix <= x <= ix + iw) and (iy <= y <= iy + ih) def on_drag_begin(self, gesture: Gtk.GestureDrag, start_x: float, start_y: float): - x = float(start_x) - y = float(start_y) + xw = float(start_x) + yw = float(start_y) + + img_pt = self.widget_to_image(xw, yw) + + if self.clamp_to_image and img_pt is None: + self.current_pts = None + return - if not self.inside_image(x, y): + if img_pt is None: self.current_pts = None return - self.current_pts = [(x, y)] + self.drag_begin_widget = (xw, yw) # widget coords for offsets + self.current_pts = [img_pt] # image coords for storage self.queue_draw() def on_drag_update(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float): if not self.current_pts: return - x0, y0 = self.current_pts[0] - x = x0 + float(offset_x) - y = y0 + float(offset_y) + # GestureDrag gives offsets from drag-begin point in WIDGET space + # We need current widget point first, then convert to image. + # We can reconstruct current widget point from the begin point: + # BUT we stored image begin point, so we must keep the begin widget point too. + if not hasattr(self, "drag_begin_widget"): + return + + xw0, yw0 = self.drag_begin_widget + xw = xw0 + float(offset_x) + yw = yw0 + float(offset_y) - if not self.inside_image(x, y): + img_pt = self.widget_to_image(xw, yw) + if img_pt is None: return lx, ly = self.current_pts[-1] - dx = x - lx - dy = y - ly - if (dx * dx + dy * dy) < self.min_point_dist2: + scale = self.image_scale_in_widget() + min_img_dist2 = (self.min_point_dist2 / (scale * scale)) if scale > 0 else self.min_point_dist2 + + dx = img_pt[0] - lx + dy = img_pt[1] - ly + if (dx*dx + dy*dy) < min_img_dist2: return - self.current_pts.append((x, y)) + self.current_pts.append(img_pt) self.queue_draw() def on_drag_end(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float): @@ -169,6 +190,8 @@ def on_drag_end(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float }) self.current_pts = None + if hasattr(self, "drag_begin_widget"): + delattr(self, "drag_begin_widget") self.queue_draw() ''' From 8921231032b12687daa7e2554d50d730064e3238 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 27 Jan 2026 16:17:45 -0500 Subject: [PATCH 16/62] DrawOverlay: Also scale stroke width based on coordinate frame. Signed-off-by: Joaquin Philco --- furios_gallery/draw_overlay.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/draw_overlay.py index 9474aee..820cf2d 100644 --- a/furios_gallery/draw_overlay.py +++ b/furios_gallery/draw_overlay.py @@ -90,7 +90,9 @@ def image_rect_in_widget(self) -> tuple[float, float, float, float]: ''' def do_snapshot(self, snapshot: Gtk.Snapshot): for s in self.strokes: - self.snapshot_stroke(snapshot, s["pts"], s["width"], s["color"]) + scale = self.image_scale_in_widget() + width_widget = s["width_img"] * scale + self.snapshot_stroke(snapshot, s["pts"], width_widget, s["color"]) if self.current_pts and len(self.current_pts) >= 2: self.snapshot_stroke(snapshot, self.current_pts, self.line_width, self.color) @@ -183,9 +185,12 @@ def on_drag_end(self, gesture: Gtk.GestureDrag, offset_x: float, offset_y: float if len(self.current_pts) >= 2: # capture style NOW so future changes don't affect this stroke + scale = self.image_scale_in_widget() + width_img = (self.line_width / scale) if scale > 0 else self.line_width + self.strokes.append({ - "pts": self.current_pts, - "width": float(self.line_width), + "pts": self.current_pts, # image coords + "width_img": float(width_img), # image-pixel width "color": self.color.copy() if hasattr(self.color, "copy") else self.color, }) From b42fc5a8de3c1654d4ce8d5d6f4d779156cf3950 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 27 Jan 2026 16:30:07 -0500 Subject: [PATCH 17/62] furios_media_toos: Implement function to Rasterize strokes via Cairo and encode with GdkPixbuf for non-PNG formats. For some reason, cairo can only saved directly to PNG, so to maintain image, we convert to pixbuff and save it from there. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 129 ++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 8d22396..3697546 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -4,11 +4,14 @@ # Authors: # Joaquin Philco +import cairo import gi, os +gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") -from gi.repository import GdkPixbuf +from gi.repository import GdkPixbuf, Gdk + class FuriOSMediaTools: @staticmethod def _basename_without_ext(path: str) -> str: @@ -129,3 +132,127 @@ def compute_output_path( stem, ext = os.path.splitext(name) return os.path.join(base_dir, f"{stem}{suffix}{ext}") + + @staticmethod + def rasterize_strokes_to_disk_cairo( + image_path: str, + strokes: list[dict], + *, + overwrite: bool = False, + out_path: str | None = None, + suffix: str = "_drawn", + jpeg_quality: int = 95, + ) -> str: + if not os.path.exists(image_path): + raise FileNotFoundError(image_path) + + base = GdkPixbuf.Pixbuf.new_from_file(image_path) + w, h = base.get_width(), base.get_height() + + # Cairo surface: ARGB32 (premultiplied), memory layout on little-endian is BGRA + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h) + ctx = cairo.Context(surface) + + # Paint original image into surface + Gdk.cairo_set_source_pixbuf(ctx, base, 0, 0) + ctx.paint() + + ctx.set_line_cap(cairo.LINE_CAP_ROUND) + ctx.set_line_join(cairo.LINE_JOIN_ROUND) + + for s in strokes or []: + pts = s.get("pts") or [] + if len(pts) < 2: + continue + + width = float(s.get("width_img", s.get("width", 4.0))) + color = s.get("color") + + r = float(getattr(color, "red", 0.0)) + g = float(getattr(color, "green", 0.0)) + b = float(getattr(color, "blue", 0.0)) + a = float(getattr(color, "alpha", 1.0)) + + ctx.set_source_rgba(r, g, b, a) + ctx.set_line_width(max(0.5, width)) + + ctx.new_path() + x0, y0 = pts[0] + ctx.move_to(float(x0), float(y0)) + for (x, y) in pts[1:]: + ctx.line_to(float(x), float(y)) + ctx.stroke() + + out_path = FuriOSMediaTools.compute_output_path( + image_path, + overwrite=overwrite, + out_path=out_path, + suffix=suffix, + ) + + ext = os.path.splitext(out_path)[1].lower() + + # Convert cairo surface (BGRA premultiplied) -> RGBA straight alpha for Pixbuf + stride = surface.get_stride() + src = surface.get_data() + rgba = bytearray(h * w * 4) + + for y in range(h): + srow = y * stride + drow = y * (w * 4) + for x in range(w): + si = srow + x * 4 + di = drow + x * 4 + + b0 = src[si + 0] + g0 = src[si + 1] + r0 = src[si + 2] + a0 = src[si + 3] + + if a0: + # Un-premultiply for "straight alpha" + r1 = (r0 * 255 + a0 // 2) // a0 + g1 = (g0 * 255 + a0 // 2) // a0 + b1 = (b0 * 255 + a0 // 2) // a0 + else: + r1 = g1 = b1 = 0 + + rgba[di + 0] = r1 + rgba[di + 1] = g1 + rgba[di + 2] = b1 + rgba[di + 3] = a0 + + pix = GdkPixbuf.Pixbuf.new_from_data(bytes(rgba), GdkPixbuf.Colorspace.RGB, True, 8, w, h, w * 4, None, None,).copy() + + if ext in (".jpg", ".jpeg"): + fmt = "jpeg" + keys = ["quality"] + values = [str(int(jpeg_quality))] + elif ext == ".png": + fmt = "png" + keys, values = [], [] + elif ext == ".webp": + fmt = "webp" + keys = ["quality"] + values = [str(int(jpeg_quality))] + elif ext in (".tif", ".tiff"): + fmt = "tiff" + keys, values = [], [] + elif ext == ".bmp": + fmt = "bmp" + keys, values = [], [] + else: + fmt = "png" + keys, values = [], [] + if not out_path.lower().endswith(".png"): + out_path = os.path.splitext(out_path)[0] + ".png" + + if overwrite: + tmp = out_path + ".tmp" + pix.savev(tmp, fmt, keys, values) + os.replace(tmp, out_path) + else: + pix.savev(out_path, fmt, keys, values) + + return out_path + From 5f010292678856e151f7aab8b2c3c26c212c6409 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 27 Jan 2026 16:32:12 -0500 Subject: [PATCH 18/62] edit_view: Implement same popup style as crop mode. TBD: Make this component reutilizable. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 78 ++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index afda7b6..563b92a 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -254,7 +254,7 @@ def on_crop_apply_clicked(self, btn): dialog.set_default_response("copy") dialog.set_close_response("cancel") - def _on_response(dlg, response_id: str): + def on_response(dlg, response_id: str): if response_id == "cancel": dlg.close() return @@ -294,7 +294,7 @@ def _on_response(dlg, response_id: str): dlg.close() - dialog.connect("response", _on_response) + dialog.connect("response", on_response) dialog.present() ''' @@ -459,11 +459,71 @@ def on_drawing_cancel_clicked(self, _btn): self.set_edit_bar_visible(True) self.zoomable_image.set_zoom_enabled(True) - def on_drawing_done_clicked(self, _btn): - # TODO: Actually save the strokes into the image - if getattr(self, "drawing_bar", None): - self.overlay.remove_overlay(self.drawing_bar) - self.drawing_bar = None + def on_drawing_done_clicked(self, btn): + if not getattr(self, "draw_overlay", None): + self.on_drawing_cancel_clicked(btn) + return + + strokes = getattr(self.draw_overlay, "strokes", None) or [] + if not strokes: + self.on_drawing_cancel_clicked(btn) + return + + dialog = Adw.MessageDialog.new( + self.get_root(), + "Save drawing?", + "Do you want to overwrite the original file or save a new copy?" + ) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + def on_response(dlg, response_id: str): + if response_id == "cancel": + dlg.close() + return + + overwrite = (response_id == "overwrite") + + out_path = FuriOSMediaTools.compute_output_path( + self.media_path, + overwrite=overwrite, + out_path=None, + suffix="_drawn", + ) + + try: + written_path = FuriOSMediaTools.rasterize_strokes_to_disk_cairo( + self.media_path, + strokes, + overwrite=overwrite, + out_path=out_path, + suffix="_drawn", + ) + print("Drawing written to:", written_path) + if self.picture: + self.main_box.remove(self.picture) + + self.media_path = written_path + self.picture = self.setup_picture_to_edit(written_path) + self.main_box.append(self.picture) + + except Exception as e: + print("Failed to apply drawing:", e) + + self.on_drawing_cancel_clicked(btn) + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + dlg.close() + + dialog.connect("response", on_response) + dialog.present() - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) From 3080dd0f5543ebd02d3abd25fa3f81a9c62f8980 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 01:26:41 -0500 Subject: [PATCH 19/62] filters_overlay: Implement a simple filters overlay widget that applys a css class to the image. TODO: Save the transformed image. I also noticed that we can create the bar in the class and just return it to the parent context. Signed-off-by: Joaquin Philco --- furios_gallery/filters_overlay.py | 158 ++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 furios_gallery/filters_overlay.py diff --git a/furios_gallery/filters_overlay.py b/furios_gallery/filters_overlay.py new file mode 100644 index 0000000..bf00498 --- /dev/null +++ b/furios_gallery/filters_overlay.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") + +from gi.repository import Gtk, Gdk, GLib + +FILTERS = [ + ("Original", "filter-original"), + ("B&W", "filter-bw"), + ("Vivid", "filter-vivid"), + ("Warm", "filter-warm"), + ("Cool", "filter-cool"), + ("Invert", "filter-invert"), + ("Soft", "filter-soft"), +] + +ALL_FILTER_CLASSES = tuple(css for _label, css in FILTERS) + +CSS = b""" + .filter-thumb picture { + border-radius: 3px; + box-shadow: 0 5px 18px rgba(0,0,0,0.25); + } + .filter-original { filter: none; } + .filter-bw { filter: grayscale(1); } + .filter-vivid { filter: saturate(1.7) contrast(1.15); } + .filter-warm { filter: sepia(0.35) saturate(1.3) brightness(1.05); } + .filter-cool { filter: hue-rotate(190deg) saturate(1.15); } + .filter-invert { filter: invert(1); } + .filter-soft { filter: blur(0.7px) brightness(1.02); } + """ + +class FiltersOverlay(Gtk.Widget): + def __init__( self, picture_widget: Gtk.Widget, media_path: str, thumbnails): + super().__init__() + self.picture_widget = picture_widget + self.media_path = media_path + self.thumbnails = thumbnails + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_halign(Gtk.Align.FILL) + self.set_valign(Gtk.Align.FILL) + + self.ensure_css_loaded() + + self.bar = self.build_bar() + + def ensure_css_loaded(self): + display = self.get_display() + if getattr(display, "_filters_css_loaded", False): + return + prov = Gtk.CssProvider() + prov.load_from_data(CSS) + Gtk.StyleContext.add_provider_for_display( + display, prov, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + display._filters_css_loaded = True + + def set_picture_from_file(self, picture: Gtk.Picture, path: str): + try: + tex = Gdk.Texture.new_from_filename(path) + picture.set_paintable(tex) + except Exception as e: + print("Failed to load filter thumbnail:", e) + return False + + def make_filter_button(self, label: str, css_class: str, thumb_path: str) -> Gtk.Button: + btn = Gtk.Button() + btn.add_css_class("flat") + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + vbox.add_css_class("filter-thumb") + + thumb = Gtk.Picture() + thumb.set_content_fit(Gtk.ContentFit.COVER) + thumb.set_size_request(56, 74) + thumb.add_css_class(css_class) + GLib.idle_add(self.set_picture_from_file, thumb, thumb_path) + + lab = Gtk.Label(label=label) + lab.set_halign(Gtk.Align.CENTER) + + vbox.append(thumb) + vbox.append(lab) + btn.set_child(vbox) + + btn.connect("clicked", self.on_filter_clicked, css_class) + return btn + + def build_bar(self) -> Gtk.Widget: + filters_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + filters_bar.set_hexpand(True) + filters_bar.set_halign(Gtk.Align.FILL) + filters_bar.set_valign(Gtk.Align.END) + filters_bar.set_margin_start(6) + filters_bar.set_margin_end(6) + filters_bar.set_margin_bottom(12) + filters_bar.add_css_class("osd") + filters_bar.add_css_class("toolbar") + + outer = Gtk.ScrolledWindow() + outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + outer.set_overlay_scrolling(True) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + row.set_margin_top(1) + row.set_margin_bottom(1) + outer.set_child(row) + + thumb_path = self.thumbnails.generate_thumbnail(self.media_path) + if thumb_path: + for label, css_class in FILTERS: + row.append(self.make_filter_button(label, css_class, thumb_path)) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + actions.set_hexpand(True) + + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.connect("clicked", self.on_cancel_clicked) + + apply = Gtk.Button(label="Apply") + apply.set_hexpand(True) + apply.add_css_class("suggested-action") + apply.connect("clicked", self.on_apply_clicked) + + actions.append(cancel) + actions.append(apply) + + filters_bar.append(outer) + filters_bar.append(actions) + return filters_bar + + def on_cancel_clicked(self, _btn): + if callable(getattr(self, "on_cancel", None)): + self.on_cancel() + + def on_apply_clicked(self, _btn): + if callable(getattr(self, "on_apply", None)): + self.on_apply(getattr(self, "selected_filter", "filter-original")) + + def get_bar_widget(self) -> Gtk.Widget: + return self.bar + + def on_filter_clicked(self, button: Gtk.Button, css_class: str): + self.selected_filter = css_class + for c in ALL_FILTER_CLASSES: + self.picture_widget.remove_css_class(c) + self.picture_widget.add_css_class(css_class) + + #TODO: Actully save the fitlered imaged to DISK From c35db8bb82a5553ad54c49dead31f997c7a292b0 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 01:32:01 -0500 Subject: [PATCH 20/62] edit_view: Create the filters bar and connect signals. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 88 ++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 563b92a..5070979 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -12,9 +12,10 @@ from .draw_overlay import DrawOverlay from .crop_overlay import CropOverlay +from .filters_overlay import FiltersOverlay from .furios_media_tools import FuriOSMediaTools from .image_viewer_widget import ImageViewerWidget -from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene +from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene, GLib from .ui import (create_edit_view_main_box, create_edit_view_overlay) class EditView(Adw.NavigationPage): @@ -116,7 +117,25 @@ def on_crop_clicked(btn): self.set_edit_bar_visible(True) def on_filters_clicked(btn): - return + if not self.texture or not self.zoomable_image: + return + + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) + + if getattr(self, "filters_overlay", None): + self.overlay.remove_overlay(self.filters_overlay.get_bar_widget()) + self.filters_overlay = None + + target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) + + self.filters_overlay = FiltersOverlay(target_widget, media_path=self.media_path, thumbnails=self.app.thumbnails) + + self.filters_overlay.on_cancel = lambda: self.on_filters_cancel_clicked(btn) + self.filters_overlay.on_apply = lambda selected: self.on_filters_apply_clicked(btn) + + self.overlay.add_overlay(self.filters_overlay.get_bar_widget()) def on_fine_tunes_clicked(btn): return @@ -297,6 +316,71 @@ def on_response(dlg, response_id: str): dialog.connect("response", on_response) dialog.present() + ''' + * Filters Feature * + ''' + def on_filters_cancel_clicked(self, btn=None): + if getattr(self, "filters_overlay", None): + self.overlay.remove_overlay(self.filters_overlay.get_bar_widget()) + self.filters_overlay = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + def on_filters_apply_clicked(self, btn=None): + if not getattr(self, "filters_overlay", None): + self.on_filters_cancel_clicked(btn) + return + + css_class = getattr(self.filters_overlay, "selected_filter", "filter-original") + + dialog = Adw.MessageDialog.new( + self.get_root(), + "Save filtered image?", + "Do you want to overwrite the original file or save a new copy?" + ) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + def on_response(dlg, response_id: str): + if response_id == "cancel": + dlg.close() + return + + overwrite = (response_id == "overwrite") + + out_path = FuriOSMediaTools.compute_output_path( + self.media_path, + overwrite=overwrite, + out_path=None, + suffix="_filtered", + ) + + try: + # TODO: This must bake the filter into pixels. + print("Clicked Apply") + + except Exception as e: + print("Failed to apply filter:", e) + + # Exit filter mode + restore UI + self.on_filters_cancel_clicked(btn) + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + dlg.close() + + dialog.connect("response", on_response) + dialog.present() + ''' * Drawing Feature * ''' From 6f297bc89af6a798dae130974feb8222cbad6c2b Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 02:54:47 -0500 Subject: [PATCH 21/62] furios_media_tools: Implement basic contrast function. RGB vectors are not linear, so we cant just linearly increase each channel, so for now, lets use a naive solution. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 3697546..7c35bdb 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -256,3 +256,14 @@ def rasterize_strokes_to_disk_cairo( return out_path + @staticmethod + def apply_linear_contrast(rbg, contrast, reference_intensity): + r = ((rgb[0] - reference_intensity) * contrast) + reference_intensity + b = ((rgb[1] - reference_intensity) * contrast) + reference_intensity + g = ((rgb[2] - reference_intensity) * contrast) + reference_intensity + + r = max(0, min(255, r)) + g = max(0, min(255, g)) + b = max(0, min(255, b)) + + return (int(r), int(g), int(b)) From f8542cfcddccdb37adefed5d200027f89cc71835 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:09:03 -0500 Subject: [PATCH 22/62] furios_media_tools: Use ITU-R BT.601 standards for RGB <-> YCrCb trasnformations in 8-bit channels. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 73 ++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 7c35bdb..73c3f41 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -6,12 +6,85 @@ import cairo import gi, os +import numpy as np gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") from gi.repository import GdkPixbuf, Gdk +class ColorSpaceStandards: + #All coefficients follow ITU-R BT.601 (full-range, approximate). + + # RGB -> Y (luma) coefficients + # Contribution of each RGB channel to perceived brightness + Y_R = 0.2990 # Red contribution to luma + Y_G = 0.5870 # Green contribution to luma (dominant for human vision) + Y_B = 0.1140 # Blue contribution to luma + + # RGB -> Cb coefficients + # Blue-difference chroma channel + CB_R = -0.168736 # Red contribution to Cb + CB_G = -0.331264 # Green contribution to Cb + CB_B = 0.500000 # Blue contribution to Cb + CB_OFFSET = 128.0 # Center chroma around mid-range for 8-bit storage + + # RGB -> Cr coefficients + # Red-difference chroma channel + CR_R = 0.500000 # Red contribution to Cr + CR_G = -0.418688 # Green contribution to Cr + CR_B = -0.081312 # Blue contribution to Cr + CR_OFFSET = 128.0 # Center chroma around mid-range for 8-bit storage + + # YCbCr -> RGB coefficients + # Inverse transform constants + R_CR = 1.402000 # Cr contribution back to Red + G_CB = 0.344136 # Cb contribution back to Green + G_CR = 0.714136 # Cr contribution back to Green + B_CB = 1.772000 # Cb contribution back to Blue + + @staticmethod + def rgb_to_ycbcr(rgb: np.ndarray): + x = rgb.astype(np.float32, copy=False) + + R = x[..., 0] + G = x[..., 1] + B = x[..., 2] + + Y = ( + ColorSpaceStandards.Y_R * R + + ColorSpaceStandards.Y_G * G + + ColorSpaceStandards.Y_B * B + ) + + Cb = ( + ColorSpaceStandards.CB_R * R + + ColorSpaceStandards.CB_G * G + + ColorSpaceStandards.CB_B * B + + ColorSpaceStandards.CB_OFFSET + ) + + Cr = ( + ColorSpaceStandards.CR_R * R + + ColorSpaceStandards.CR_G * G + + ColorSpaceStandards.CR_B * B + + ColorSpaceStandards.CR_OFFSET + ) + + return Y, Cb, Cr + + @staticmethod + def ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray): + R = Y + ColorSpaceStandards.R_CR * (Cr - ColorSpaceStandards.CR_OFFSET) + G = ( + Y - + ColorSpaceStandards.G_CB * (Cb - ColorSpaceStandards.CB_OFFSET) - + ColorSpaceStandards.G_CR * (Cr - ColorSpaceStandards.CR_OFFSET) + ) + B = Y + ColorSpaceStandards.B_CB * (Cb - ColorSpaceStandards.CB_OFFSET) + + return np.stack([R, G, B], axis=-1) + class FuriOSMediaTools: @staticmethod def _basename_without_ext(path: str) -> str: From 6254f9f7f75cef7175789834db6b53b8d36fa244 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:10:49 -0500 Subject: [PATCH 23/62] furios_media_tools: Add contrast luma transform. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 73c3f41..7abbf08 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -329,6 +329,9 @@ def rasterize_strokes_to_disk_cairo( return out_path + ''' + * Computational Imaging Functions * + ''' @staticmethod def apply_linear_contrast(rbg, contrast, reference_intensity): r = ((rgb[0] - reference_intensity) * contrast) + reference_intensity @@ -340,3 +343,22 @@ def apply_linear_contrast(rbg, contrast, reference_intensity): b = max(0, min(255, b)) return (int(r), int(g), int(b)) + + @staticmethod + def apply_luma_contrast(rgb, contrast, reference_intensity): + if not isinstance(rgb, np.ndarray): + raise TypeError("rgb must be a numpy ndarray") + if rgb.ndim != 3 or rgb.shape[-1] != 3: + raise ValueError(f"rgb must have shape (H, W, 3); got {rgb.shape}") + + # RGB -> YCbCr + Y, Cb, Cr = ImageColorStandards.rgb_to_ycbcr(rgb) + + # Apply contrast ONLY to luma + Y = contrast * (Y - reference_intensity) + reference_intensity + + # YCbCr -> RGB + out = ImageColorStandards.ycbcr_to_rgb(Y, Cb, Cr) + + # Clip + convert for output + return np.clip(out, 0.0, 255.0).astype(np.uint8) From 2a5c4328bdbef6cd8411d7f40e66273d766e82c8 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:16:42 -0500 Subject: [PATCH 24/62] furios_media_tools: Add naive and luma brightness operators. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 7abbf08..09ae033 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -333,16 +333,16 @@ def rasterize_strokes_to_disk_cairo( * Computational Imaging Functions * ''' @staticmethod - def apply_linear_contrast(rbg, contrast, reference_intensity): + def apply_linear_contrast(rgb, contrast, reference_intensity): r = ((rgb[0] - reference_intensity) * contrast) + reference_intensity - b = ((rgb[1] - reference_intensity) * contrast) + reference_intensity - g = ((rgb[2] - reference_intensity) * contrast) + reference_intensity + g = ((rgb[1] - reference_intensity) * contrast) + reference_intensity + b = ((rgb[2] - reference_intensity) * contrast) + reference_intensity r = max(0, min(255, r)) g = max(0, min(255, g)) b = max(0, min(255, b)) - return (int(r), int(g), int(b)) + return (int(round(r)), int(round(g)), int(round(b))) @staticmethod def apply_luma_contrast(rgb, contrast, reference_intensity): @@ -362,3 +362,20 @@ def apply_luma_contrast(rgb, contrast, reference_intensity): # Clip + convert for output return np.clip(out, 0.0, 255.0).astype(np.uint8) + + @staticmethod + def apply_brightness(rgb, brightness): + r = rgb[0] * brightness + g = rgb[1] * brightness + b = rgb[2] * brightness + r = max(0, min(255, r)) + g = max(0, min(255, g)) + b = max(0, min(255, b)) + return (int(round(r)), int(round(g)), int(round(b))) + + @staticmethod + def apply_luma_brightness(rgb_img, brightness): + Y, Cb, Cr = ImageColorStandards.rgb_to_ycbcr(rgb_img) + Y = Y * brightness + out = ImageColorStandards.ycbcr_to_rgb(Y, Cb, Cr) + return np.clip(out, 0.0, 255.0).astype(np.uint8) From 758f1c95d565fb65e3deadc438a613bfbc3496e1 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:28:42 -0500 Subject: [PATCH 25/62] furios_media_tools: Add greyscale operator. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 09ae033..3e23698 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -379,3 +379,13 @@ def apply_luma_brightness(rgb_img, brightness): Y = Y * brightness out = ImageColorStandards.ycbcr_to_rgb(Y, Cb, Cr) return np.clip(out, 0.0, 255.0).astype(np.uint8) + + @staticmethod + def grayscale(rgb: np.ndarray, *, three_channel: bool = True): + Y, _, _ = ImageColorStandards.rgb_to_ycbcr(rgb) + + Y8 = np.clip(Y, 0.0, 255.0).astype(np.uint8) + + if three_channel: + return np.stack([Y8, Y8, Y8], axis=-1) + return Y8 From 6d4981f11dbac44a7f8e49105a6a365edd6b4608 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:43:00 -0500 Subject: [PATCH 26/62] furios_media_tools: Add gaussian blur operator. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 3e23698..9408f2e 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -389,3 +389,58 @@ def grayscale(rgb: np.ndarray, *, three_channel: bool = True): if three_channel: return np.stack([Y8, Y8, Y8], axis=-1) return Y8 + + @staticmethod + def gaussian_kernel1d(sigma: float, radius: int | None = None) -> np.ndarray: + if sigma <= 0: + raise ValueError("sigma must be > 0") + + if radius is None: + radius = int(np.ceil(3.0 * sigma)) + + if radius < 0: + raise ValueError("radius must be >= 0") + + x = np.arange(-radius, radius + 1, dtype=np.float32) + kernel = np.exp(-(x * x) / (2.0 * sigma * sigma)).astype(np.float32) + kernel /= kernel.sum() + + return kernel + + @staticmethod + def convolve1d_axis(img: np.ndarray, kernel: np.ndarray, axis: int): + kernel = np.asarray(kernel, dtype=np.float32) + radius = kernel.size // 2 + + pad_width = [(0, 0)] * img.ndim + pad_width[axis] = (radius, radius) + padded = np.pad(img.astype(np.float32, copy=False), pad_width, mode="edge") + + out = np.zeros_like(img, dtype=np.float32) + + base = [slice(None)] * img.ndim + n = img.shape[axis] + + # Loop only over kernel taps (K), not pixels + for i, k in enumerate(kernel): + base[axis] = slice(i, i + n) + out += k * padded[tuple(base)] + + return out + + @staticmethod + def apply_gaussian_blur(img: np.ndarray, sigma: float) -> np.ndarray: + if not isinstance(img, np.ndarray): + raise TypeError("img must be a numpy ndarray") + + if sigma <= 0: + return img.copy() + + x = img.astype(np.float32, copy=False) + + kernel = FuriOSMediaTools.gaussian_kernel1d(sigma) + + x = convolve1d_axis(x, kernel, axis=1) + x = convolve1d_axis(x, kernel, axis=0) + + return np.clip(x, 0.0, 255.0).astype(np.uint8) From 1850c2ca0bb3fbbed9f0320f6a20491dfff5d17b Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:46:03 -0500 Subject: [PATCH 27/62] furios_media_tools: Add invert opertaor. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 9408f2e..e4f50ad 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -444,3 +444,15 @@ def apply_gaussian_blur(img: np.ndarray, sigma: float) -> np.ndarray: x = convolve1d_axis(x, kernel, axis=0) return np.clip(x, 0.0, 255.0).astype(np.uint8) + + @staticmethod + def apply_invert(img: np.ndarray, amount: float = 1.0): + if not (0.0 <= amount <= 1.0): + raise ValueError("amount must be in range [0, 1]") + + x = img.astype(np.float32, copy=False) + + inverted = 255.0 - x + out = (1.0 - amount) * x + amount * inverted + + return np.clip(out, 0.0, 255.0).astype(np.uint8) From 11bfd0861cdf93650b6fb996591dea7d820692af Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:52:36 -0500 Subject: [PATCH 28/62] furios_media_tools: Add Sepia Transform. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index e4f50ad..8d9fedd 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -14,6 +14,12 @@ from gi.repository import GdkPixbuf, Gdk class ColorSpaceStandards: + SEPIA_MATRIX = np.array([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131], + ], dtype=np.float32) + #All coefficients follow ITU-R BT.601 (full-range, approximate). # RGB -> Y (luma) coefficients @@ -456,3 +462,14 @@ def apply_invert(img: np.ndarray, amount: float = 1.0): out = (1.0 - amount) * x + amount * inverted return np.clip(out, 0.0, 255.0).astype(np.uint8) + + @staticmethod + def apply_sepia(rgb: np.ndarray, amount: float = 1.0) -> np.ndarray: + if not (0.0 <= amount <= 1.0): + raise ValueError("amount must be in [0, 1]") + + x = rgb.astype(np.float32, copy=False) + sepia = x @ FuriOSMediaTools.SEPIA_MATRIX.T + + out = (1.0 - amount) * x + amount * sepia + return np.clip(out, 0.0, 255.0).astype(np.uint8) From fbd1848e634aa92274eb8f74655a85654499e38d Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 03:57:22 -0500 Subject: [PATCH 29/62] furios_media_tools: Add saturation transfrom. Signed-off-by: Joaquin Philco --- furios_gallery/furios_media_tools.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 8d9fedd..e16dcc7 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -473,3 +473,29 @@ def apply_sepia(rgb: np.ndarray, amount: float = 1.0) -> np.ndarray: out = (1.0 - amount) * x + amount * sepia return np.clip(out, 0.0, 255.0).astype(np.uint8) + + @staticmethod + def apply_saturate(rgb: np.ndarray, amount: float) -> np.ndarray: + if not isinstance(rgb, np.ndarray): + raise TypeError("rgb must be a numpy ndarray") + if rgb.ndim != 3 or rgb.shape[-1] != 3: + raise ValueError("rgb must have shape (H, W, 3)") + + x = rgb.astype(np.float32, copy=False) + + s = amount + inv = 1.0 - s + + lr = ColorSpaceStandards.Y_R + lg = ColorSpaceStandards.Y_G + lb = ColorSpaceStandards.Y_B + + # Saturation Matrix + M = np.array([ + [inv * lr + s, inv * lg, inv * lb ], + [inv * lr, inv * lg + s, inv * lb ], + [inv * lr, inv * lg, inv * lb + s ], + ], dtype=np.float32) + + out = x @ M.T + return np.clip(out, 0.0, 255.0).astype(np.uint8) From 1164aa1852e6f147f7f964af0fe747d5bb408930 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 20:52:29 -0500 Subject: [PATCH 30/62] furios_media_tools: Add function to apply image transformations into the actual images. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 5 +- furios_gallery/furios_media_tools.py | 83 +++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 5070979..8d6d1aa 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -5,6 +5,7 @@ # Joaquin Philco import gi, os +import traceback gi.require_version('Adw', '1') gi.require_version('Gtk', '4.0') gi.require_version("Gdk", "4.0") @@ -365,9 +366,7 @@ def on_response(dlg, response_id: str): ) try: - # TODO: This must bake the filter into pixels. - print("Clicked Apply") - + FuriOSMediaTools.bake_filter_to_file(self.media_path, out_path, css_class) except Exception as e: print("Failed to apply filter:", e) diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index e16dcc7..b61fbef 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -7,11 +7,12 @@ import cairo import gi, os import numpy as np +from PIL import Image gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") -from gi.repository import GdkPixbuf, Gdk +from gi.repository import GdkPixbuf, Gdk, GLib class ColorSpaceStandards: SEPIA_MATRIX = np.array([ @@ -335,6 +336,62 @@ def rasterize_strokes_to_disk_cairo( return out_path + @staticmethod + def save_rgb_numpy(rgb: np.ndarray, out_path: str) -> None: + if rgb.dtype != np.uint8 or rgb.ndim != 3 or rgb.shape[2] != 3: + raise ValueError(f"Expected uint8 (H,W,3), got {rgb.dtype} {rgb.shape}") + + ext = os.path.splitext(out_path)[1].lower() + im = Image.fromarray(rgb, mode="RGB") + + if ext in [".jpg", ".jpeg"]: + im.save(out_path, format="JPEG", quality=95) + elif ext == ".png": + im.save(out_path, format="PNG") + elif ext == ".webp": + im.save(out_path, format="WEBP", quality=95, method=6) + else: + im.save(out_path, format="PNG") + + @staticmethod + def apply_filter_to_rgb(rgb: np.ndarray, css_class: str) -> np.ndarray: + css_class = (css_class or "filter-original").strip() + + if css_class == "filter-original": + return rgb.copy() + + if css_class == "filter-bw": + return FuriOSMediaTools.grayscale(rgb) + + if css_class == "filter-invert": + return FuriOSMediaTools.apply_invert(rgb, amount=1.0) + + if css_class == "filter-vivid": + x = FuriOSMediaTools.apply_saturate(rgb, 1.7) + x = FuriOSMediaTools.apply_luma_contrast(x, contrast=1.15, reference_intensity=128.0) + return x + + if css_class == "filter-warm": + x = FuriOSMediaTools.apply_sepia(rgb, amount=0.35) + x = FuriOSMediaTools.apply_saturate(x, 1.3) + x = FuriOSMediaTools.apply_brightness(x, 1.05) + return x + + if css_class == "filter-soft": + x = FuriOSMediaTools.apply_gaussian_blur(rgb, sigma=0.7) + x = FuriOSMediaTools.apply_brightness(x, 1.02) + return x + + return rgb + + @staticmethod + def bake_filter_to_file(in_path: str, out_path: str, css_class: str): + with Image.open(in_path) as im: + rgb = np.array(im.convert("RGB"), dtype=np.uint8) + + out_rgb = FuriOSMediaTools.apply_filter_to_rgb(rgb, css_class) + FuriOSMediaTools.save_rgb_numpy(out_rgb, out_path) + ''' * Computational Imaging Functions * ''' @@ -358,37 +415,33 @@ def apply_luma_contrast(rgb, contrast, reference_intensity): raise ValueError(f"rgb must have shape (H, W, 3); got {rgb.shape}") # RGB -> YCbCr - Y, Cb, Cr = ImageColorStandards.rgb_to_ycbcr(rgb) + Y, Cb, Cr = ColorSpaceStandards.rgb_to_ycbcr(rgb) # Apply contrast ONLY to luma Y = contrast * (Y - reference_intensity) + reference_intensity # YCbCr -> RGB - out = ImageColorStandards.ycbcr_to_rgb(Y, Cb, Cr) + out = ColorSpaceStandards.ycbcr_to_rgb(Y, Cb, Cr) # Clip + convert for output return np.clip(out, 0.0, 255.0).astype(np.uint8) @staticmethod - def apply_brightness(rgb, brightness): - r = rgb[0] * brightness - g = rgb[1] * brightness - b = rgb[2] * brightness - r = max(0, min(255, r)) - g = max(0, min(255, g)) - b = max(0, min(255, b)) - return (int(round(r)), int(round(g)), int(round(b))) + def apply_brightness(rgb: np.ndarray, brightness: float): + x = rgb.astype(np.float32) * float(brightness) + x = np.clip(x, 0, 255) + return x.astype(np.uint8) @staticmethod def apply_luma_brightness(rgb_img, brightness): - Y, Cb, Cr = ImageColorStandards.rgb_to_ycbcr(rgb_img) + Y, Cb, Cr = ColorSpaceStandards.rgb_to_ycbcr(rgb_img) Y = Y * brightness - out = ImageColorStandards.ycbcr_to_rgb(Y, Cb, Cr) + out = ColorSpaceStandards.ycbcr_to_rgb(Y, Cb, Cr) return np.clip(out, 0.0, 255.0).astype(np.uint8) @staticmethod def grayscale(rgb: np.ndarray, *, three_channel: bool = True): - Y, _, _ = ImageColorStandards.rgb_to_ycbcr(rgb) + Y, _, _ = ColorSpaceStandards.rgb_to_ycbcr(rgb) Y8 = np.clip(Y, 0.0, 255.0).astype(np.uint8) @@ -469,7 +522,7 @@ def apply_sepia(rgb: np.ndarray, amount: float = 1.0) -> np.ndarray: raise ValueError("amount must be in [0, 1]") x = rgb.astype(np.float32, copy=False) - sepia = x @ FuriOSMediaTools.SEPIA_MATRIX.T + sepia = x @ ColorSpaceStandards.SEPIA_MATRIX.T out = (1.0 - amount) * x + amount * sepia return np.clip(out, 0.0, 255.0).astype(np.uint8) From b98912c40e8ebfd308aa90d799ffee9ca89b06f4 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 20:56:29 -0500 Subject: [PATCH 31/62] filters_overlay: Remove cool-fitler. Signed-off-by: Joaquin Philco --- furios_gallery/filters_overlay.py | 22 ++++++++++------------ furios_gallery/furios_media_tools.py | 4 ++-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/furios_gallery/filters_overlay.py b/furios_gallery/filters_overlay.py index bf00498..5937196 100644 --- a/furios_gallery/filters_overlay.py +++ b/furios_gallery/filters_overlay.py @@ -12,12 +12,11 @@ FILTERS = [ ("Original", "filter-original"), - ("B&W", "filter-bw"), - ("Vivid", "filter-vivid"), - ("Warm", "filter-warm"), - ("Cool", "filter-cool"), - ("Invert", "filter-invert"), - ("Soft", "filter-soft"), + ("B&W", "filter-bw"), + ("Vivid", "filter-vivid"), + ("Warm", "filter-warm"), + ("Invert", "filter-invert"), + ("Soft", "filter-soft"), ] ALL_FILTER_CLASSES = tuple(css for _label, css in FILTERS) @@ -28,12 +27,11 @@ box-shadow: 0 5px 18px rgba(0,0,0,0.25); } .filter-original { filter: none; } - .filter-bw { filter: grayscale(1); } - .filter-vivid { filter: saturate(1.7) contrast(1.15); } - .filter-warm { filter: sepia(0.35) saturate(1.3) brightness(1.05); } - .filter-cool { filter: hue-rotate(190deg) saturate(1.15); } - .filter-invert { filter: invert(1); } - .filter-soft { filter: blur(0.7px) brightness(1.02); } + .filter-bw { filter: grayscale(1); } + .filter-vivid { filter: saturate(1.7) contrast(1.15); } + .filter-warm { filter: sepia(0.35) saturate(1.3) brightness(1.05); } + .filter-invert { filter: invert(1); } + .filter-soft { filter: blur(0.7px) brightness(1.02); } """ class FiltersOverlay(Gtk.Widget): diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index b61fbef..9416317 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -499,8 +499,8 @@ def apply_gaussian_blur(img: np.ndarray, sigma: float) -> np.ndarray: kernel = FuriOSMediaTools.gaussian_kernel1d(sigma) - x = convolve1d_axis(x, kernel, axis=1) - x = convolve1d_axis(x, kernel, axis=0) + x = FuriOSMediaTools.convolve1d_axis(x, kernel, axis=1) + x = FuriOSMediaTools.convolve1d_axis(x, kernel, axis=0) return np.clip(x, 0.0, 255.0).astype(np.uint8) From 9db11677a420922b7a1fba3c5b024fe93f8b38ed Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 22:24:55 -0500 Subject: [PATCH 32/62] image_transformations_overlay: Implement a widget bar that allows the user to manually tweak contrast, brightness, temperature, blur and saturation. Signed-off-by: Joaquin Philco --- .../image_transformations_overlay.py | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 furios_gallery/image_transformations_overlay.py diff --git a/furios_gallery/image_transformations_overlay.py b/furios_gallery/image_transformations_overlay.py new file mode 100644 index 0000000..b64f0dc --- /dev/null +++ b/furios_gallery/image_transformations_overlay.py @@ -0,0 +1,264 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") + +from gi.repository import Gtk, Gdk, GLib + +class ImageTransformationsOverlay(Gtk.Widget): + def __init__(self, picture_widget: Gtk.Widget, media_path: str): + super().__init__() + self.picture_widget = picture_widget + self.media_path = media_path + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_halign(Gtk.Align.FILL) + self.set_valign(Gtk.Align.FILL) + + self.preview_css_class = "imtr-preview" + self.css_provider = Gtk.CssProvider() + self.css_update_id = 0 + + Gtk.StyleContext.add_provider_for_display( + self.get_display(), + self.css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + self.picture_widget.add_css_class(self.preview_css_class) + self.brightness = 1.00 + self.contrast = 1.00 + self.saturation = 1.00 + self.temperature = 0.00 + self.blur = 0.0 + + self.defaults = (self.brightness, self.contrast, self.saturation, self.temperature, self.blur) + + self.bar = self.build_imtr_bar() + + def get_bar_widget(self) -> Gtk.Widget: + return self.bar + + def make_slider_menu_button(self, icon_name: str, tooltip: str, title: str, value: float, lower: float, upper: float, step: float, digits: int, on_change) -> Gtk.MenuButton: + btn = Gtk.MenuButton() + btn.set_tooltip_text(tooltip) + btn.set_valign(Gtk.Align.CENTER) + btn.set_has_frame(True) + + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_pixel_size(18) + btn.set_child(icon) + + pop = Gtk.Popover() + pop.set_has_arrow(True) + pop.set_size_request(260, -1) + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.set_margin_top(10) + box.set_margin_bottom(10) + box.set_margin_start(12) + box.set_margin_end(12) + box.set_hexpand(True) + + label = Gtk.Label(label=title) + label.set_valign(Gtk.Align.CENTER) + label.add_css_class("dim-label") + + adj = Gtk.Adjustment( + value=float(value), + lower=float(lower), + upper=float(upper), + step_increment=float(step), + page_increment=float(step) * 10.0, + ) + + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_draw_value(True) + scale.set_digits(int(digits)) + scale.set_valign(Gtk.Align.CENTER) + scale.set_hexpand(True) + + adj.connect("value-changed", lambda a: on_change(float(a.get_value()))) + + box.append(label) + box.append(scale) + pop.set_child(box) + btn.set_popover(pop) + + return btn + + def build_imtr_bar(self) -> Gtk.Widget: + bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + bar.set_hexpand(True) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_margin_start(6) + bar.set_margin_end(6) + bar.set_margin_bottom(12) + bar.add_css_class("osd") + bar.add_css_class("toolbar") + + outer = Gtk.ScrolledWindow() + outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + outer.set_overlay_scrolling(True) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.set_halign(Gtk.Align.CENTER) + row.set_margin_top(1) + row.set_margin_bottom(1) + outer.set_child(row) + + # Brigthness, Contrast, Saturation, Temperature, Blur + row.append(self.make_slider_menu_button( + icon_name="weather-clear-symbolic", + tooltip="Brightness", + title="Brightness", + value=self.brightness, + lower=0.0, + upper=2.0, + step=0.01, + digits=2, + on_change=self.on_brightness_changed, + )) + + row.append(self.make_slider_menu_button( + icon_name="semi-starred-symbolic", + tooltip="Contrast", + title="Contrast", + value=self.contrast, + lower=0.0, + upper=2.0, + step=0.01, + digits=2, + on_change=self.on_contrast_changed, + )) + + row.append(self.make_slider_menu_button( + icon_name="color-select-symbolic", + tooltip="Saturation", + title="Saturation", + value=self.saturation, + lower=0.0, + upper=2.0, + step=0.01, + digits=2, + on_change=self.on_saturation_changed, + )) + + row.append(self.make_slider_menu_button( + icon_name="temperature-symbolic", + tooltip="Temperature", + title="Temperature", + value=self.temperature, + lower=-1.0, + upper=1.0, + step=0.01, + digits=2, + on_change=self.on_temperature_changed, + )) + + row.append(self.make_slider_menu_button( + icon_name="edit-select-all-symbolic", + tooltip="Blur", + title="Blur", + value=self.blur, + lower=0.0, + upper=5.0, + step=0.05, + digits=2, + on_change=self.on_blur_changed, + )) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + actions.set_hexpand(True) + + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.connect("clicked", self.on_cancel_clicked) + + apply = Gtk.Button(label="Apply") + apply.set_hexpand(True) + apply.add_css_class("suggested-action") + apply.connect("clicked", self.on_apply_clicked) + + actions.append(cancel) + actions.append(apply) + + bar.append(outer) + bar.append(actions) + return bar + + def on_brightness_changed(self, v: float): + self.brightness = v + self.update_preview() + + def on_contrast_changed(self, v: float): + self.contrast = v + self.update_preview() + + def on_saturation_changed(self, v: float): + self.saturation = v + self.update_preview() + + def on_temperature_changed(self, v: float): + self.temperature = v + self.update_preview() + + def on_blur_changed(self, v: float): + self.blur = v + self.update_preview() + + def build_filter_css(self) -> str: + b = max(0.0, min(2.0, float(self.brightness))) + c = max(0.0, min(2.0, float(self.contrast))) + s = max(0.0, min(2.0, float(self.saturation))) + bl = max(0.0, min(10.0, float(self.blur))) + + t = max(-1.0, min(1.0, float(self.temperature))) + sep = max(0.0, t) * 0.6 + + parts = [ + f"brightness({b:.3f})", + f"contrast({c:.3f})", + f"saturate({s:.3f})", + f"sepia({sep:.3f})", + ] + + if bl > 0.0001: + parts.append(f"blur({bl:.3f}px)") + + return " ".join(parts) + + def update_preview(self): + if getattr(self, "css_update_id", 0): + GLib.source_remove(self.css_update_id) + self.css_update_id = GLib.timeout_add(16, self.apply_preview_css) + + def apply_preview_css(self): + self.css_update_id = 0 + filt = self.build_filter_css() + + css = f""" + .{self.preview_css_class} {{ + filter: {filt}; + }} + """.encode("utf-8") + + self.css_provider.load_from_data(css) + return False + + def on_cancel_clicked(self, _btn=None): + self.brightness, self.contrast, self.saturation, self.temperature, self.blur = self.defaults + self.update_preview() + + def on_apply_clicked(self, _btn=None): + # TODO + pass + From cb3fa3aed3dddec574f62767c12c8c9ad4203459 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Wed, 28 Jan 2026 22:29:18 -0500 Subject: [PATCH 33/62] edit_view: Add the imageTransforamtionsOverlay class to editView. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 8d6d1aa..094a10b 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -14,6 +14,7 @@ from .draw_overlay import DrawOverlay from .crop_overlay import CropOverlay from .filters_overlay import FiltersOverlay +from .image_transformations_overlay import ImageTransformationsOverlay from .furios_media_tools import FuriOSMediaTools from .image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene, GLib @@ -139,7 +140,25 @@ def on_filters_clicked(btn): self.overlay.add_overlay(self.filters_overlay.get_bar_widget()) def on_fine_tunes_clicked(btn): - return + if not self.texture or not self.picture: + return + + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) + + if getattr(self, "image_transformations_overlay", None): + self.overlay.remove_overlay(self.image_transformations_overlay) + self.image_transformations_overlay = None + + target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) + + self.image_transforamtions_overlay = ImageTransformationsOverlay(target_widget, media_path=self.media_path) + + self.image_transforamtions_overlay.on_cancel = lambda: self.on_filters_cancel_clicked(btn) + self.image_transforamtions_overlay.on_apply = lambda selected: self.on_filters_apply_clicked(btn) + + self.overlay.add_overlay(self.image_transforamtions_overlay.get_bar_widget()) def on_drawing_clicked(btn): if not self.texture or not self.picture: From 5b194fc3cd3399fa87ce80ff79d2c4a87e199133 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 01:31:13 -0500 Subject: [PATCH 34/62] custom_filters: Connect buttons to save to disk image transformations in furios_media_tools. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 78 +++++++++++++++++-- furios_gallery/furios_media_tools.py | 29 +++++++ .../image_transformations_overlay.py | 10 ++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 094a10b..1aefc21 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -148,17 +148,20 @@ def on_fine_tunes_clicked(btn): self.set_edit_bar_visible(False) if getattr(self, "image_transformations_overlay", None): - self.overlay.remove_overlay(self.image_transformations_overlay) + self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) self.image_transformations_overlay = None target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) - self.image_transforamtions_overlay = ImageTransformationsOverlay(target_widget, media_path=self.media_path) + self.image_transformations_overlay = ImageTransformationsOverlay( + target_widget, + media_path=self.media_path + ) - self.image_transforamtions_overlay.on_cancel = lambda: self.on_filters_cancel_clicked(btn) - self.image_transforamtions_overlay.on_apply = lambda selected: self.on_filters_apply_clicked(btn) + self.image_transformations_overlay.on_cancel = lambda: self.on_it_cancel_clicked(btn) + self.image_transformations_overlay.on_apply = lambda payload=None: self.on_it_apply_clicked(btn) - self.overlay.add_overlay(self.image_transforamtions_overlay.get_bar_widget()) + self.overlay.add_overlay(self.image_transformations_overlay.get_bar_widget()) def on_drawing_clicked(btn): if not self.texture or not self.picture: @@ -399,6 +402,71 @@ def on_response(dlg, response_id: str): dialog.connect("response", on_response) dialog.present() + ''' + * Image Transformations Feature * + ''' + def on_it_cancel_clicked(self, btn=None): + if getattr(self, "image_transformations_overlay", None): + self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) + self.image_transformations_overlay = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + def on_it_apply_clicked(self, btn=None): + it = getattr(self, "image_transformations_overlay", None) + if not it: + self.on_it_cancel_clicked(btn) + return + + params = getattr(it, "get_params", lambda: {})() + + dialog = Adw.MessageDialog.new( + self.get_root(), + "Save transformed image?", + "Do you want to overwrite the original file or save a new copy?" + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + def on_response(dlg, response_id: str): + if response_id == "cancel": + dlg.close() + return + + overwrite = (response_id == "overwrite") + + out_path = FuriOSMediaTools.compute_output_path( + self.media_path, + overwrite=overwrite, + out_path=None, + suffix="_copy", + ) + + try: + FuriOSMediaTools.apply_custom_filters( + in_path = self.media_path, + out_path = out_path, + brightness = it.brightness, + contrast = it.contrast, + saturation = it.saturation, + sepia = it.temperature, + blur = it.blur + ) + except Exception as e: + print("Failed to apply filter:", e) + + self.on_it_cancel_clicked(btn) + dlg.close() + + dialog.connect("response", on_response) + dialog.present() + ''' * Drawing Feature * ''' diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/furios_media_tools.py index 9416317..3b9570e 100644 --- a/furios_gallery/furios_media_tools.py +++ b/furios_gallery/furios_media_tools.py @@ -384,6 +384,35 @@ def apply_filter_to_rgb(rgb: np.ndarray, css_class: str) -> np.ndarray: return rgb + @staticmethod + def apply_custom_filters(in_path: str, out_path: str, brightness: float | None, contrast: float | None, saturation: float | None, sepia: float | None, blur: float | None): + with Image.open(in_path) as im: + rgb = np.array(im.convert("RGB"), dtype=np.uint8) + + out_rgb = rgb + + if not brightness == 1.0: + out_rgb = FuriOSMediaTools.apply_brightness(out_rgb, brightness) + print(f"applied brigthness: {brightness}") + + if not contrast == 1.0: + out_rgb = FuriOSMediaTools.apply_luma_contrast(out_rgb, contrast, 128) + print(f"applied contrast: {contrast}") + + if not saturation == 1.0: + out_rgb = FuriOSMediaTools.apply_saturate(out_rgb, saturation) + print(f"applied saturation: {saturation}") + + if not sepia == 0.0: + out_rgb = FuriOSMediaTools.apply_sepia(out_rgb, sepia) + print(f"applied temperature: {sepia}") + + if not blur == 0.0: + print(f"applied blur: {blur}") + out_rgb = FuriOSMediaTools.apply_gaussian_blur(out_rgb, blur) + + FuriOSMediaTools.save_rgb_numpy(out_rgb, out_path) + @staticmethod def bake_filter_to_file(in_path: str, out_path: str, css_class: str): with Image.open(in_path) as im: diff --git a/furios_gallery/image_transformations_overlay.py b/furios_gallery/image_transformations_overlay.py index b64f0dc..06a55f3 100644 --- a/furios_gallery/image_transformations_overlay.py +++ b/furios_gallery/image_transformations_overlay.py @@ -254,11 +254,13 @@ def apply_preview_css(self): self.css_provider.load_from_data(css) return False - def on_cancel_clicked(self, _btn=None): + def on_cancel_clicked(self, btn=None): self.brightness, self.contrast, self.saturation, self.temperature, self.blur = self.defaults self.update_preview() - def on_apply_clicked(self, _btn=None): - # TODO - pass + if callable(getattr(self, "on_cancel", None)): + self.on_cancel() + def on_apply_clicked(self, btn=None): + if callable(getattr(self, "on_apply", None)): + self.on_apply(getattr(self, "selected_filter", "filter-original")) \ No newline at end of file From 595c4b4485d166a449a97ab579075b697db07547 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 03:00:10 -0500 Subject: [PATCH 35/62] edit_view: Make apply btn reusable and use it all four menu options. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 308 +++++++++++++----------------------- 1 file changed, 110 insertions(+), 198 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 1aefc21..14d9c8a 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -93,6 +93,55 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: ''' * Editing Bar * ''' + + def on_apply_btn_clicked(self, btn, title: str, body: str, operation, reload_after: bool): + dialog = Adw.MessageDialog.new(self.get_root(), title, body) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + def on_response(dlg, response_id: str): + if response_id == "cancel": + dlg.close() + return + + overwrite = (response_id == "overwrite") + + out_path = FuriOSMediaTools.compute_output_path( + self.media_path, + overwrite=overwrite, + out_path=None, + suffix="_copy", + ) + + try: + maybe_written = operation(self.media_path, out_path, overwrite) + + if reload_after: + new_path = maybe_written or out_path + + if self.picture: + self.main_box.remove(self.picture) + + self.media_path = new_path + self.picture = self.setup_picture_to_edit(new_path) + self.main_box.append(self.picture) + + except Exception as e: + print("Failed to apply drawing:", e) + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + dlg.close() + + dialog.connect("response", on_response) + dialog.present() def setup_editing_tools_bar(self): def on_crop_clicked(btn): @@ -274,70 +323,29 @@ def on_crop_cancel_clicked(self, btn): self.set_edit_bar_visible(True) self.zoomable_image.set_zoom_enabled(True) - def on_crop_apply_clicked(self, btn): - if not self.crop_overlay: + def on_crop_apply_clicked(self, btn=None): + if not getattr(self, "crop_overlay", None): return x, y, w, h = self.crop_overlay.get_crop_in_image_pixels() - dialog = Adw.MessageDialog.new( - self.get_root(), - "Save cropped image?", - "Do you want to overwrite the original file or save a new copy?" - ) - - dialog.add_response("cancel", "Cancel") - dialog.add_response("copy", "Save Copy") - dialog.add_response("overwrite", "Overwrite") - - dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) - - dialog.set_default_response("copy") - dialog.set_close_response("cancel") - - def on_response(dlg, response_id: str): - if response_id == "cancel": - dlg.close() - return - - overwrite = (response_id == "overwrite") - - out_path = FuriOSMediaTools.compute_output_path( - self.media_path, + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.crop_image_to_disk( + in_path, x, y, w, h, overwrite=overwrite, - out_path=None, + out_path=out_path, suffix="_cropped", ) - try: - written_path = FuriOSMediaTools.crop_image_to_disk( - self.media_path, - x, y, w, h, - overwrite=overwrite, - out_path=out_path, - suffix="_cropped" - ) - print("Cropped written to:", written_path) - # Remove old viewer - if self.picture: - self.main_box.remove(self.picture) - - # Create and add new viewer - self.picture = self.setup_picture_to_edit(written_path) - self.main_box.append(self.picture) - - except Exception as e: - print("Crop failed:", e) - - self.on_crop_cancel_clicked(btn) - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) + self.on_apply_btn_clicked( + btn=btn, + title="Save cropped image?", + body="Do you want to overwrite the original file or save a new copy?", + operation=op, + reload_after=True, + ) - dlg.close() - - dialog.connect("response", on_response) - dialog.present() + self.on_crop_cancel_clicked(btn) ''' * Filters Feature * @@ -351,56 +359,25 @@ def on_filters_cancel_clicked(self, btn=None): self.zoomable_image.set_zoom_enabled(True) def on_filters_apply_clicked(self, btn=None): - if not getattr(self, "filters_overlay", None): + overlay = getattr(self, "filters_overlay", None) + if not overlay: self.on_filters_cancel_clicked(btn) return - css_class = getattr(self.filters_overlay, "selected_filter", "filter-original") - - dialog = Adw.MessageDialog.new( - self.get_root(), - "Save filtered image?", - "Do you want to overwrite the original file or save a new copy?" - ) - - dialog.add_response("cancel", "Cancel") - dialog.add_response("copy", "Save Copy") - dialog.add_response("overwrite", "Overwrite") - - dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) - - dialog.set_default_response("copy") - dialog.set_close_response("cancel") - - def on_response(dlg, response_id: str): - if response_id == "cancel": - dlg.close() - return - - overwrite = (response_id == "overwrite") - - out_path = FuriOSMediaTools.compute_output_path( - self.media_path, - overwrite=overwrite, - out_path=None, - suffix="_filtered", - ) - - try: - FuriOSMediaTools.bake_filter_to_file(self.media_path, out_path, css_class) - except Exception as e: - print("Failed to apply filter:", e) + css_class = getattr(overlay, "selected_filter", "filter-original") - # Exit filter mode + restore UI - self.on_filters_cancel_clicked(btn) - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.bake_filter_to_file(in_path, out_path, css_class) - dlg.close() + self.on_apply_btn_clicked( + btn=btn, + title="Save filtered image?", + body="Do you want to overwrite the original file or save a new copy?", + operation=op, + reload_after=True, + ) - dialog.connect("response", on_response) - dialog.present() + self.on_filters_cancel_clicked(btn) ''' * Image Transformations Feature * @@ -419,53 +396,26 @@ def on_it_apply_clicked(self, btn=None): self.on_it_cancel_clicked(btn) return - params = getattr(it, "get_params", lambda: {})() - - dialog = Adw.MessageDialog.new( - self.get_root(), - "Save transformed image?", - "Do you want to overwrite the original file or save a new copy?" - ) - dialog.add_response("cancel", "Cancel") - dialog.add_response("copy", "Save Copy") - dialog.add_response("overwrite", "Overwrite") - dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("copy") - dialog.set_close_response("cancel") - - def on_response(dlg, response_id: str): - if response_id == "cancel": - dlg.close() - return - - overwrite = (response_id == "overwrite") - - out_path = FuriOSMediaTools.compute_output_path( - self.media_path, - overwrite=overwrite, - out_path=None, - suffix="_copy", + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.apply_custom_filters( + in_path=in_path, + out_path=out_path, + brightness=it.brightness, + contrast=it.contrast, + saturation=it.saturation, + sepia=it.temperature, + blur=it.blur, ) - try: - FuriOSMediaTools.apply_custom_filters( - in_path = self.media_path, - out_path = out_path, - brightness = it.brightness, - contrast = it.contrast, - saturation = it.saturation, - sepia = it.temperature, - blur = it.blur - ) - except Exception as e: - print("Failed to apply filter:", e) + self.on_apply_btn_clicked( + btn=btn, + title="Save transformed image?", + body="Do you want to overwrite the original file or save a new copy?", + operation=op, + reload_after=True, + ) - self.on_it_cancel_clicked(btn) - dlg.close() - - dialog.connect("response", on_response) - dialog.present() + self.on_it_cancel_clicked(btn) ''' * Drawing Feature * @@ -629,71 +579,33 @@ def on_drawing_cancel_clicked(self, _btn): self.set_edit_bar_visible(True) self.zoomable_image.set_zoom_enabled(True) - def on_drawing_done_clicked(self, btn): - if not getattr(self, "draw_overlay", None): + def on_drawing_done_clicked(self, btn=None): + draw = getattr(self, "draw_overlay", None) + if not draw: self.on_drawing_cancel_clicked(btn) return - strokes = getattr(self.draw_overlay, "strokes", None) or [] + strokes = getattr(draw, "strokes", None) or [] if not strokes: self.on_drawing_cancel_clicked(btn) return - dialog = Adw.MessageDialog.new( - self.get_root(), - "Save drawing?", - "Do you want to overwrite the original file or save a new copy?" - ) - - dialog.add_response("cancel", "Cancel") - dialog.add_response("copy", "Save Copy") - dialog.add_response("overwrite", "Overwrite") - - dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) - - dialog.set_default_response("copy") - dialog.set_close_response("cancel") - - def on_response(dlg, response_id: str): - if response_id == "cancel": - dlg.close() - return - - overwrite = (response_id == "overwrite") - - out_path = FuriOSMediaTools.compute_output_path( - self.media_path, + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.rasterize_strokes_to_disk_cairo( + in_path, + strokes, overwrite=overwrite, - out_path=None, + out_path=out_path, suffix="_drawn", ) - try: - written_path = FuriOSMediaTools.rasterize_strokes_to_disk_cairo( - self.media_path, - strokes, - overwrite=overwrite, - out_path=out_path, - suffix="_drawn", - ) - print("Drawing written to:", written_path) - if self.picture: - self.main_box.remove(self.picture) - - self.media_path = written_path - self.picture = self.setup_picture_to_edit(written_path) - self.main_box.append(self.picture) - - except Exception as e: - print("Failed to apply drawing:", e) - - self.on_drawing_cancel_clicked(btn) - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) - - dlg.close() + self.on_apply_btn_clicked( + btn=btn, + title="Save drawing?", + body="Do you want to overwrite the original file or save a new copy?", + operation=op, + reload_after=True, + ) - dialog.connect("response", on_response) - dialog.present() + self.on_drawing_cancel_clicked(btn) From 73c6a70edca4823391c19d416a59c3d925740e8d Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 03:52:51 -0500 Subject: [PATCH 36/62] crop: Move bar creation to CropOverlay. Signed-off-by: Joaquin Philco --- furios_gallery/crop_overlay.py | 47 +++++++++++++++++++ furios_gallery/edit_view.py | 84 ++++++++++------------------------ 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/furios_gallery/crop_overlay.py b/furios_gallery/crop_overlay.py index 6a5d9f8..90df194 100644 --- a/furios_gallery/crop_overlay.py +++ b/furios_gallery/crop_overlay.py @@ -40,6 +40,53 @@ def __init__(self, picture_widget: Gtk.Widget, texture: Gdk.Texture): drag.connect("drag-end", self.on_drag_end) self.add_controller(drag) + self.bar = self.build_crop_bar() + self.bar.set_can_target(True) + + def get_bar_widget(self) -> Gtk.Widget: + return self.bar + + def build_crop_bar(self): + if getattr(self, "crop_bar", None): + return + + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_hexpand(True) + bar.set_margin_start(12) + bar.set_margin_end(12) + bar.set_margin_bottom(12) + bar.set_margin_top(6) + + bar.add_css_class("osd") + bar.add_css_class("toolbar") + + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.set_halign(Gtk.Align.FILL) + cancel.connect("clicked", self.on_cancel_clicked) + + crop = Gtk.Button(label="Crop") + crop.set_hexpand(True) + crop.set_halign(Gtk.Align.FILL) + crop.add_css_class("suggested-action") + crop.connect("clicked", self.on_apply_clicked) + + bar.append(cancel) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(crop) + + return bar + + def on_cancel_clicked(self, btn=None): + if callable(getattr(self, "on_cancel", None)): + self.on_cancel() + + def on_apply_clicked(self, btn=None): + if callable(getattr(self, "on_apply", None)): + self.on_apply(getattr(self, "selected_filter", "filter-original")) + def image_rect_in_widget(self) -> tuple[float, float, float, float]: w = float(self.get_allocated_width()) h = float(self.get_allocated_height()) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 14d9c8a..4b9b090 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -93,7 +93,6 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: ''' * Editing Bar * ''' - def on_apply_btn_clicked(self, btn, title: str, body: str, operation, reload_after: bool): dialog = Adw.MessageDialog.new(self.get_root(), title, body) @@ -144,29 +143,6 @@ def on_response(dlg, response_id: str): dialog.present() def setup_editing_tools_bar(self): - def on_crop_clicked(btn): - if not self.texture or not self.picture: - return - - # Return image to original size - self.zoomable_image.reset_view_fit() - - # Disable zoom - self.zoomable_image.set_zoom_enabled(not self.zoomable_image.zoom_enabled) - - # Hide the edit bar - self.set_edit_bar_visible(False) - - if self.crop_overlay is None: - self.crop_overlay = CropOverlay(self.picture, self.texture) - self.overlay.add_overlay(self.crop_overlay) - self.show_crop_bar() - else: - self.overlay.remove_overlay(self.crop_overlay) - self.crop_overlay = None - self.hide_crop_bar() - self.set_edit_bar_visible(True) - def on_filters_clicked(btn): if not self.texture or not self.zoomable_image: return @@ -253,7 +229,7 @@ def _icon_button(icon_name: str, tooltip: str, handler): btn.connect("clicked", handler) return btn - crop_btn = _icon_button("zoom-fit-best", "Crop", on_crop_clicked) + crop_btn = _icon_button("zoom-fit-best", "Crop", self.on_crop_clicked) filters_btn = _icon_button("color-select", "Filters", on_filters_clicked) fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", on_fine_tunes_clicked) drawing_btn = _icon_button("document-edit", "Drawing", on_drawing_clicked) @@ -275,51 +251,37 @@ def set_edit_bar_visible(self, visible: bool): ''' * Crop Feature * ''' - def show_crop_bar(self): - if getattr(self, "crop_bar", None): - return + def on_crop_clicked(self, btn): + if not self.texture or not self.picture: + return - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_hexpand(True) - bar.set_margin_start(12) - bar.set_margin_end(12) - bar.set_margin_bottom(12) - bar.set_margin_top(6) + # Return image to original size + self.zoomable_image.reset_view_fit() - bar.add_css_class("osd") - bar.add_css_class("toolbar") + # Disable zoom + self.zoomable_image.set_zoom_enabled(not self.zoomable_image.zoom_enabled) - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.set_halign(Gtk.Align.FILL) - cancel.connect("clicked", self.on_crop_cancel_clicked) + # Hide the edit bar + self.set_edit_bar_visible(False) - crop = Gtk.Button(label="Crop") - crop.set_hexpand(True) - crop.set_halign(Gtk.Align.FILL) - crop.add_css_class("suggested-action") - crop.connect("clicked", self.on_crop_apply_clicked) + self.crop_overlay = CropOverlay(self.picture, self.texture) + self.overlay.add_overlay(self.crop_overlay) - bar.append(cancel) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(crop) + self.crop_overlay.on_cancel = lambda: self.on_crop_cancel_clicked(btn) + self.crop_overlay.on_apply = lambda selected: self.on_crop_apply_clicked(btn) - self.crop_bar = bar - self.overlay.add_overlay(bar) - bar.set_can_target(True) + self.overlay.add_overlay(self.crop_overlay.get_bar_widget()) - def hide_crop_bar(self): - if getattr(self, "crop_bar", None): - self.overlay.remove_overlay(self.crop_bar) - self.crop_bar = None + def on_crop_cancel_clicked(self, btn=None): + crop = getattr(self, "crop_overlay", None) + if crop: + self.overlay.remove_overlay(crop) + bar = crop.get_bar_widget() + if bar: + self.overlay.remove_overlay(bar) - def on_crop_cancel_clicked(self, btn): - if self.crop_overlay: - self.overlay.remove_overlay(self.crop_overlay) self.crop_overlay = None - self.hide_crop_bar() + self.set_edit_bar_visible(True) self.zoomable_image.set_zoom_enabled(True) From 511710e463a245778c55d2bcd1ffd90e05ce49ed Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 03:56:42 -0500 Subject: [PATCH 37/62] filters: Move filters to FiltersOverlay. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 4b9b090..4cf3aaf 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -143,27 +143,6 @@ def on_response(dlg, response_id: str): dialog.present() def setup_editing_tools_bar(self): - def on_filters_clicked(btn): - if not self.texture or not self.zoomable_image: - return - - self.zoomable_image.reset_view_fit() - self.zoomable_image.set_zoom_enabled(False) - self.set_edit_bar_visible(False) - - if getattr(self, "filters_overlay", None): - self.overlay.remove_overlay(self.filters_overlay.get_bar_widget()) - self.filters_overlay = None - - target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) - - self.filters_overlay = FiltersOverlay(target_widget, media_path=self.media_path, thumbnails=self.app.thumbnails) - - self.filters_overlay.on_cancel = lambda: self.on_filters_cancel_clicked(btn) - self.filters_overlay.on_apply = lambda selected: self.on_filters_apply_clicked(btn) - - self.overlay.add_overlay(self.filters_overlay.get_bar_widget()) - def on_fine_tunes_clicked(btn): if not self.texture or not self.picture: return @@ -230,7 +209,7 @@ def _icon_button(icon_name: str, tooltip: str, handler): return btn crop_btn = _icon_button("zoom-fit-best", "Crop", self.on_crop_clicked) - filters_btn = _icon_button("color-select", "Filters", on_filters_clicked) + filters_btn = _icon_button("color-select", "Filters", self.on_filters_clicked) fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", on_fine_tunes_clicked) drawing_btn = _icon_button("document-edit", "Drawing", on_drawing_clicked) @@ -312,6 +291,27 @@ def op(in_path: str, out_path: str, overwrite: bool): ''' * Filters Feature * ''' + def on_filters_clicked(self, btn): + if not self.texture or not self.zoomable_image: + return + + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) + + if getattr(self, "filters_overlay", None): + self.overlay.remove_overlay(self.filters_overlay.get_bar_widget()) + self.filters_overlay = None + + target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) + + self.filters_overlay = FiltersOverlay(target_widget, media_path=self.media_path, thumbnails=self.app.thumbnails) + + self.filters_overlay.on_cancel = lambda: self.on_filters_cancel_clicked(btn) + self.filters_overlay.on_apply = lambda selected: self.on_filters_apply_clicked(btn) + + self.overlay.add_overlay(self.filters_overlay.get_bar_widget()) + def on_filters_cancel_clicked(self, btn=None): if getattr(self, "filters_overlay", None): self.overlay.remove_overlay(self.filters_overlay.get_bar_widget()) From da9f942a8037a572d845cc36f1a2a7a818924b7a Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 03:59:11 -0500 Subject: [PATCH 38/62] image transformations: Move IT to ImageTransformationsOverlay. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view.py | 50 ++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 4cf3aaf..466c643 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -143,30 +143,6 @@ def on_response(dlg, response_id: str): dialog.present() def setup_editing_tools_bar(self): - def on_fine_tunes_clicked(btn): - if not self.texture or not self.picture: - return - - self.zoomable_image.reset_view_fit() - self.zoomable_image.set_zoom_enabled(False) - self.set_edit_bar_visible(False) - - if getattr(self, "image_transformations_overlay", None): - self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) - self.image_transformations_overlay = None - - target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) - - self.image_transformations_overlay = ImageTransformationsOverlay( - target_widget, - media_path=self.media_path - ) - - self.image_transformations_overlay.on_cancel = lambda: self.on_it_cancel_clicked(btn) - self.image_transformations_overlay.on_apply = lambda payload=None: self.on_it_apply_clicked(btn) - - self.overlay.add_overlay(self.image_transformations_overlay.get_bar_widget()) - def on_drawing_clicked(btn): if not self.texture or not self.picture: return @@ -210,7 +186,7 @@ def _icon_button(icon_name: str, tooltip: str, handler): crop_btn = _icon_button("zoom-fit-best", "Crop", self.on_crop_clicked) filters_btn = _icon_button("color-select", "Filters", self.on_filters_clicked) - fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", on_fine_tunes_clicked) + fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", self.on_fine_tunes_clicked) drawing_btn = _icon_button("document-edit", "Drawing", on_drawing_clicked) for b in (crop_btn, filters_btn, fine_tunes_btn, drawing_btn): @@ -344,6 +320,30 @@ def op(in_path: str, out_path: str, overwrite: bool): ''' * Image Transformations Feature * ''' + def on_fine_tunes_clicked(self, btn): + if not self.texture or not self.picture: + return + + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) + + if getattr(self, "image_transformations_overlay", None): + self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) + self.image_transformations_overlay = None + + target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) + + self.image_transformations_overlay = ImageTransformationsOverlay( + target_widget, + media_path=self.media_path + ) + + self.image_transformations_overlay.on_cancel = lambda: self.on_it_cancel_clicked(btn) + self.image_transformations_overlay.on_apply = lambda payload=None: self.on_it_apply_clicked(btn) + + self.overlay.add_overlay(self.image_transformations_overlay.get_bar_widget()) + def on_it_cancel_clicked(self, btn=None): if getattr(self, "image_transformations_overlay", None): self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) From e944206ff8c561df31a6c9419fb601960598ec93 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 04:32:40 -0500 Subject: [PATCH 39/62] drawing: Move Drawing bar creation to DrawingOverlay. Signed-off-by: Joaquin Philco --- furios_gallery/draw_overlay.py | 153 +++++++++++++++++++++++++++ furios_gallery/edit_view.py | 186 ++++----------------------------- 2 files changed, 175 insertions(+), 164 deletions(-) diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/draw_overlay.py index 820cf2d..d0004cc 100644 --- a/furios_gallery/draw_overlay.py +++ b/furios_gallery/draw_overlay.py @@ -47,6 +47,159 @@ def __init__( drag.connect("drag-end", self.on_drag_end) self.add_controller(drag) + self.bar = self.build_drawing_bar() + + def get_bar_widget(self) -> Gtk.Widget: + return self.bar + + def build_drawing_bar(self): + if getattr(self, "draw_bar", None): + return + + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_hexpand(True) + bar.set_margin_start(5) + bar.set_margin_end(5) + bar.set_margin_bottom(12) + bar.set_margin_top(6) + + bar.add_css_class("osd") + bar.add_css_class("toolbar") + bar.set_can_target(True) + + # Cancel button + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.set_halign(Gtk.Align.FILL) + cancel.connect("clicked", self.on_cancel_clicked) + + # Color popover button + color_menu = Gtk.MenuButton() + color_menu.set_tooltip_text("Stroke color") + color_menu.set_valign(Gtk.Align.CENTER) + color_menu.set_has_frame(True) + + color_icon = Gtk.Image.new_from_icon_name("color-select") + color_icon.set_pixel_size(18) + color_menu.set_child(color_icon) + + color_pop = Gtk.Popover() + color_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + color_box.set_margin_top(10) + color_box.set_margin_bottom(10) + color_box.set_margin_start(10) + color_box.set_margin_end(10) + + color_label = Gtk.Label(label="Stroke color") + color_label.set_halign(Gtk.Align.START) + + color_dialog = Gtk.ColorDialog() + color_btn = Gtk.ColorDialogButton.new(color_dialog) + color_btn.set_rgba(self.color) + + def _on_color_changed(btn, _pspec=None): + self.set_color(btn.get_rgba()) + + color_btn.connect("notify::rgba", _on_color_changed) + + color_box.append(color_label) + color_box.append(color_btn) + color_pop.set_child(color_box) + color_menu.set_popover(color_pop) + + # Size popover button + size_btn = Gtk.MenuButton() + size_btn.set_tooltip_text("Stroke size") + size_btn.set_valign(Gtk.Align.CENTER) + size_btn.set_has_frame(True) + + size_icon = Gtk.Image.new_from_icon_name("find-location") + size_icon.set_pixel_size(18) + size_btn.set_child(size_icon) + + size_pop = Gtk.Popover() + size_pop.set_has_arrow(True) + + size_pop.set_size_request(220, -1) + + size_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + size_box.set_margin_top(10) + size_box.set_margin_bottom(10) + size_box.set_margin_start(12) + size_box.set_margin_end(12) + size_box.set_hexpand(True) + + size_label = Gtk.Label(label="Size") + size_label.set_valign(Gtk.Align.CENTER) + size_label.add_css_class("dim-label") + + adj = Gtk.Adjustment(value=4.0, lower=1.0, upper=100.0, step_increment=1.0, page_increment=2.0) + + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_draw_value(True) + scale.set_digits(0) + scale.set_valign(Gtk.Align.CENTER) + scale.set_hexpand(True) + + scale.set_size_request(150, -1) + + adj.set_value(float(self.line_width)) + + def _on_size_changed(s): + self.set_line_width(s.get_value()) + + scale.connect("value-changed", _on_size_changed) + + size_box.append(size_label) + size_box.append(scale) + + size_pop.set_child(size_box) + size_btn.set_popover(size_pop) + + # Delete / Undo last stroke button + undo_btn = Gtk.MenuButton() + undo_btn.set_has_frame(True) + undo_btn.set_valign(Gtk.Align.CENTER) + undo_btn.set_tooltip_text("Undo last stroke") + + undo_icon = Gtk.Image.new_from_icon_name("edit-undo-symbolic") + undo_icon.set_pixel_size(18) + undo_btn.set_child(undo_icon) + + gesture = Gtk.GestureClick.new() + gesture.connect("pressed", lambda *_: self and self.undo_last_stroke()) + undo_btn.add_controller(gesture) + + # Done Button + done = Gtk.Button(label="Done") + done.set_hexpand(True) + done.set_halign(Gtk.Align.FILL) + done.add_css_class("suggested-action") + done.connect("clicked", self.on_apply_clicked) + + # Layout + bar.append(cancel) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(color_menu) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(size_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(undo_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(done) + + return bar + + def on_cancel_clicked(self, btn=None): + if callable(getattr(self, "on_cancel", None)): + self.on_cancel() + + def on_apply_clicked(self, btn=None): + if callable(getattr(self, "on_apply", None)): + self.on_apply(getattr(self, "selected_filter", "filter-original")) + ''' * Public Helpers * ''' diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view.py index 466c643..cfcb4a7 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view.py @@ -143,23 +143,6 @@ def on_response(dlg, response_id: str): dialog.present() def setup_editing_tools_bar(self): - def on_drawing_clicked(btn): - if not self.texture or not self.picture: - return - - self.zoomable_image.reset_view_fit() - self.zoomable_image.set_zoom_enabled(False) - self.set_edit_bar_visible(False) - - if getattr(self, "draw_overlay", None): - self.overlay.remove_overlay(self.draw_overlay) - self.draw_overlay = None - - self.draw_overlay = DrawOverlay(self.picture, self.texture, clamp_to_image=True) - self.overlay.add_overlay(self.draw_overlay) - self.draw_overlay.queue_draw() - self.show_drawing_bar() - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) bar.set_hexpand(True) bar.set_halign(Gtk.Align.FILL) @@ -187,7 +170,7 @@ def _icon_button(icon_name: str, tooltip: str, handler): crop_btn = _icon_button("zoom-fit-best", "Crop", self.on_crop_clicked) filters_btn = _icon_button("color-select", "Filters", self.on_filters_clicked) fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", self.on_fine_tunes_clicked) - drawing_btn = _icon_button("document-edit", "Drawing", on_drawing_clicked) + drawing_btn = _icon_button("document-edit", "Drawing", self.on_drawing_clicked) for b in (crop_btn, filters_btn, fine_tunes_btn, drawing_btn): b.set_hexpand(True) @@ -382,166 +365,41 @@ def op(in_path: str, out_path: str, overwrite: bool): ''' * Drawing Feature * ''' - def show_drawing_bar(self): - if getattr(self, "drawing_bar", None): + def on_drawing_clicked(self, btn): + if not self.texture or not self.picture: return - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_hexpand(True) - bar.set_margin_start(5) - bar.set_margin_end(5) - bar.set_margin_bottom(12) - bar.set_margin_top(6) - - bar.add_css_class("osd") - bar.add_css_class("toolbar") - bar.set_can_target(True) - - # Cancel button - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.set_halign(Gtk.Align.FILL) - cancel.connect("clicked", self.on_drawing_cancel_clicked) - - # Color popover button - color_menu = Gtk.MenuButton() - color_menu.set_tooltip_text("Stroke color") - color_menu.set_valign(Gtk.Align.CENTER) - color_menu.set_has_frame(True) - - color_icon = Gtk.Image.new_from_icon_name("color-select") - color_icon.set_pixel_size(18) - color_menu.set_child(color_icon) - - color_pop = Gtk.Popover() - color_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - color_box.set_margin_top(10) - color_box.set_margin_bottom(10) - color_box.set_margin_start(10) - color_box.set_margin_end(10) - - color_label = Gtk.Label(label="Stroke color") - color_label.set_halign(Gtk.Align.START) - - color_dialog = Gtk.ColorDialog() - color_btn = Gtk.ColorDialogButton.new(color_dialog) + self.zoomable_image.reset_view_fit() + self.zoomable_image.set_zoom_enabled(False) + self.set_edit_bar_visible(False) if getattr(self, "draw_overlay", None): - color_btn.set_rgba(self.draw_overlay.color) - - def _on_color_changed(btn, _pspec=None): - if not getattr(self, "draw_overlay", None): - return - self.draw_overlay.set_color(btn.get_rgba()) - - color_btn.connect("notify::rgba", _on_color_changed) - - color_box.append(color_label) - color_box.append(color_btn) - color_pop.set_child(color_box) - color_menu.set_popover(color_pop) - - # Size popover button - size_btn = Gtk.MenuButton() - size_btn.set_tooltip_text("Stroke size") - size_btn.set_valign(Gtk.Align.CENTER) - size_btn.set_has_frame(True) - - size_icon = Gtk.Image.new_from_icon_name("find-location") - size_icon.set_pixel_size(18) - size_btn.set_child(size_icon) - - size_pop = Gtk.Popover() - size_pop.set_has_arrow(True) - - size_pop.set_size_request(220, -1) - - size_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - size_box.set_margin_top(10) - size_box.set_margin_bottom(10) - size_box.set_margin_start(12) - size_box.set_margin_end(12) - size_box.set_hexpand(True) - - size_label = Gtk.Label(label="Size") - size_label.set_valign(Gtk.Align.CENTER) - size_label.add_css_class("dim-label") + self.overlay.remove_overlay(self.draw_overlay.get_bar_widget()) + self.draw_overlay = None - adj = Gtk.Adjustment(value=4.0, lower=1.0, upper=100.0, step_increment=1.0, page_increment=2.0) + self.draw_overlay = DrawOverlay(self.picture, self.texture, clamp_to_image=True) - scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) - scale.set_draw_value(True) - scale.set_digits(0) - scale.set_valign(Gtk.Align.CENTER) - scale.set_hexpand(True) + self.draw_overlay.on_cancel = lambda: self.on_drawing_cancel_clicked(btn) + self.draw_overlay.on_apply = lambda payload=None: self.on_drawing_apply_clicked(btn) - scale.set_size_request(150, -1) + self.overlay.add_overlay(self.draw_overlay) + self.overlay.add_overlay(self.draw_overlay.get_bar_widget()) + self.draw_overlay.queue_draw() - if getattr(self, "draw_overlay", None): - adj.set_value(float(self.draw_overlay.line_width)) - - def _on_size_changed(s): - if getattr(self, "draw_overlay", None): - self.draw_overlay.set_line_width(s.get_value()) - - scale.connect("value-changed", _on_size_changed) - - size_box.append(size_label) - size_box.append(scale) - - size_pop.set_child(size_box) - size_btn.set_popover(size_pop) - - # Delete / Undo last stroke button - undo_btn = Gtk.MenuButton() - undo_btn.set_has_frame(True) - undo_btn.set_valign(Gtk.Align.CENTER) - undo_btn.set_tooltip_text("Undo last stroke") - - undo_icon = Gtk.Image.new_from_icon_name("edit-undo-symbolic") - undo_icon.set_pixel_size(18) - undo_btn.set_child(undo_icon) - - gesture = Gtk.GestureClick.new() - gesture.connect("pressed", lambda *_: self.draw_overlay and self.draw_overlay.undo_last_stroke()) - undo_btn.add_controller(gesture) - - # Done Button - done = Gtk.Button(label="Done") - done.set_hexpand(True) - done.set_halign(Gtk.Align.FILL) - done.add_css_class("suggested-action") - done.connect("clicked", self.on_drawing_done_clicked) - - # Layout - bar.append(cancel) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(color_menu) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(size_btn) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(undo_btn) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(done) - - self.drawing_bar = bar - self.overlay.add_overlay(bar) + def on_drawing_cancel_clicked(self, _btn=None): + draw = getattr(self, "draw_overlay", None) + if draw: + bar = draw.get_bar_widget() + if bar: + self.overlay.remove_overlay(bar) - def on_drawing_cancel_clicked(self, _btn): - if getattr(self, "draw_overlay", None): - self.overlay.remove_overlay(self.draw_overlay) + self.overlay.remove_overlay(draw) self.draw_overlay = None - if getattr(self, "drawing_bar", None): - self.overlay.remove_overlay(self.drawing_bar) - self.drawing_bar = None - self.set_edit_bar_visible(True) self.zoomable_image.set_zoom_enabled(True) - def on_drawing_done_clicked(self, btn=None): + def on_drawing_apply_clicked(self, btn=None): draw = getattr(self, "draw_overlay", None) if not draw: self.on_drawing_cancel_clicked(btn) From d42ad77e9564924ee1bdd4c68f89fcb9aefda382 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 04:38:54 -0500 Subject: [PATCH 40/62] treewide: Move all editing software into its own directory. Signed-off-by: Joaquin Philco --- furios_gallery/{ => edit_view}/crop_overlay.py | 0 furios_gallery/{ => edit_view}/draw_overlay.py | 0 furios_gallery/{ => edit_view}/edit_view.py | 4 ++-- furios_gallery/{ => edit_view}/filters_overlay.py | 0 furios_gallery/{ => edit_view}/furios_media_tools.py | 0 .../{ => edit_view}/image_transformations_overlay.py | 0 furios_gallery/gallery_window.py | 4 ++-- 7 files changed, 4 insertions(+), 4 deletions(-) rename furios_gallery/{ => edit_view}/crop_overlay.py (100%) rename furios_gallery/{ => edit_view}/draw_overlay.py (100%) rename furios_gallery/{ => edit_view}/edit_view.py (99%) rename furios_gallery/{ => edit_view}/filters_overlay.py (100%) rename furios_gallery/{ => edit_view}/furios_media_tools.py (100%) rename furios_gallery/{ => edit_view}/image_transformations_overlay.py (100%) diff --git a/furios_gallery/crop_overlay.py b/furios_gallery/edit_view/crop_overlay.py similarity index 100% rename from furios_gallery/crop_overlay.py rename to furios_gallery/edit_view/crop_overlay.py diff --git a/furios_gallery/draw_overlay.py b/furios_gallery/edit_view/draw_overlay.py similarity index 100% rename from furios_gallery/draw_overlay.py rename to furios_gallery/edit_view/draw_overlay.py diff --git a/furios_gallery/edit_view.py b/furios_gallery/edit_view/edit_view.py similarity index 99% rename from furios_gallery/edit_view.py rename to furios_gallery/edit_view/edit_view.py index cfcb4a7..b90fde6 100644 --- a/furios_gallery/edit_view.py +++ b/furios_gallery/edit_view/edit_view.py @@ -16,9 +16,9 @@ from .filters_overlay import FiltersOverlay from .image_transformations_overlay import ImageTransformationsOverlay from .furios_media_tools import FuriOSMediaTools -from .image_viewer_widget import ImageViewerWidget +from ..image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene, GLib -from .ui import (create_edit_view_main_box, create_edit_view_overlay) +from ..ui import (create_edit_view_main_box, create_edit_view_overlay) class EditView(Adw.NavigationPage): def __init__(self, app, media_path: str): diff --git a/furios_gallery/filters_overlay.py b/furios_gallery/edit_view/filters_overlay.py similarity index 100% rename from furios_gallery/filters_overlay.py rename to furios_gallery/edit_view/filters_overlay.py diff --git a/furios_gallery/furios_media_tools.py b/furios_gallery/edit_view/furios_media_tools.py similarity index 100% rename from furios_gallery/furios_media_tools.py rename to furios_gallery/edit_view/furios_media_tools.py diff --git a/furios_gallery/image_transformations_overlay.py b/furios_gallery/edit_view/image_transformations_overlay.py similarity index 100% rename from furios_gallery/image_transformations_overlay.py rename to furios_gallery/edit_view/image_transformations_overlay.py diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 0bdc9a9..fcece17 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -17,9 +17,9 @@ from os.path import expanduser from pathlib import Path -from .furios_media_tools import FuriOSMediaTools +from .edit_view.furios_media_tools import FuriOSMediaTools from .media_view import MediaView -from .edit_view import EditView +from .edit_view.edit_view import EditView from .grid_view import GridView from .albums_view import Albums from .thumbnail_generator import ThumbnailGenerator From 2dfc5cb210842a7a30d62b6173c193b23ce68695 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 05:30:01 -0500 Subject: [PATCH 41/62] ui: Offload all ui to new file within edit_view. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view/crop_overlay.py | 29 +- furios_gallery/edit_view/draw_overlay.py | 152 +--------- furios_gallery/edit_view/edit_view.py | 45 +-- furios_gallery/edit_view/filters_overlay.py | 22 +- .../image_transformations_overlay.py | 78 +---- furios_gallery/edit_view/ui.py | 275 ++++++++++++++++++ 6 files changed, 314 insertions(+), 287 deletions(-) create mode 100644 furios_gallery/edit_view/ui.py diff --git a/furios_gallery/edit_view/crop_overlay.py b/furios_gallery/edit_view/crop_overlay.py index 90df194..2323c0d 100644 --- a/furios_gallery/edit_view/crop_overlay.py +++ b/furios_gallery/edit_view/crop_overlay.py @@ -7,7 +7,9 @@ import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") + from gi.repository import Gtk, Gdk, Graphene, Gsk +from .ui import (create_main_bar_body, create_cancel_btn, create_crop_btn) class CropOverlay(Gtk.Widget): HANDLE = 12 @@ -50,28 +52,11 @@ def build_crop_bar(self): if getattr(self, "crop_bar", None): return - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_hexpand(True) - bar.set_margin_start(12) - bar.set_margin_end(12) - bar.set_margin_bottom(12) - bar.set_margin_top(6) - - bar.add_css_class("osd") - bar.add_css_class("toolbar") - - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.set_halign(Gtk.Align.FILL) - cancel.connect("clicked", self.on_cancel_clicked) - - crop = Gtk.Button(label="Crop") - crop.set_hexpand(True) - crop.set_halign(Gtk.Align.FILL) - crop.add_css_class("suggested-action") - crop.connect("clicked", self.on_apply_clicked) + bar = create_main_bar_body(12, 12, 12, 12, 6, "horizontal") + + cancel = create_cancel_btn(self.on_cancel_clicked) + + crop = create_crop_btn(self.on_apply_clicked) bar.append(cancel) bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) diff --git a/furios_gallery/edit_view/draw_overlay.py b/furios_gallery/edit_view/draw_overlay.py index d0004cc..f4c72f1 100644 --- a/furios_gallery/edit_view/draw_overlay.py +++ b/furios_gallery/edit_view/draw_overlay.py @@ -8,6 +8,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk, Gsk +from .ui import create_drawing_bar class DrawOverlay(Gtk.Widget): def __init__( @@ -47,151 +48,20 @@ def __init__( drag.connect("drag-end", self.on_drag_end) self.add_controller(drag) - self.bar = self.build_drawing_bar() + self.bar = create_drawing_bar( + on_cancel=self.on_cancel_clicked, + on_done=self.on_apply_clicked, + on_undo=self.undo_last_stroke, + on_color_changed=self.set_color, + initial_rgba=self.color, + on_size_changed=self.set_line_width, + initial_size=self.line_width, + size_range=(1.0, 100.0), + ) def get_bar_widget(self) -> Gtk.Widget: return self.bar - def build_drawing_bar(self): - if getattr(self, "draw_bar", None): - return - - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_hexpand(True) - bar.set_margin_start(5) - bar.set_margin_end(5) - bar.set_margin_bottom(12) - bar.set_margin_top(6) - - bar.add_css_class("osd") - bar.add_css_class("toolbar") - bar.set_can_target(True) - - # Cancel button - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.set_halign(Gtk.Align.FILL) - cancel.connect("clicked", self.on_cancel_clicked) - - # Color popover button - color_menu = Gtk.MenuButton() - color_menu.set_tooltip_text("Stroke color") - color_menu.set_valign(Gtk.Align.CENTER) - color_menu.set_has_frame(True) - - color_icon = Gtk.Image.new_from_icon_name("color-select") - color_icon.set_pixel_size(18) - color_menu.set_child(color_icon) - - color_pop = Gtk.Popover() - color_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - color_box.set_margin_top(10) - color_box.set_margin_bottom(10) - color_box.set_margin_start(10) - color_box.set_margin_end(10) - - color_label = Gtk.Label(label="Stroke color") - color_label.set_halign(Gtk.Align.START) - - color_dialog = Gtk.ColorDialog() - color_btn = Gtk.ColorDialogButton.new(color_dialog) - color_btn.set_rgba(self.color) - - def _on_color_changed(btn, _pspec=None): - self.set_color(btn.get_rgba()) - - color_btn.connect("notify::rgba", _on_color_changed) - - color_box.append(color_label) - color_box.append(color_btn) - color_pop.set_child(color_box) - color_menu.set_popover(color_pop) - - # Size popover button - size_btn = Gtk.MenuButton() - size_btn.set_tooltip_text("Stroke size") - size_btn.set_valign(Gtk.Align.CENTER) - size_btn.set_has_frame(True) - - size_icon = Gtk.Image.new_from_icon_name("find-location") - size_icon.set_pixel_size(18) - size_btn.set_child(size_icon) - - size_pop = Gtk.Popover() - size_pop.set_has_arrow(True) - - size_pop.set_size_request(220, -1) - - size_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - size_box.set_margin_top(10) - size_box.set_margin_bottom(10) - size_box.set_margin_start(12) - size_box.set_margin_end(12) - size_box.set_hexpand(True) - - size_label = Gtk.Label(label="Size") - size_label.set_valign(Gtk.Align.CENTER) - size_label.add_css_class("dim-label") - - adj = Gtk.Adjustment(value=4.0, lower=1.0, upper=100.0, step_increment=1.0, page_increment=2.0) - - scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) - scale.set_draw_value(True) - scale.set_digits(0) - scale.set_valign(Gtk.Align.CENTER) - scale.set_hexpand(True) - - scale.set_size_request(150, -1) - - adj.set_value(float(self.line_width)) - - def _on_size_changed(s): - self.set_line_width(s.get_value()) - - scale.connect("value-changed", _on_size_changed) - - size_box.append(size_label) - size_box.append(scale) - - size_pop.set_child(size_box) - size_btn.set_popover(size_pop) - - # Delete / Undo last stroke button - undo_btn = Gtk.MenuButton() - undo_btn.set_has_frame(True) - undo_btn.set_valign(Gtk.Align.CENTER) - undo_btn.set_tooltip_text("Undo last stroke") - - undo_icon = Gtk.Image.new_from_icon_name("edit-undo-symbolic") - undo_icon.set_pixel_size(18) - undo_btn.set_child(undo_icon) - - gesture = Gtk.GestureClick.new() - gesture.connect("pressed", lambda *_: self and self.undo_last_stroke()) - undo_btn.add_controller(gesture) - - # Done Button - done = Gtk.Button(label="Done") - done.set_hexpand(True) - done.set_halign(Gtk.Align.FILL) - done.add_css_class("suggested-action") - done.connect("clicked", self.on_apply_clicked) - - # Layout - bar.append(cancel) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(color_menu) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(size_btn) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(undo_btn) - bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) - bar.append(done) - - return bar - def on_cancel_clicked(self, btn=None): if callable(getattr(self, "on_cancel", None)): self.on_cancel() diff --git a/furios_gallery/edit_view/edit_view.py b/furios_gallery/edit_view/edit_view.py index b90fde6..19e9abc 100644 --- a/furios_gallery/edit_view/edit_view.py +++ b/furios_gallery/edit_view/edit_view.py @@ -19,6 +19,7 @@ from ..image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene, GLib from ..ui import (create_edit_view_main_box, create_edit_view_overlay) +from .ui import (create_main_bar_body, create_confirmation_dialog, create_icon_btn) class EditView(Adw.NavigationPage): def __init__(self, app, media_path: str): @@ -94,15 +95,7 @@ def setup_picture_to_edit(self, media_path: str | None) -> Gtk.Widget: * Editing Bar * ''' def on_apply_btn_clicked(self, btn, title: str, body: str, operation, reload_after: bool): - dialog = Adw.MessageDialog.new(self.get_root(), title, body) - - dialog.add_response("cancel", "Cancel") - dialog.add_response("copy", "Save Copy") - dialog.add_response("overwrite", "Overwrite") - dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) - dialog.set_default_response("copy") - dialog.set_close_response("cancel") + dialog = create_confirmation_dialog(self.get_root(), title, body) def on_response(dlg, response_id: str): if response_id == "cancel": @@ -143,34 +136,12 @@ def on_response(dlg, response_id: str): dialog.present() def setup_editing_tools_bar(self): - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - bar.set_hexpand(True) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_margin_start(12) - bar.set_margin_end(12) - bar.set_margin_bottom(12) - bar.set_margin_top(6) - - bar.add_css_class("toolbar") - bar.add_css_class("osd") - - def _icon_button(icon_name: str, tooltip: str, handler): - btn = Gtk.Button() - btn.set_has_frame(False) - btn.set_tooltip_text(tooltip) - - img = Gtk.Image.new_from_icon_name(icon_name) - img.set_pixel_size(22) - btn.set_child(img) - - btn.connect("clicked", handler) - return btn - - crop_btn = _icon_button("zoom-fit-best", "Crop", self.on_crop_clicked) - filters_btn = _icon_button("color-select", "Filters", self.on_filters_clicked) - fine_tunes_btn = _icon_button("preferences-system", "Fine tunes", self.on_fine_tunes_clicked) - drawing_btn = _icon_button("document-edit", "Drawing", self.on_drawing_clicked) + bar = create_main_bar_body(12, 12, 12, 12, 6, "horizontal") + + crop_btn = create_icon_btn("zoom-fit-best", "Crop", self.on_crop_clicked) + filters_btn = create_icon_btn("color-select", "Filters", self.on_filters_clicked) + fine_tunes_btn = create_icon_btn("preferences-system", "Fine tunes", self.on_fine_tunes_clicked) + drawing_btn = create_icon_btn("document-edit", "Drawing", self.on_drawing_clicked) for b in (crop_btn, filters_btn, fine_tunes_btn, drawing_btn): b.set_hexpand(True) diff --git a/furios_gallery/edit_view/filters_overlay.py b/furios_gallery/edit_view/filters_overlay.py index 5937196..73d0b5e 100644 --- a/furios_gallery/edit_view/filters_overlay.py +++ b/furios_gallery/edit_view/filters_overlay.py @@ -9,6 +9,7 @@ gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk, GLib +from .ui import (create_main_bar_body, create_cancel_btn, create_apply_btn) FILTERS = [ ("Original", "filter-original"), @@ -93,15 +94,7 @@ def make_filter_button(self, label: str, css_class: str, thumb_path: str) -> Gtk return btn def build_bar(self) -> Gtk.Widget: - filters_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - filters_bar.set_hexpand(True) - filters_bar.set_halign(Gtk.Align.FILL) - filters_bar.set_valign(Gtk.Align.END) - filters_bar.set_margin_start(6) - filters_bar.set_margin_end(6) - filters_bar.set_margin_bottom(12) - filters_bar.add_css_class("osd") - filters_bar.add_css_class("toolbar") + filters_bar = create_main_bar_body(8, 6, 6, 12, 6, "vertical") outer = Gtk.ScrolledWindow() outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) @@ -120,14 +113,9 @@ def build_bar(self) -> Gtk.Widget: actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) actions.set_hexpand(True) - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.connect("clicked", self.on_cancel_clicked) + cancel = create_cancel_btn(self.on_cancel_clicked) - apply = Gtk.Button(label="Apply") - apply.set_hexpand(True) - apply.add_css_class("suggested-action") - apply.connect("clicked", self.on_apply_clicked) + apply = create_apply_btn(self.on_apply_clicked) actions.append(cancel) actions.append(apply) @@ -152,5 +140,3 @@ def on_filter_clicked(self, button: Gtk.Button, css_class: str): for c in ALL_FILTER_CLASSES: self.picture_widget.remove_css_class(c) self.picture_widget.add_css_class(css_class) - - #TODO: Actully save the fitlered imaged to DISK diff --git a/furios_gallery/edit_view/image_transformations_overlay.py b/furios_gallery/edit_view/image_transformations_overlay.py index 06a55f3..35b4014 100644 --- a/furios_gallery/edit_view/image_transformations_overlay.py +++ b/furios_gallery/edit_view/image_transformations_overlay.py @@ -10,6 +10,7 @@ gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk, GLib +from .ui import (create_main_bar_body, create_cancel_btn, create_apply_btn, make_slider_menu_button) class ImageTransformationsOverlay(Gtk.Widget): def __init__(self, picture_widget: Gtk.Widget, media_path: str): @@ -46,64 +47,8 @@ def __init__(self, picture_widget: Gtk.Widget, media_path: str): def get_bar_widget(self) -> Gtk.Widget: return self.bar - def make_slider_menu_button(self, icon_name: str, tooltip: str, title: str, value: float, lower: float, upper: float, step: float, digits: int, on_change) -> Gtk.MenuButton: - btn = Gtk.MenuButton() - btn.set_tooltip_text(tooltip) - btn.set_valign(Gtk.Align.CENTER) - btn.set_has_frame(True) - - icon = Gtk.Image.new_from_icon_name(icon_name) - icon.set_pixel_size(18) - btn.set_child(icon) - - pop = Gtk.Popover() - pop.set_has_arrow(True) - pop.set_size_request(260, -1) - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - box.set_margin_top(10) - box.set_margin_bottom(10) - box.set_margin_start(12) - box.set_margin_end(12) - box.set_hexpand(True) - - label = Gtk.Label(label=title) - label.set_valign(Gtk.Align.CENTER) - label.add_css_class("dim-label") - - adj = Gtk.Adjustment( - value=float(value), - lower=float(lower), - upper=float(upper), - step_increment=float(step), - page_increment=float(step) * 10.0, - ) - - scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) - scale.set_draw_value(True) - scale.set_digits(int(digits)) - scale.set_valign(Gtk.Align.CENTER) - scale.set_hexpand(True) - - adj.connect("value-changed", lambda a: on_change(float(a.get_value()))) - - box.append(label) - box.append(scale) - pop.set_child(box) - btn.set_popover(pop) - - return btn - def build_imtr_bar(self) -> Gtk.Widget: - bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - bar.set_hexpand(True) - bar.set_halign(Gtk.Align.FILL) - bar.set_valign(Gtk.Align.END) - bar.set_margin_start(6) - bar.set_margin_end(6) - bar.set_margin_bottom(12) - bar.add_css_class("osd") - bar.add_css_class("toolbar") + bar = create_main_bar_body(8, 6, 6, 12, 6, "vertical") outer = Gtk.ScrolledWindow() outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) @@ -116,7 +61,7 @@ def build_imtr_bar(self) -> Gtk.Widget: outer.set_child(row) # Brigthness, Contrast, Saturation, Temperature, Blur - row.append(self.make_slider_menu_button( + row.append(make_slider_menu_button( icon_name="weather-clear-symbolic", tooltip="Brightness", title="Brightness", @@ -128,7 +73,7 @@ def build_imtr_bar(self) -> Gtk.Widget: on_change=self.on_brightness_changed, )) - row.append(self.make_slider_menu_button( + row.append(make_slider_menu_button( icon_name="semi-starred-symbolic", tooltip="Contrast", title="Contrast", @@ -140,7 +85,7 @@ def build_imtr_bar(self) -> Gtk.Widget: on_change=self.on_contrast_changed, )) - row.append(self.make_slider_menu_button( + row.append(make_slider_menu_button( icon_name="color-select-symbolic", tooltip="Saturation", title="Saturation", @@ -152,7 +97,7 @@ def build_imtr_bar(self) -> Gtk.Widget: on_change=self.on_saturation_changed, )) - row.append(self.make_slider_menu_button( + row.append(make_slider_menu_button( icon_name="temperature-symbolic", tooltip="Temperature", title="Temperature", @@ -164,7 +109,7 @@ def build_imtr_bar(self) -> Gtk.Widget: on_change=self.on_temperature_changed, )) - row.append(self.make_slider_menu_button( + row.append(make_slider_menu_button( icon_name="edit-select-all-symbolic", tooltip="Blur", title="Blur", @@ -179,14 +124,9 @@ def build_imtr_bar(self) -> Gtk.Widget: actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) actions.set_hexpand(True) - cancel = Gtk.Button(label="Cancel") - cancel.set_hexpand(True) - cancel.connect("clicked", self.on_cancel_clicked) + cancel = create_cancel_btn(self.on_cancel_clicked) - apply = Gtk.Button(label="Apply") - apply.set_hexpand(True) - apply.add_css_class("suggested-action") - apply.connect("clicked", self.on_apply_clicked) + apply = create_apply_btn(self.on_apply_clicked) actions.append(cancel) actions.append(apply) diff --git a/furios_gallery/edit_view/ui.py b/furios_gallery/edit_view/ui.py new file mode 100644 index 0000000..213da56 --- /dev/null +++ b/furios_gallery/edit_view/ui.py @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi +from typing import Callable +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Shumate', '1.0') +from gi.repository import Gtk, Adw, Gdk, GdkPixbuf, Pango, Shumate + +def create_main_bar_body(spacing, mstart, mend, mbottom, mtop, direction): + if direction == "vertical": + bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) + elif direction == "horizontal": + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=spacing) + + bar.set_halign(Gtk.Align.FILL) + bar.set_valign(Gtk.Align.END) + bar.set_hexpand(True) + bar.set_margin_start(mstart) + bar.set_margin_end(mend) + bar.set_margin_bottom(mbottom) + bar.set_margin_top(mtop) + + bar.add_css_class("osd") + bar.add_css_class("toolbar") + + return bar + +def create_cancel_btn(callback): + cancel = Gtk.Button(label="Cancel") + cancel.set_hexpand(True) + cancel.set_halign(Gtk.Align.FILL) + cancel.connect("clicked", callback) + + return cancel + +def create_confirmation_dialog(root_ctx, title, body): + dialog = Adw.MessageDialog.new(root_ctx, title, body) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("copy", "Save Copy") + dialog.add_response("overwrite", "Overwrite") + dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_response_appearance("copy", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("copy") + dialog.set_close_response("cancel") + + return dialog + +def create_icon_btn(icon_name: str,tooltip: str, handler): + btn = Gtk.Button() + btn.set_has_frame(False) + btn.set_tooltip_text(tooltip) + + img = Gtk.Image.new_from_icon_name(icon_name) + img.set_pixel_size(22) + btn.set_child(img) + + btn.connect("clicked", handler) + return btn + +''' +* Crop Feature * +''' +def create_crop_btn(callback): + crop = Gtk.Button(label="Crop") + crop.set_hexpand(True) + crop.set_halign(Gtk.Align.FILL) + crop.add_css_class("suggested-action") + crop.connect("clicked", callback) + + return crop + +''' +* Filters Feature * +''' +def create_apply_btn(callback): + crop = Gtk.Button(label="Apply") + crop.set_hexpand(True) + crop.set_halign(Gtk.Align.FILL) + crop.add_css_class("suggested-action") + crop.connect("clicked", callback) + + return crop + + +''' +* Image Transformations Feature * +''' +def make_slider_menu_button(icon_name: str, tooltip: str, title: str, value: float, lower: float, upper: float, step: float, digits: int, on_change) -> Gtk.MenuButton: + btn = Gtk.MenuButton() + btn.set_tooltip_text(tooltip) + btn.set_valign(Gtk.Align.CENTER) + btn.set_has_frame(True) + + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_pixel_size(18) + btn.set_child(icon) + + pop = Gtk.Popover() + pop.set_has_arrow(True) + pop.set_size_request(260, -1) + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.set_margin_top(10) + box.set_margin_bottom(10) + box.set_margin_start(12) + box.set_margin_end(12) + box.set_hexpand(True) + + label = Gtk.Label(label=title) + label.set_valign(Gtk.Align.CENTER) + label.add_css_class("dim-label") + + adj = Gtk.Adjustment( + value=float(value), + lower=float(lower), + upper=float(upper), + step_increment=float(step), + page_increment=float(step) * 10.0, + ) + + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_draw_value(True) + scale.set_digits(int(digits)) + scale.set_valign(Gtk.Align.CENTER) + scale.set_hexpand(True) + + adj.connect("value-changed", lambda a: on_change(float(a.get_value()))) + + box.append(label) + box.append(scale) + pop.set_child(box) + btn.set_popover(pop) + + return btn + +''' +* Drawing Feature * +''' +def create_drawing_bar( + on_cancel, + on_done, + on_undo, + on_color_changed, + initial_rgba, + on_size_changed, + initial_size: float, + size_range=(1.0, 100.0), + spacing=5, + mstart=5, mend=5, mbottom=12, mtop=6, +) -> Gtk.Widget: + bar = create_main_bar_body(spacing, mstart, mend, mbottom, mtop, "horizontal") + bar.set_can_target(True) + + # Cancel + cancel = create_cancel_btn(on_cancel) + + # Color popover + color_menu = Gtk.MenuButton() + color_menu.set_tooltip_text("Stroke color") + color_menu.set_valign(Gtk.Align.CENTER) + color_menu.set_has_frame(True) + + color_icon = Gtk.Image.new_from_icon_name("color-select") + color_icon.set_pixel_size(18) + color_menu.set_child(color_icon) + + color_pop = Gtk.Popover() + color_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + for fn in (color_box.set_margin_top, color_box.set_margin_bottom, color_box.set_margin_start, color_box.set_margin_end): + fn(10) + + color_label = Gtk.Label(label="Stroke color") + color_label.set_halign(Gtk.Align.START) + + color_dialog = Gtk.ColorDialog() + color_btn = Gtk.ColorDialogButton.new(color_dialog) + color_btn.set_rgba(initial_rgba) + + def _on_rgba_notify(btn, _pspec=None): + on_color_changed(btn.get_rgba()) + + color_btn.connect("notify::rgba", _on_rgba_notify) + + color_box.append(color_label) + color_box.append(color_btn) + color_pop.set_child(color_box) + color_menu.set_popover(color_pop) + + # Size popover + size_btn = Gtk.MenuButton() + size_btn.set_tooltip_text("Stroke size") + size_btn.set_valign(Gtk.Align.CENTER) + size_btn.set_has_frame(True) + + size_icon = Gtk.Image.new_from_icon_name("find-location") + size_icon.set_pixel_size(18) + size_btn.set_child(size_icon) + + size_pop = Gtk.Popover() + size_pop.set_has_arrow(True) + size_pop.set_size_request(220, -1) + + size_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + size_box.set_margin_top(10) + size_box.set_margin_bottom(10) + size_box.set_margin_start(12) + size_box.set_margin_end(12) + size_box.set_hexpand(True) + + size_label = Gtk.Label(label="Size") + size_label.set_valign(Gtk.Align.CENTER) + size_label.add_css_class("dim-label") + + low, high = size_range + adj = Gtk.Adjustment(value=float(initial_size), lower=float(low), upper=float(high), + step_increment=1.0, page_increment=2.0) + + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.set_draw_value(True) + scale.set_digits(0) + scale.set_valign(Gtk.Align.CENTER) + scale.set_hexpand(True) + scale.set_size_request(150, -1) + + def _on_size_value_changed(s): + on_size_changed(s.get_value()) + + scale.connect("value-changed", _on_size_value_changed) + + size_box.append(size_label) + size_box.append(scale) + size_pop.set_child(size_box) + size_btn.set_popover(size_pop) + + # Undo + undo_btn = Gtk.MenuButton() + undo_btn.set_has_frame(True) + undo_btn.set_valign(Gtk.Align.CENTER) + undo_btn.set_tooltip_text("Undo last stroke") + + undo_icon = Gtk.Image.new_from_icon_name("edit-undo-symbolic") + undo_icon.set_pixel_size(18) + undo_btn.set_child(undo_icon) + + gesture = Gtk.GestureClick.new() + gesture.connect("pressed", lambda *_: on_undo()) + undo_btn.add_controller(gesture) + + # Done + done = Gtk.Button(label="Done") + done.set_hexpand(True) + done.set_halign(Gtk.Align.FILL) + done.add_css_class("suggested-action") + done.connect("clicked", on_done) + + # Layout + bar.append(cancel) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(color_menu) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(size_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(undo_btn) + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + bar.append(done) + + return bar + + From 9801e068e06e70066ef87b806ae5e1bc0bf28588 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 16:53:21 -0500 Subject: [PATCH 42/62] media_watcher: Implement a watcher that reacts on file created within the directories ~/Pictures and ~/Videos. * This one also checks albums within root ones recursively. * I still need to use events to update the UI components, but for now it detects new files created, copied and edited from edit_view. Signed-off-by: Joaquin Philco --- furios_gallery/media_watcher.py | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 furios_gallery/media_watcher.py diff --git a/furios_gallery/media_watcher.py b/furios_gallery/media_watcher.py new file mode 100644 index 0000000..258f1a9 --- /dev/null +++ b/furios_gallery/media_watcher.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import os, gi + +from pathlib import Path +from gi.repository import GLib, Gio +from .database_manager import (list_database_albums, insert_file_and_albums) + +class MediaWatcher(): + def __init__(self, conn, app): + self.app = app + self.conn = conn + self.dir_monitors = {} + self.root_dirs = [Path.home() / "Pictures", Path.home() / "Videos"] + + self.db_albums = set(list_database_albums(self.conn)) + + def start_media_monitors(self): + # Pictures Root + if self.root_dirs[0].exists(): + self.monitor_tree(self.root_dirs[0]) + + # Videos Root + if self.root_dirs[1].exists(): + self.monitor_tree(self.root_dirs[1]) + + def monitor_tree(self, root: Path): + for dirpath, dirnames, _ in os.walk(root): + self.monitor_directory(Path(dirpath)) + + def monitor_directory(self, directory: Path): + key = str(directory) + if key in self.dir_monitors: + return + + gfile = Gio.File.new_for_path(key) + try: + mon = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) + except Exception as e: + print(f"Failed to monitor {directory}: {e}") + return + + mon.connect("changed", self.on_dir_changed) + self.dir_monitors[key] = mon + + def on_dir_changed(self, monitor, file, other_file, event_type): + path = file.get_path() + if not path: + return + + # If a new directory was created, start monitoring it too (recursive) + if event_type == Gio.FileMonitorEvent.CREATED and os.path.isdir(path): + self.monitor_directory(Path(path)) + return + + if event_type != Gio.FileMonitorEvent.CREATED: + return + + GLib.idle_add(self.handle_new_media_path, path) + + def handle_new_media_path(self, file_path: str): + try: + if not os.path.isfile(file_path): + return False + + p = Path(file_path).resolve() + + root = self.get_root_media_dir(p) + if root is None: + return False + + # Determine candidate album based on folder under root + candidate_album = self.album_from_path(root, p) + + # Build album list to link + albums_to_link = [] + if candidate_album and candidate_album in self.db_albums: + albums_to_link.append(candidate_album) + + # Insert + link + insert_file_and_albums(self.conn, str(p), albums_to_link) + + + self.db_albums = set(list_database_albums(self.conn)) + + # TODO: From here, we need to signal the rest of the UI pages to updates gridview album views and so on + self.app.media_paths.append(str(p)) + + print(f"Inserted new file: {p} (album candidate: {candidate_album}, linked: {albums_to_link})") + + except Exception as e: + print("Error handling new media file:", e) + + return False + + def get_root_media_dir(self, p: Path) -> Path | None: + for root in self.root_dirs: + try: + root_resolved = root.resolve() + if p == root_resolved or root_resolved in p.parents: + return root_resolved + except Exception: + continue + return None + + def album_from_path(self, root: Path, p: Path) -> str: + rel = p.relative_to(root) + + if len(rel.parts) <= 1: + return root.name + + return rel.parts[0] \ No newline at end of file From 4940c7e0f1caf806114a2a9ffe3665fec83bd868 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 16:54:17 -0500 Subject: [PATCH 43/62] furios_gallery: Create an instance of media_watcher for top_down signaling of new files created. Signed-off-by: Joaquin Philco --- furios_gallery/gallery_window.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index fcece17..c964881 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -22,6 +22,7 @@ from .edit_view.edit_view import EditView from .grid_view import GridView from .albums_view import Albums +from .media_watcher import MediaWatcher from .thumbnail_generator import ThumbnailGenerator from .media_properties_view import MediaPropertiesView from .database_manager import ( @@ -109,6 +110,10 @@ def __init__(self, *args, **kwargs): self.present() + # Start Directory Watcher + self.media_watcher = MediaWatcher(self.conn, self) + self.media_watcher.start_media_monitors() + # Start background database population AFTER window is shown self.start_background_loading(str(app_dir / "gallery-albums.db")) From 452d90b77e8c8d1b9588491ae857c65d268a3044 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 17:31:54 -0500 Subject: [PATCH 44/62] grid_view: Handle case when there is only one item in the album. * Solves issue: https://github.com/FuriLabs/furios-gallery/issues/36. Signed-off-by: Joaquin Philco --- furios_gallery/grid_view.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/furios_gallery/grid_view.py b/furios_gallery/grid_view.py index 70a979b..c7a1e85 100644 --- a/furios_gallery/grid_view.py +++ b/furios_gallery/grid_view.py @@ -86,6 +86,16 @@ async def load_more_items(self): start_index = self.app.current_index end_index = max(self.app.current_index - self.items_per_load, 0) + + # Handle the case in which there is only one item: + if start_index == end_index == 0: + media_path = self.app.media_paths[0] + + if not media_path: + return + + self.add_media_to_flowbox(media_path, 0) + # We go from start_index down to end_index, in steps of batch_size while start_index > end_index: chunk_end = max(start_index - batch_size, end_index) From 5daa56eda4eb3b22513deccf6d7ce75db76233bb Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 17:50:50 -0500 Subject: [PATCH 45/62] tree_wide: Update all UI pages if a new file has been added or created. Signed-off-by: Joaquin Philco --- furios_gallery/gallery_window.py | 12 ++++++++++-- furios_gallery/media_watcher.py | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index c964881..bfdf32e 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -95,8 +95,8 @@ def __init__(self, *args, **kwargs): self.header.pack_start(self.create_change_name_btn) # Create initial albums page - self.initial_albums_page = self.create_albums_page() - self.navigation_view.add(self.initial_albums_page) + self.albums_page = self.create_albums_page() + self.navigation_view.add(self.albums_page) # Add header to toolbar view self.toolbar_view.add_top_bar(self.header) @@ -511,3 +511,11 @@ def do_rename(): dlg.present(self.get_root()) entry.grab_focus() entry.select_region(0, -1) + + def on_new_file_created(self, new_media_path: str): + visible_page = self.navigation_view.get_visible_page() + if visible_page: + if visible_page.get_title() == "Albums": + visible_page.update_all_album_thumbnails() + else: # GridView + visible_page.add_media_to_flowbox(new_media_path, len(self.media_paths) + 1) diff --git a/furios_gallery/media_watcher.py b/furios_gallery/media_watcher.py index 258f1a9..746d052 100644 --- a/furios_gallery/media_watcher.py +++ b/furios_gallery/media_watcher.py @@ -89,6 +89,7 @@ def handle_new_media_path(self, file_path: str): # TODO: From here, we need to signal the rest of the UI pages to updates gridview album views and so on self.app.media_paths.append(str(p)) + self.app.on_new_file_created(str(p)) print(f"Inserted new file: {p} (album candidate: {candidate_album}, linked: {albums_to_link})") From e6bc10752f3113d73f1cd04488fe0a0b09a6524a Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 30 Jan 2026 18:59:02 -0500 Subject: [PATCH 46/62] mediaView: Only show edit view button if its an image. There is no support for videos yet. Signed-off-by: Joaquin Philco --- furios_gallery/media_view.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/furios_gallery/media_view.py b/furios_gallery/media_view.py index 49a3283..566691f 100644 --- a/furios_gallery/media_view.py +++ b/furios_gallery/media_view.py @@ -80,15 +80,17 @@ def open_menu_popup(self, btn): remove_from_album_btn = create_option_button("Remove from Album", self.delete_from_album, dialog) media_options.append(remove_from_album_btn) - edit_medit_btn = create_option_button( - "Edit Media", - lambda _btn: ( - dialog.close(), - self.app.open_media_edit(self.app.current_index, self.app.media_paths[self.app.current_index]) + media_path = self.app.media_paths[self.app.current_index] + if media_path.endswith(('.png', '.jpg', '.jpeg', '.gif')): + edit_medit_btn = create_option_button( + "Edit Media", + lambda _btn: ( + dialog.close(), + self.app.open_media_edit(self.app.current_index, self.app.media_paths[self.app.current_index]) + ) ) - ) - media_options.append(edit_medit_btn) + media_options.append(edit_medit_btn) close_media_options_btn = create_option_button("Cancel", self.on_close_media_options, dialog) media_options.append(close_media_options_btn) From 9c39361702a00a25681f6c2db2671959164b1160 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 3 Feb 2026 20:24:11 -0500 Subject: [PATCH 47/62] image_viewer_widget: Respect Exif data orientation. Signed-off-by: Joaquin Philco --- furios_gallery/image_viewer_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/furios_gallery/image_viewer_widget.py b/furios_gallery/image_viewer_widget.py index 8a6d759..fde29a0 100644 --- a/furios_gallery/image_viewer_widget.py +++ b/furios_gallery/image_viewer_widget.py @@ -17,6 +17,7 @@ class ImageViewerWidget(Gtk.Widget): def __init__(self, path, win, scrolled_win, *args, **kwargs): super().__init__(*args, **kwargs) self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) + self.pixbuf = GdkPixbuf.Pixbuf.apply_embedded_orientation(self.pixbuf) self.texture = Gdk.Texture.new_for_pixbuf(self.pixbuf) self.min_scale = 0 self.scale = 1.0 From 64b3ea7427c50f6f38b85c72adbc2ac914b4a8a8 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Thu, 5 Feb 2026 16:37:21 -0500 Subject: [PATCH 48/62] gallery_window: Update both album view and grid views everytime a new file is added. Signed-off-by: Joaquin Philco --- furios_gallery/albums_view.py | 4 ++-- furios_gallery/gallery_window.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/furios_gallery/albums_view.py b/furios_gallery/albums_view.py index b689de6..36a55cd 100644 --- a/furios_gallery/albums_view.py +++ b/furios_gallery/albums_view.py @@ -157,10 +157,10 @@ def on_album_selected(self, flowbox): self.app_window.header.set_title_widget(Adw.WindowTitle(title=album_name)) # Create grid view page for the album - grid_view_page = self.app_window.create_grid_view_page(album_name) + self.app_window.grid_view_page = self.app_window.create_grid_view_page(album_name) # Push grid view to navigation view - self.app_window.navigation_view.push(grid_view_page) + self.app_window.navigation_view.push(self.app_window.grid_view_page) self.flowbox.unselect_all() diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index bfdf32e..1430686 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -98,6 +98,9 @@ def __init__(self, *args, **kwargs): self.albums_page = self.create_albums_page() self.navigation_view.add(self.albums_page) + # Create initial grid view page + self.grid_view_page = self.create_grid_view_page("Recents") + # Add header to toolbar view self.toolbar_view.add_top_bar(self.header) @@ -513,9 +516,8 @@ def do_rename(): entry.select_region(0, -1) def on_new_file_created(self, new_media_path: str): - visible_page = self.navigation_view.get_visible_page() - if visible_page: - if visible_page.get_title() == "Albums": - visible_page.update_all_album_thumbnails() - else: # GridView - visible_page.add_media_to_flowbox(new_media_path, len(self.media_paths) + 1) + #Update albums thumbnails + self.albums_page.update_all_album_thumbnails() + + #Update grid view + self.grid_view_page.add_media_to_flowbox(new_media_path, len(self.media_paths) + 1) \ No newline at end of file From 7612dace861198e885d3d4778aa14e84945b00d6 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Thu, 5 Feb 2026 21:35:31 -0500 Subject: [PATCH 49/62] media_watcher: Wait for signal of CHANGES_DONE_HINT, so videos can be handled as well. Signed-off-by: Joaquin Philco --- furios_gallery/media_watcher.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/furios_gallery/media_watcher.py b/furios_gallery/media_watcher.py index 746d052..85e4524 100644 --- a/furios_gallery/media_watcher.py +++ b/furios_gallery/media_watcher.py @@ -8,7 +8,7 @@ from pathlib import Path from gi.repository import GLib, Gio -from .database_manager import (list_database_albums, insert_file_and_albums) +from .database_manager import (list_database_albums, insert_file_and_albums, file_exists_in_database) class MediaWatcher(): def __init__(self, conn, app): @@ -57,10 +57,16 @@ def on_dir_changed(self, monitor, file, other_file, event_type): self.monitor_directory(Path(path)) return - if event_type != Gio.FileMonitorEvent.CREATED: + if not os.path.isfile(path): return - GLib.idle_add(self.handle_new_media_path, path) + p = Path(path) + + if event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT: + GLib.idle_add(self.handle_new_media_path, path) + return + else: + return def handle_new_media_path(self, file_path: str): try: @@ -73,6 +79,10 @@ def handle_new_media_path(self, file_path: str): if root is None: return False + # If the media already exists, do not insert again + if file_exists_in_database(self.conn, str(p)): + return False + # Determine candidate album based on folder under root candidate_album = self.album_from_path(root, p) @@ -84,10 +94,7 @@ def handle_new_media_path(self, file_path: str): # Insert + link insert_file_and_albums(self.conn, str(p), albums_to_link) - self.db_albums = set(list_database_albums(self.conn)) - - # TODO: From here, we need to signal the rest of the UI pages to updates gridview album views and so on self.app.media_paths.append(str(p)) self.app.on_new_file_created(str(p)) From 07f7a3c126fba03e6b5a6a813190847b19d3ad90 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sat, 7 Feb 2026 19:55:19 -0500 Subject: [PATCH 50/62] grid_view: On single item case, only add if the database return the object. Signed-off-by: Joaquin Philco --- furios_gallery/grid_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furios_gallery/grid_view.py b/furios_gallery/grid_view.py index c7a1e85..4e59b0c 100644 --- a/furios_gallery/grid_view.py +++ b/furios_gallery/grid_view.py @@ -88,7 +88,7 @@ async def load_more_items(self): end_index = max(self.app.current_index - self.items_per_load, 0) # Handle the case in which there is only one item: - if start_index == end_index == 0: + if len(self.app.media_paths) == 1 and start_index == end_index == 0: media_path = self.app.media_paths[0] if not media_path: From 90c2ebbceba3958870a5ea80709bf92bf11368ca Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sat, 7 Feb 2026 20:22:31 -0500 Subject: [PATCH 51/62] gallery_window: Check for the instance of the object raather than the title and give a default case. Signed-off-by: Joaquin Philco --- furios_gallery/gallery_window.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 1430686..7eafa05 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -154,7 +154,7 @@ def on_navigation_changed(self, navigation_view, page=None): self.media_options_btn.set_visible(True) self.info_btn.set_visible(True) self.return_btn.set_visible(True) - elif visible_page.get_title() == "Albums": + elif isinstance(visible_page, Albums): # Album view header self.header.set_title_widget(Adw.WindowTitle(title="Gallery")) self.create_change_name_btn.set_visible(False) @@ -163,7 +163,7 @@ def on_navigation_changed(self, navigation_view, page=None): self.media_options_btn.set_visible(False) self.info_btn.set_visible(False) self.return_btn.set_visible(False) - elif visible_page.get_title() == "Location": + elif isinstance(visible_page, MediaPropertiesView): # Map view header self.header.set_title_widget(Adw.WindowTitle(title=self.current_album)) self.create_change_name_btn.set_visible(False) @@ -172,7 +172,7 @@ def on_navigation_changed(self, navigation_view, page=None): self.media_options_btn.set_visible(False) self.info_btn.set_visible(False) self.return_btn.set_visible(True) - elif visible_page.get_title() == "Edit": + elif isinstance(visible_page, EditView): # Edit view header curr_file = f"Editing {os.path.basename(self.media_paths[self.current_index])}" self.header.set_title_widget(Adw.WindowTitle(title=curr_file)) @@ -182,14 +182,23 @@ def on_navigation_changed(self, navigation_view, page=None): self.media_options_btn.set_visible(False) self.info_btn.set_visible(False) self.return_btn.set_visible(True) - else: + elif isinstance(visible_page, GridView): # Grid view header self.header.set_title_widget(Adw.WindowTitle(title=self.current_album)) + self.create_change_name_btn.set_visible(False) self.create_album_btn.set_visible(False) self.delete_media_btn.set_visible(True) self.media_options_btn.set_visible(False) self.info_btn.set_visible(False) self.return_btn.set_visible(True) + else: + self.header.set_title_widget(Adw.WindowTitle(title="You shouldnt be here")) + self.create_change_name_btn.set_visible(False) + self.create_album_btn.set_visible(False) + self.delete_media_btn.set_visible(False) + self.media_options_btn.set_visible(False) + self.info_btn.set_visible(False) + self.return_btn.set_visible(True) def on_info_clicked(self, btn): current_page = self.navigation_view.get_visible_page() From 711626c464a8532c8ef0792790291ab2fa486e63 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 8 Feb 2026 10:03:59 -0500 Subject: [PATCH 52/62] treewide: Fix guards with the correct length of the media_paths array. Signed-off-by: Joaquin Philco --- furios_gallery/gallery_window.py | 2 +- furios_gallery/grid_view.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 7eafa05..0ae959e 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -529,4 +529,4 @@ def on_new_file_created(self, new_media_path: str): self.albums_page.update_all_album_thumbnails() #Update grid view - self.grid_view_page.add_media_to_flowbox(new_media_path, len(self.media_paths) + 1) \ No newline at end of file + self.grid_view_page.add_media_to_flowbox(new_media_path, len(self.media_paths) - 1) \ No newline at end of file diff --git a/furios_gallery/grid_view.py b/furios_gallery/grid_view.py index 4e59b0c..4d3c854 100644 --- a/furios_gallery/grid_view.py +++ b/furios_gallery/grid_view.py @@ -72,7 +72,7 @@ def update_selected_count(self, flowbox): def on_scroll(self, adjustment): if adjustment.get_value() + adjustment.get_page_size() >= adjustment.get_upper() - 50: - if self.app.current_index < len(self.app.media_paths): + if self.app.current_index < len(self.app.media_paths) - 1: asyncio.create_task(self.load_more_items()) async def load_more_items(self): From 58e8ccfa6215c2d44939e9e56c115e69ae80d43d Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 8 Feb 2026 12:01:05 -0500 Subject: [PATCH 53/62] ui: Make the button creation reusable and use it accordingly. Signed-off-by: Joaquin Philco --- furios_gallery/gallery_window.py | 19 +++++++------- furios_gallery/ui.py | 43 ++++---------------------------- 2 files changed, 14 insertions(+), 48 deletions(-) diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 0ae959e..9399ef3 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -32,10 +32,9 @@ populate_database_async, ) from .ui import ( - create_gallery_header, create_album_button, create_info_button, create_media_options_button, - create_delete_media_button, create_return_button, create_main_window_layout, - create_album_create_dialog, create_selection_header_bar, create_delete_confirmation_dialog, - create_map_page, clear_flowbox, create_change_file_name_button, create_rename_dialog + create_gallery_header, create_main_window_layout, create_album_create_dialog, + create_selection_header_bar, create_delete_confirmation_dialog, create_map_page, + clear_flowbox, create_rename_dialog, create_header_btn ) class GalleryWindow(Adw.ApplicationWindow): @@ -71,27 +70,27 @@ def __init__(self, *args, **kwargs): self.header = create_gallery_header() # Create album button - self.create_album_btn = create_album_button(self.create_album) + self.create_album_btn = create_header_btn(self.create_album, "folder-new-symbolic", True) self.header.pack_start(self.create_album_btn) # Info button (initially hidden) - self.info_btn = create_info_button(self.on_info_clicked) + self.info_btn = create_header_btn(self.on_info_clicked, "help-about-symbolic", False) self.header.pack_end(self.info_btn) # Media view buttons (initially hidden) - self.media_options_btn = create_media_options_button(self.on_media_options_clicked) + self.media_options_btn = create_header_btn(self.on_media_options_clicked, "view-more-symbolic", False) self.header.pack_end(self.media_options_btn) # Delete media button - self.delete_media_btn = create_delete_media_button(self.open_delete_popup) + self.delete_media_btn = create_header_btn(self.open_delete_popup, "user-trash-symbolic", True) self.header.pack_end(self.delete_media_btn) # Return button - self.return_btn = create_return_button(self.on_return_clicked) + self.return_btn = create_header_btn(self.on_return_clicked, "go-previous-symbolic", False) self.header.pack_start(self.return_btn) # Create change Name button (initially hidden) - self.create_change_name_btn = create_change_file_name_button(self.change_file_name) + self.create_change_name_btn = create_header_btn(self.change_file_name, "text-editor-symbolic", False) self.header.pack_start(self.create_change_name_btn) # Create initial albums page diff --git a/furios_gallery/ui.py b/furios_gallery/ui.py index 659e819..c8f4fb6 100644 --- a/furios_gallery/ui.py +++ b/furios_gallery/ui.py @@ -18,49 +18,16 @@ def create_gallery_header() -> Adw.HeaderBar: """Create the main gallery header bar.""" header = Adw.HeaderBar() header.set_title_widget(Adw.WindowTitle(title="Gallery")) + header.add_css_class("osd") + header.set_margin_bottom(10) return header -def create_album_button(callback: Callable) -> Gtk.Button: - """Create album button for header.""" - button = Gtk.Button(icon_name="folder-new-symbolic") +def create_header_btn(callback: Callable, icon_name: str, visible: bool = True) -> Gtk.Button: + button = Gtk.Button(icon_name=icon_name) + button.set_visible(visible) button.connect("clicked", callback) return button -def create_change_file_name_button(callback: Callable) -> Gtk.Button: - """Create change file name button for header.""" - button = Gtk.Button(icon_name="text-editor-symbolic") - button.connect("clicked", callback) - button.set_visible(False) - return button - -def create_info_button(callback: Callable) -> Gtk.Button: - """Create info button for header.""" - button = Gtk.Button(icon_name="help-about-symbolic") - button.connect("clicked", callback) - button.set_visible(False) - return button - -def create_media_options_button(callback: Callable) -> Gtk.Button: - """Create media options button for header.""" - button = Gtk.Button(icon_name="view-more-symbolic") - button.connect("clicked", callback) - button.set_visible(False) - return button - -def create_delete_media_button(callback: Callable) -> Gtk.Button: - """Create delete media button for header.""" - button = Gtk.Button(icon_name="user-trash-symbolic") - button.connect("clicked", callback) - button.add_css_class("delete-btn") - return button - -def create_return_button(callback: Callable) -> Gtk.Button: - """Create return button for header.""" - button = Gtk.Button(icon_name="go-previous-symbolic") - button.connect("clicked", callback) - button.set_visible(False) - return button - def create_albums_content_box() -> Gtk.Box: """Create main content box for albums view.""" content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) From bbef439b9f801d9d27128b5ac6b6cc8902cab72c Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sun, 8 Feb 2026 17:09:46 -0500 Subject: [PATCH 54/62] debian/control: Add python3-cairo to deps. Signed-off-by: Joaquin Philco --- debian/control | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/control b/debian/control index e997f5f..b77ad82 100644 --- a/debian/control +++ b/debian/control @@ -29,4 +29,5 @@ Depends: ${misc:Depends}, gstreamer1.0-gtk4, ffmpeg, python3-av, + python3-cairo, Description: Media gallery application for FuriOS From 9b2efbbc742445602a886d48f0b3b4a959fa9d65 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 9 Feb 2026 02:50:26 -0500 Subject: [PATCH 55/62] furios_gallery: If application exist, return already created window instead of a new instance. Signed-off-by: Joaquin Philco --- furios_gallery/furios_gallery.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/furios_gallery/furios_gallery.py b/furios_gallery/furios_gallery.py index d3a8276..f409c59 100644 --- a/furios_gallery/furios_gallery.py +++ b/furios_gallery/furios_gallery.py @@ -17,7 +17,11 @@ class GalleryApp(Adw.Application): def __init__(self): super().__init__(application_id='io.FuriOS.Gallery') self.connect('activate', self.on_activate) + self.win = None def on_activate(self, app): - self.win = GalleryWindow(application=app) + if self.win is None: + self.win = GalleryWindow(application=app) + app.add_window(self.win) + self.win.present() From 26615801773c7758e0bedba0e434506e7f575d98 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Sat, 14 Feb 2026 22:33:40 -0500 Subject: [PATCH 56/62] edit_view/ui: Reduce draw_overlay spacing. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furios_gallery/edit_view/ui.py b/furios_gallery/edit_view/ui.py index 213da56..921a1cc 100644 --- a/furios_gallery/edit_view/ui.py +++ b/furios_gallery/edit_view/ui.py @@ -151,7 +151,7 @@ def create_drawing_bar( on_size_changed, initial_size: float, size_range=(1.0, 100.0), - spacing=5, + spacing=0, mstart=5, mend=5, mbottom=12, mtop=6, ) -> Gtk.Widget: bar = create_main_bar_body(spacing, mstart, mend, mbottom, mtop, "horizontal") From 5cd72694c109ddbc61577bb3fc653f7bc16047af Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Mon, 23 Feb 2026 16:02:09 -0500 Subject: [PATCH 57/62] edit_view: Clamp gaussian blur to a small kernel for now. Bigger kernels take up to 30s to complete and it can trigger a Wayland compositor disconnect. TODO: Find a way to use GPU acceleration or a quicker way to perform the blur. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view/furios_media_tools.py | 2 ++ furios_gallery/edit_view/image_transformations_overlay.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/furios_gallery/edit_view/furios_media_tools.py b/furios_gallery/edit_view/furios_media_tools.py index 3b9570e..64cb15b 100644 --- a/furios_gallery/edit_view/furios_media_tools.py +++ b/furios_gallery/edit_view/furios_media_tools.py @@ -8,6 +8,7 @@ import gi, os import numpy as np from PIL import Image +import time gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") @@ -408,6 +409,7 @@ def apply_custom_filters(in_path: str, out_path: str, brightness: float | None, print(f"applied temperature: {sepia}") if not blur == 0.0: + blur = min(blur * 2.2, 10.0) print(f"applied blur: {blur}") out_rgb = FuriOSMediaTools.apply_gaussian_blur(out_rgb, blur) diff --git a/furios_gallery/edit_view/image_transformations_overlay.py b/furios_gallery/edit_view/image_transformations_overlay.py index 35b4014..02d2fde 100644 --- a/furios_gallery/edit_view/image_transformations_overlay.py +++ b/furios_gallery/edit_view/image_transformations_overlay.py @@ -159,7 +159,7 @@ def build_filter_css(self) -> str: b = max(0.0, min(2.0, float(self.brightness))) c = max(0.0, min(2.0, float(self.contrast))) s = max(0.0, min(2.0, float(self.saturation))) - bl = max(0.0, min(10.0, float(self.blur))) + bl = max(0.0, min(1.5, float(self.blur * 0.5))) t = max(-1.0, min(1.0, float(self.temperature))) sep = max(0.0, t) * 0.6 From a413892be0b8c0b9dba1f436d072d64fb2e7a08f Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 24 Feb 2026 00:36:13 -0500 Subject: [PATCH 58/62] media_view: Use main app member variables rather than getting them from NavigationView. Signed-off-by: Joaquin Philco --- furios_gallery/media_view.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/furios_gallery/media_view.py b/furios_gallery/media_view.py index 566691f..4c1909f 100644 --- a/furios_gallery/media_view.py +++ b/furios_gallery/media_view.py @@ -184,14 +184,8 @@ def on_delete_media(self, dialog, response): media_to_delete_index = self.app.current_index self.update_carousel() - - albums_view_page = self.app.navigation_view.find_page("albumsView") - if albums_view_page: - albums_view_page.update_all_album_thumbnails() - - grid_view_page = self.app.navigation_view.find_page("gridView") - if grid_view_page: - grid_view_page.delete_media_from_flowbox(media_to_delete_index) + self.app.albums_page.update_all_album_thumbnails() + self.app.grid_view_page.delete_media_from_flowbox(media_to_delete_index) return True else: From 79437915082712e2925e8675b5e268ebebe772c3 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Tue, 24 Feb 2026 02:42:00 -0500 Subject: [PATCH 59/62] grid_view: Delete from gridview based on pathname, not media index. This one does not update on delete, since its an attribute of the flowbox child set by us. Signed-off-by: Joaquin Philco --- furios_gallery/grid_view.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/furios_gallery/grid_view.py b/furios_gallery/grid_view.py index 4d3c854..910bbf9 100644 --- a/furios_gallery/grid_view.py +++ b/furios_gallery/grid_view.py @@ -21,6 +21,7 @@ def __init__(self, app, thumbnails, album_name="Media", items_per_load=200): self.app = app self.thumbnails = thumbnails self.items_per_load = items_per_load + self.child_by_path = {} # Main box to hold the grid view self.main_grid_box = create_grid_view_main_box() @@ -124,6 +125,7 @@ def add_media_to_flowbox(self, media_path, media_index): if thumbnail_path: flowbox_child = Gtk.FlowBoxChild() flowbox_child.media_index = media_index + flowbox_child.media_path = media_path flowbox_child.set_size_request(50, 90) GLib.idle_add( @@ -132,6 +134,8 @@ def add_media_to_flowbox(self, media_path, media_index): thumbnail_path ) + self.child_by_path[media_path] = flowbox_child + GLib.idle_add(self.flowbox.append, flowbox_child) GLib.idle_add(self.setup_single_child_click_handler, flowbox_child) @@ -140,14 +144,20 @@ def setup_single_child_click_handler(self, child): gesture.connect("pressed", self.on_flowbox_child_clicked, child) child.add_controller(gesture) - def delete_media_from_flowbox(self, media_index): - child = self.flowbox.get_first_child() + def delete_media_from_flowbox(self, media_path): + child = self.child_by_path.pop(media_path, None) + if child is not None: + self.flowbox.remove(child) + return + # fallback if dict not populated for some weird reason + child = self.flowbox.get_first_child() while child: - if hasattr(child, "media_index") and child.media_index == media_index: + next_child = child.get_next_sibling() + if getattr(child, "media_path", None) == media_path: self.flowbox.remove(child) break - child = child.get_next_sibling() + child = next_child def on_child_selected(self, flowbox): if self.flowbox.get_selection_mode() == Gtk.SelectionMode.MULTIPLE: @@ -158,8 +168,17 @@ def on_child_selected(self, flowbox): selected = flowbox.get_selected_children() if selected: # Check if there are selected items item = selected[0] - self.app.current_index = item.media_index - self.app.open_media_at_index(item.media_index) + path = getattr(item, "media_path", None) + if not path: + return + + try: + media_index = self.app.media_paths.index(path) + except ValueError: + return # item no longer exists + + self.app.current_index = media_index + self.app.open_media_at_index(media_index) self.flowbox.unselect_all() def setup_flowbox_click_handlers(self): From 5c285f7d11dbca00222e50064669f2de84ae9ef2 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 27 Feb 2026 23:02:22 -0500 Subject: [PATCH 60/62] Drop Image Transformations until I find a better way to compute them. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view/edit_view.py | 66 +----- .../image_transformations_overlay.py | 206 ------------------ 2 files changed, 1 insertion(+), 271 deletions(-) delete mode 100644 furios_gallery/edit_view/image_transformations_overlay.py diff --git a/furios_gallery/edit_view/edit_view.py b/furios_gallery/edit_view/edit_view.py index 19e9abc..a7fbba7 100644 --- a/furios_gallery/edit_view/edit_view.py +++ b/furios_gallery/edit_view/edit_view.py @@ -14,7 +14,6 @@ from .draw_overlay import DrawOverlay from .crop_overlay import CropOverlay from .filters_overlay import FiltersOverlay -from .image_transformations_overlay import ImageTransformationsOverlay from .furios_media_tools import FuriOSMediaTools from ..image_viewer_widget import ImageViewerWidget from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, Graphene, GLib @@ -140,10 +139,9 @@ def setup_editing_tools_bar(self): crop_btn = create_icon_btn("zoom-fit-best", "Crop", self.on_crop_clicked) filters_btn = create_icon_btn("color-select", "Filters", self.on_filters_clicked) - fine_tunes_btn = create_icon_btn("preferences-system", "Fine tunes", self.on_fine_tunes_clicked) drawing_btn = create_icon_btn("document-edit", "Drawing", self.on_drawing_clicked) - for b in (crop_btn, filters_btn, fine_tunes_btn, drawing_btn): + for b in (crop_btn, filters_btn, drawing_btn): b.set_hexpand(True) b.set_halign(Gtk.Align.CENTER) bar.append(b) @@ -271,68 +269,6 @@ def op(in_path: str, out_path: str, overwrite: bool): self.on_filters_cancel_clicked(btn) - ''' - * Image Transformations Feature * - ''' - def on_fine_tunes_clicked(self, btn): - if not self.texture or not self.picture: - return - - self.zoomable_image.reset_view_fit() - self.zoomable_image.set_zoom_enabled(False) - self.set_edit_bar_visible(False) - - if getattr(self, "image_transformations_overlay", None): - self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) - self.image_transformations_overlay = None - - target_widget = getattr(self.zoomable_image, "picture", self.zoomable_image) - - self.image_transformations_overlay = ImageTransformationsOverlay( - target_widget, - media_path=self.media_path - ) - - self.image_transformations_overlay.on_cancel = lambda: self.on_it_cancel_clicked(btn) - self.image_transformations_overlay.on_apply = lambda payload=None: self.on_it_apply_clicked(btn) - - self.overlay.add_overlay(self.image_transformations_overlay.get_bar_widget()) - - def on_it_cancel_clicked(self, btn=None): - if getattr(self, "image_transformations_overlay", None): - self.overlay.remove_overlay(self.image_transformations_overlay.get_bar_widget()) - self.image_transformations_overlay = None - - self.set_edit_bar_visible(True) - self.zoomable_image.set_zoom_enabled(True) - - def on_it_apply_clicked(self, btn=None): - it = getattr(self, "image_transformations_overlay", None) - if not it: - self.on_it_cancel_clicked(btn) - return - - def op(in_path: str, out_path: str, overwrite: bool): - return FuriOSMediaTools.apply_custom_filters( - in_path=in_path, - out_path=out_path, - brightness=it.brightness, - contrast=it.contrast, - saturation=it.saturation, - sepia=it.temperature, - blur=it.blur, - ) - - self.on_apply_btn_clicked( - btn=btn, - title="Save transformed image?", - body="Do you want to overwrite the original file or save a new copy?", - operation=op, - reload_after=True, - ) - - self.on_it_cancel_clicked(btn) - ''' * Drawing Feature * ''' diff --git a/furios_gallery/edit_view/image_transformations_overlay.py b/furios_gallery/edit_view/image_transformations_overlay.py deleted file mode 100644 index 02d2fde..0000000 --- a/furios_gallery/edit_view/image_transformations_overlay.py +++ /dev/null @@ -1,206 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0 -# Copyright (C) 2026 Furi Labs -# -# Authors: -# Joaquin Philco - -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") - -from gi.repository import Gtk, Gdk, GLib -from .ui import (create_main_bar_body, create_cancel_btn, create_apply_btn, make_slider_menu_button) - -class ImageTransformationsOverlay(Gtk.Widget): - def __init__(self, picture_widget: Gtk.Widget, media_path: str): - super().__init__() - self.picture_widget = picture_widget - self.media_path = media_path - - self.set_hexpand(True) - self.set_vexpand(True) - self.set_halign(Gtk.Align.FILL) - self.set_valign(Gtk.Align.FILL) - - self.preview_css_class = "imtr-preview" - self.css_provider = Gtk.CssProvider() - self.css_update_id = 0 - - Gtk.StyleContext.add_provider_for_display( - self.get_display(), - self.css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - self.picture_widget.add_css_class(self.preview_css_class) - self.brightness = 1.00 - self.contrast = 1.00 - self.saturation = 1.00 - self.temperature = 0.00 - self.blur = 0.0 - - self.defaults = (self.brightness, self.contrast, self.saturation, self.temperature, self.blur) - - self.bar = self.build_imtr_bar() - - def get_bar_widget(self) -> Gtk.Widget: - return self.bar - - def build_imtr_bar(self) -> Gtk.Widget: - bar = create_main_bar_body(8, 6, 6, 12, 6, "vertical") - - outer = Gtk.ScrolledWindow() - outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) - outer.set_overlay_scrolling(True) - - row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - row.set_halign(Gtk.Align.CENTER) - row.set_margin_top(1) - row.set_margin_bottom(1) - outer.set_child(row) - - # Brigthness, Contrast, Saturation, Temperature, Blur - row.append(make_slider_menu_button( - icon_name="weather-clear-symbolic", - tooltip="Brightness", - title="Brightness", - value=self.brightness, - lower=0.0, - upper=2.0, - step=0.01, - digits=2, - on_change=self.on_brightness_changed, - )) - - row.append(make_slider_menu_button( - icon_name="semi-starred-symbolic", - tooltip="Contrast", - title="Contrast", - value=self.contrast, - lower=0.0, - upper=2.0, - step=0.01, - digits=2, - on_change=self.on_contrast_changed, - )) - - row.append(make_slider_menu_button( - icon_name="color-select-symbolic", - tooltip="Saturation", - title="Saturation", - value=self.saturation, - lower=0.0, - upper=2.0, - step=0.01, - digits=2, - on_change=self.on_saturation_changed, - )) - - row.append(make_slider_menu_button( - icon_name="temperature-symbolic", - tooltip="Temperature", - title="Temperature", - value=self.temperature, - lower=-1.0, - upper=1.0, - step=0.01, - digits=2, - on_change=self.on_temperature_changed, - )) - - row.append(make_slider_menu_button( - icon_name="edit-select-all-symbolic", - tooltip="Blur", - title="Blur", - value=self.blur, - lower=0.0, - upper=5.0, - step=0.05, - digits=2, - on_change=self.on_blur_changed, - )) - - actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - actions.set_hexpand(True) - - cancel = create_cancel_btn(self.on_cancel_clicked) - - apply = create_apply_btn(self.on_apply_clicked) - - actions.append(cancel) - actions.append(apply) - - bar.append(outer) - bar.append(actions) - return bar - - def on_brightness_changed(self, v: float): - self.brightness = v - self.update_preview() - - def on_contrast_changed(self, v: float): - self.contrast = v - self.update_preview() - - def on_saturation_changed(self, v: float): - self.saturation = v - self.update_preview() - - def on_temperature_changed(self, v: float): - self.temperature = v - self.update_preview() - - def on_blur_changed(self, v: float): - self.blur = v - self.update_preview() - - def build_filter_css(self) -> str: - b = max(0.0, min(2.0, float(self.brightness))) - c = max(0.0, min(2.0, float(self.contrast))) - s = max(0.0, min(2.0, float(self.saturation))) - bl = max(0.0, min(1.5, float(self.blur * 0.5))) - - t = max(-1.0, min(1.0, float(self.temperature))) - sep = max(0.0, t) * 0.6 - - parts = [ - f"brightness({b:.3f})", - f"contrast({c:.3f})", - f"saturate({s:.3f})", - f"sepia({sep:.3f})", - ] - - if bl > 0.0001: - parts.append(f"blur({bl:.3f}px)") - - return " ".join(parts) - - def update_preview(self): - if getattr(self, "css_update_id", 0): - GLib.source_remove(self.css_update_id) - self.css_update_id = GLib.timeout_add(16, self.apply_preview_css) - - def apply_preview_css(self): - self.css_update_id = 0 - filt = self.build_filter_css() - - css = f""" - .{self.preview_css_class} {{ - filter: {filt}; - }} - """.encode("utf-8") - - self.css_provider.load_from_data(css) - return False - - def on_cancel_clicked(self, btn=None): - self.brightness, self.contrast, self.saturation, self.temperature, self.blur = self.defaults - self.update_preview() - - if callable(getattr(self, "on_cancel", None)): - self.on_cancel() - - def on_apply_clicked(self, btn=None): - if callable(getattr(self, "on_apply", None)): - self.on_apply(getattr(self, "selected_filter", "filter-original")) \ No newline at end of file From 0a997183f4efa07f3f6da6b1238b0ce23fb5baf5 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 27 Feb 2026 23:03:43 -0500 Subject: [PATCH 61/62] filters: drop warm filter due to Sepia misconception. Signed-off-by: Joaquin Philco --- furios_gallery/edit_view/filters_overlay.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/furios_gallery/edit_view/filters_overlay.py b/furios_gallery/edit_view/filters_overlay.py index 73d0b5e..2925f71 100644 --- a/furios_gallery/edit_view/filters_overlay.py +++ b/furios_gallery/edit_view/filters_overlay.py @@ -15,7 +15,6 @@ ("Original", "filter-original"), ("B&W", "filter-bw"), ("Vivid", "filter-vivid"), - ("Warm", "filter-warm"), ("Invert", "filter-invert"), ("Soft", "filter-soft"), ] @@ -30,7 +29,6 @@ .filter-original { filter: none; } .filter-bw { filter: grayscale(1); } .filter-vivid { filter: saturate(1.7) contrast(1.15); } - .filter-warm { filter: sepia(0.35) saturate(1.3) brightness(1.05); } .filter-invert { filter: invert(1); } .filter-soft { filter: blur(0.7px) brightness(1.02); } """ From e2988527c2f3c3611e4dad3b27911f47b70436e0 Mon Sep 17 00:00:00 2001 From: Joaquin Philco Date: Fri, 27 Feb 2026 23:17:20 -0500 Subject: [PATCH 62/62] GridView: Add furios-tight-grid CSS styling Signed-off-by: Joaquin Philco --- furios_gallery/ui.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/furios_gallery/ui.py b/furios_gallery/ui.py index c8f4fb6..6c9953b 100644 --- a/furios_gallery/ui.py +++ b/furios_gallery/ui.py @@ -188,6 +188,7 @@ def create_grid_view_flowbox(selection_callback: Callable, update_callback: Call """Create flowbox for grid view.""" flowbox = Gtk.FlowBox() flowbox.set_valign(Gtk.Align.START) + flowbox.add_css_class("furios-tight-grid") flowbox.set_column_spacing(0) flowbox.set_row_spacing(0) flowbox.set_max_children_per_line(5) @@ -206,7 +207,24 @@ def setup_grid_view_css(): .delete-btn { padding: 5px; } + + .furios-tight-grid flowboxchild { + padding: 0px; + margin: 1px; + border: 0px; + min-width: 0px; + min-height: 0px; + } + + .furios-tight-grid frame, + .furios-tight-grid button { + padding: 0px; + margin: 0px; + border: 0px; + box-shadow: none; + } """) + display = Gdk.Display.get_default() Gtk.StyleContext.add_provider_for_display(display, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)