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 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/edit_view/crop_overlay.py b/furios_gallery/edit_view/crop_overlay.py new file mode 100644 index 0000000..2323c0d --- /dev/null +++ b/furios_gallery/edit_view/crop_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, Graphene, Gsk +from .ui import (create_main_bar_body, create_cancel_btn, create_crop_btn) + +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) + + 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 = 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)) + 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()) + + 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) diff --git a/furios_gallery/edit_view/draw_overlay.py b/furios_gallery/edit_view/draw_overlay.py new file mode 100644 index 0000000..f4c72f1 --- /dev/null +++ b/furios_gallery/edit_view/draw_overlay.py @@ -0,0 +1,265 @@ +# 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 +from .ui import create_drawing_bar + +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) + + 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 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 * + ''' + 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: + 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) + + def snapshot_stroke(self, snapshot: Gtk.Snapshot, + pts: list[tuple[float, float]], + width: float, + color: Gdk.RGBA): + 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 = wpts[0] + path.move_to(x0, y0) + for (x, y) in wpts[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): + 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 img_pt is None: + self.current_pts = None + return + + 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 + + # 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) + + img_pt = self.widget_to_image(xw, yw) + if img_pt is None: + return + + lx, ly = self.current_pts[-1] + 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(img_pt) + 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 + 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, # image coords + "width_img": float(width_img), # image-pixel width + "color": self.color.copy() if hasattr(self.color, "copy") else self.color, + }) + + self.current_pts = None + if hasattr(self, "drag_begin_widget"): + delattr(self, "drag_begin_widget") + 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) diff --git a/furios_gallery/edit_view/edit_view.py b/furios_gallery/edit_view/edit_view.py new file mode 100644 index 0000000..a7fbba7 --- /dev/null +++ b/furios_gallery/edit_view/edit_view.py @@ -0,0 +1,338 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import gi, os +import traceback +gi.require_version('Adw', '1') +gi.require_version('Gtk', '4.0') +gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") + +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, 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): + 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 + 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) + + # self.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 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) + + 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(self.zoomable_image) + self.zoomable_image.init_gestures() + + self.picture = scrolled + + return scrolled + + ''' + * Editing Bar * + ''' + def on_apply_btn_clicked(self, btn, title: str, body: str, operation, reload_after: bool): + dialog = create_confirmation_dialog(self.get_root(), title, body) + + 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): + 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) + drawing_btn = create_icon_btn("document-edit", "Drawing", self.on_drawing_clicked) + + for b in (crop_btn, filters_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) + 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 on_crop_clicked(self, 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) + + self.crop_overlay = CropOverlay(self.picture, self.texture) + self.overlay.add_overlay(self.crop_overlay) + + 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.overlay.add_overlay(self.crop_overlay.get_bar_widget()) + + 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) + + self.crop_overlay = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + 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() + + 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=out_path, + suffix="_cropped", + ) + + 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, + ) + + self.on_crop_cancel_clicked(btn) + + ''' + * 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()) + 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): + overlay = getattr(self, "filters_overlay", None) + if not overlay: + self.on_filters_cancel_clicked(btn) + return + + css_class = getattr(overlay, "selected_filter", "filter-original") + + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.bake_filter_to_file(in_path, out_path, css_class) + + 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, + ) + + self.on_filters_cancel_clicked(btn) + + ''' + * Drawing Feature * + ''' + def on_drawing_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, "draw_overlay", None): + self.overlay.remove_overlay(self.draw_overlay.get_bar_widget()) + self.draw_overlay = None + + self.draw_overlay = DrawOverlay(self.picture, self.texture, clamp_to_image=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) + + self.overlay.add_overlay(self.draw_overlay) + self.overlay.add_overlay(self.draw_overlay.get_bar_widget()) + self.draw_overlay.queue_draw() + + 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) + + self.overlay.remove_overlay(draw) + self.draw_overlay = None + + self.set_edit_bar_visible(True) + self.zoomable_image.set_zoom_enabled(True) + + def on_drawing_apply_clicked(self, btn=None): + draw = getattr(self, "draw_overlay", None) + if not draw: + self.on_drawing_cancel_clicked(btn) + return + + strokes = getattr(draw, "strokes", None) or [] + if not strokes: + self.on_drawing_cancel_clicked(btn) + return + + def op(in_path: str, out_path: str, overwrite: bool): + return FuriOSMediaTools.rasterize_strokes_to_disk_cairo( + in_path, + strokes, + overwrite=overwrite, + out_path=out_path, + suffix="_drawn", + ) + + 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, + ) + + self.on_drawing_cancel_clicked(btn) + diff --git a/furios_gallery/edit_view/filters_overlay.py b/furios_gallery/edit_view/filters_overlay.py new file mode 100644 index 0000000..2925f71 --- /dev/null +++ b/furios_gallery/edit_view/filters_overlay.py @@ -0,0 +1,140 @@ +# 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) + +FILTERS = [ + ("Original", "filter-original"), + ("B&W", "filter-bw"), + ("Vivid", "filter-vivid"), + ("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-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 = 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=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 = create_cancel_btn(self.on_cancel_clicked) + + apply = create_apply_btn(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) diff --git a/furios_gallery/edit_view/furios_media_tools.py b/furios_gallery/edit_view/furios_media_tools.py new file mode 100644 index 0000000..64cb15b --- /dev/null +++ b/furios_gallery/edit_view/furios_media_tools.py @@ -0,0 +1,585 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 Furi Labs +# +# Authors: +# Joaquin Philco + +import cairo +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") + +from gi.repository import GdkPixbuf, Gdk, GLib + +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 + # 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: + 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) + return True, new_path + except OSError as e: + 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}") + + @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 + + @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 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: + blur = min(blur * 2.2, 10.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: + 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 * + ''' + @staticmethod + def apply_linear_contrast(rgb, contrast, reference_intensity): + r = ((rgb[0] - 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(round(r)), int(round(g)), int(round(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 = ColorSpaceStandards.rgb_to_ycbcr(rgb) + + # Apply contrast ONLY to luma + Y = contrast * (Y - reference_intensity) + reference_intensity + + # YCbCr -> RGB + 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: 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 = ColorSpaceStandards.rgb_to_ycbcr(rgb_img) + Y = Y * brightness + 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, _, _ = ColorSpaceStandards.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 + + @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 = 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) + + @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) + + @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 @ ColorSpaceStandards.SEPIA_MATRIX.T + + 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) diff --git a/furios_gallery/edit_view/ui.py b/furios_gallery/edit_view/ui.py new file mode 100644 index 0000000..921a1cc --- /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=0, + 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 + + 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() diff --git a/furios_gallery/gallery_window.py b/furios_gallery/gallery_window.py index 94d43ec..9399ef3 100644 --- a/furios_gallery/gallery_window.py +++ b/furios_gallery/gallery_window.py @@ -7,18 +7,22 @@ # Jesús Higueras # Luis Garcia +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 -import os +from .edit_view.furios_media_tools import FuriOSMediaTools from .media_view import MediaView +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 ( @@ -28,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_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): @@ -67,28 +70,35 @@ 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_header_btn(self.change_file_name, "text-editor-symbolic", False) + 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) + + # 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) @@ -102,6 +112,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")) @@ -133,35 +147,57 @@ 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) 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) self.create_album_btn.set_visible(True) 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(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) 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: + 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)) + 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) + self.info_btn.set_visible(False) + self.return_btn.set_visible(True) + 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() @@ -223,6 +259,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 +297,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) @@ -438,3 +489,43 @@ 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 + 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) + + def on_new_file_created(self, new_media_path: str): + #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 diff --git a/furios_gallery/grid_view.py b/furios_gallery/grid_view.py index 70a979b..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() @@ -72,7 +73,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): @@ -86,6 +87,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 len(self.app.media_paths) == 1 and 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) @@ -114,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( @@ -122,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) @@ -130,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: @@ -148,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): diff --git a/furios_gallery/image_viewer_widget.py b/furios_gallery/image_viewer_widget.py index 7a6f2fd..fde29a0 100644 --- a/furios_gallery/image_viewer_widget.py +++ b/furios_gallery/image_viewer_widget.py @@ -17,16 +17,46 @@ 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 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 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): + # 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 +91,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() diff --git a/furios_gallery/media_view.py b/furios_gallery/media_view.py index 824d5b0..4c1909f 100644 --- a/furios_gallery/media_view.py +++ b/furios_gallery/media_view.py @@ -80,6 +80,18 @@ 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) + 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) + close_media_options_btn = create_option_button("Cancel", self.on_close_media_options, dialog) media_options.append(close_media_options_btn) @@ -172,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: diff --git a/furios_gallery/media_watcher.py b/furios_gallery/media_watcher.py new file mode 100644 index 0000000..85e4524 --- /dev/null +++ b/furios_gallery/media_watcher.py @@ -0,0 +1,124 @@ +# 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, file_exists_in_database) + +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 not os.path.isfile(path): + return + + 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: + 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 + + # 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) + + # 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)) + 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})") + + 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 diff --git a/furios_gallery/ui.py b/furios_gallery/ui.py index 317cd2b..6c9953b 100644 --- a/furios_gallery/ui.py +++ b/furios_gallery/ui.py @@ -18,42 +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_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) @@ -85,6 +59,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() @@ -168,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) @@ -186,10 +207,45 @@ 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) +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)