718 lines
26 KiB
Python
718 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Pixlit — Image format converter with HEIC/HEIF, WebP, JPEG, PNG support.
|
|
GTK4 + libadwaita UI. Two-pane layout: controls left, thumbnail grid right.
|
|
"""
|
|
|
|
import gi
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Adw", "1")
|
|
|
|
from gi.repository import Gtk, Adw, Gio, GLib, Gdk, GdkPixbuf
|
|
import os
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
|
|
# ── HEIC support ─────────────────────────────────────────────────────────────
|
|
try:
|
|
import pillow_heif
|
|
pillow_heif.register_heif_opener()
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
# ── Conversion logic ──────────────────────────────────────────────────────────
|
|
|
|
FORMAT_EXT = {"JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp"}
|
|
|
|
SUPPORTED_INPUT = {
|
|
".heic", ".heif",
|
|
".jpg", ".jpeg",
|
|
".png", ".webp",
|
|
".bmp", ".tiff", ".tif", ".gif",
|
|
}
|
|
|
|
|
|
def convert_image(src_path: str, dst_path: str, fmt: str, quality: int) -> None:
|
|
from PIL import Image
|
|
img = Image.open(src_path)
|
|
exif = img.info.get("exif", b"")
|
|
|
|
if fmt == "JPEG" and img.mode in ("RGBA", "LA", "P"):
|
|
if img.mode == "P":
|
|
img = img.convert("RGBA")
|
|
bg = Image.new("RGB", img.size, (255, 255, 255))
|
|
bg.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
|
|
img = bg
|
|
elif fmt in ("PNG", "WEBP") and img.mode == "P":
|
|
img = img.convert("RGBA")
|
|
|
|
kwargs = {}
|
|
if fmt == "JPEG":
|
|
kwargs = {"quality": quality, "optimize": True}
|
|
if exif:
|
|
kwargs["exif"] = exif
|
|
elif fmt == "WEBP":
|
|
kwargs = {"quality": quality, "method": 6}
|
|
elif fmt == "PNG":
|
|
kwargs = {"compress_level": max(0, min(9, 9 - round((quality / 100) * 9)))}
|
|
|
|
img.save(dst_path, format=fmt, **kwargs)
|
|
|
|
|
|
def build_output_path(src: str, out_dir: str, fmt: str) -> str:
|
|
stem = Path(src).stem
|
|
ext = FORMAT_EXT[fmt]
|
|
candidate = os.path.join(out_dir, stem + ext)
|
|
counter = 1
|
|
while os.path.exists(candidate):
|
|
candidate = os.path.join(out_dir, f"{stem}_{counter}{ext}")
|
|
counter += 1
|
|
return candidate
|
|
|
|
|
|
def load_thumbnail_pixbuf(path: str, size: int = 180):
|
|
"""Load and scale image to a square thumbnail. Returns None on failure."""
|
|
try:
|
|
from PIL import Image
|
|
img = Image.open(path)
|
|
img.thumbnail((size, size), Image.LANCZOS)
|
|
if img.mode != "RGBA":
|
|
img = img.convert("RGBA")
|
|
data = img.tobytes()
|
|
w, h = img.size
|
|
return GdkPixbuf.Pixbuf.new_from_data(
|
|
data, GdkPixbuf.Colorspace.RGB, True, 8, w, h, w * 4,
|
|
)
|
|
except Exception as e:
|
|
print(f"[pixlit] thumbnail failed for {path}: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def load_preview_pixbuf(path: str, max_size: int = 1200):
|
|
"""Load image scaled to fit max_size for the preview dialog."""
|
|
try:
|
|
from PIL import Image
|
|
img = Image.open(path)
|
|
img.thumbnail((max_size, max_size), Image.LANCZOS)
|
|
if img.mode != "RGBA":
|
|
img = img.convert("RGBA")
|
|
data = img.tobytes()
|
|
w, h = img.size
|
|
return GdkPixbuf.Pixbuf.new_from_data(
|
|
data, GdkPixbuf.Colorspace.RGB, True, 8, w, h, w * 4,
|
|
)
|
|
except Exception as e:
|
|
print(f"[pixlit] preview failed for {path}: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
# ── Thumbnail card widget ─────────────────────────────────────────────────────
|
|
|
|
THUMB_SIZE = 96
|
|
|
|
class ThumbnailCard(Gtk.Box):
|
|
"""A card showing a thumbnail + filename + status, clickable for preview."""
|
|
|
|
def __init__(self, path: str, on_remove, on_preview):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
self.path = path
|
|
self._on_preview = on_preview
|
|
|
|
self.add_css_class("card")
|
|
self.set_size_request(THUMB_SIZE + 24, -1)
|
|
|
|
# Spinner placeholder
|
|
self._spinner = Gtk.Spinner()
|
|
self._spinner.start()
|
|
self._spinner.set_size_request(THUMB_SIZE, THUMB_SIZE)
|
|
self._spinner.set_margin_top(8)
|
|
self._spinner.set_margin_start(8)
|
|
self._spinner.set_margin_end(8)
|
|
self.append(self._spinner)
|
|
|
|
# Picture (swapped in after load)
|
|
self._picture = Gtk.Picture()
|
|
self._picture.set_size_request(THUMB_SIZE, THUMB_SIZE)
|
|
self._picture.set_content_fit(Gtk.ContentFit.COVER)
|
|
self._picture.set_can_shrink(True)
|
|
self._picture.set_margin_top(8)
|
|
self._picture.set_margin_start(8)
|
|
self._picture.set_margin_end(8)
|
|
|
|
# Filename
|
|
name_lbl = Gtk.Label(label=Path(path).name)
|
|
name_lbl.set_ellipsize(3)
|
|
name_lbl.set_max_width_chars(14)
|
|
name_lbl.add_css_class("caption")
|
|
name_lbl.set_margin_top(4)
|
|
name_lbl.set_margin_start(6)
|
|
name_lbl.set_margin_end(6)
|
|
self.append(name_lbl)
|
|
|
|
# Status
|
|
self.status_lbl = Gtk.Label(label="")
|
|
self.status_lbl.add_css_class("caption")
|
|
self.status_lbl.set_margin_bottom(2)
|
|
self.append(self.status_lbl)
|
|
|
|
# Remove button
|
|
remove_btn = Gtk.Button(icon_name="list-remove-symbolic")
|
|
remove_btn.add_css_class("flat")
|
|
remove_btn.add_css_class("circular")
|
|
remove_btn.set_halign(Gtk.Align.END)
|
|
remove_btn.set_margin_end(4)
|
|
remove_btn.set_margin_bottom(4)
|
|
remove_btn.connect("clicked", lambda _: on_remove(self))
|
|
self.append(remove_btn)
|
|
|
|
# Click to preview (on the whole card)
|
|
click = Gtk.GestureClick()
|
|
click.connect("released", self._on_click)
|
|
self.add_controller(click)
|
|
|
|
threading.Thread(target=self._load_thumb, daemon=True).start()
|
|
|
|
def _load_thumb(self):
|
|
pb = load_thumbnail_pixbuf(self.path, THUMB_SIZE)
|
|
GLib.idle_add(self._set_thumb, pb)
|
|
|
|
def _set_thumb(self, pb):
|
|
self.remove(self._spinner)
|
|
if pb:
|
|
self._picture.set_pixbuf(pb)
|
|
else:
|
|
self._picture.set_icon_name("image-missing")
|
|
# Insert picture at position 0
|
|
first = self.get_first_child()
|
|
self.prepend(self._picture)
|
|
|
|
def _on_click(self, gesture, n_press, x, y):
|
|
self._on_preview(self.path)
|
|
|
|
def set_status(self, text: str, success: bool):
|
|
self.status_lbl.set_label(text)
|
|
css = "success" if success else "error"
|
|
other = "error" if success else "success"
|
|
self.status_lbl.remove_css_class(other)
|
|
self.status_lbl.add_css_class(css)
|
|
|
|
|
|
# ── Preview dialog ────────────────────────────────────────────────────────────
|
|
|
|
class PreviewDialog(Adw.Dialog):
|
|
def __init__(self, path: str):
|
|
super().__init__()
|
|
self.set_title(Path(path).name)
|
|
self.set_content_width(820)
|
|
self.set_content_height(680)
|
|
self.set_follows_content_size(False)
|
|
|
|
toolbar_view = Adw.ToolbarView()
|
|
self.set_child(toolbar_view)
|
|
|
|
header = Adw.HeaderBar()
|
|
toolbar_view.add_top_bar(header)
|
|
|
|
try:
|
|
stat = os.stat(path)
|
|
size_kb = stat.st_size / 1024
|
|
size_str = f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB"
|
|
except OSError:
|
|
size_str = ""
|
|
|
|
header.set_title_widget(Adw.WindowTitle(
|
|
title=Path(path).name,
|
|
subtitle=f"{Path(path).suffix.upper().lstrip('.')} · {size_str}"
|
|
))
|
|
|
|
scroll = Gtk.ScrolledWindow()
|
|
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
scroll.set_vexpand(True)
|
|
scroll.set_hexpand(True)
|
|
toolbar_view.set_content(scroll)
|
|
|
|
self._stack = Gtk.Stack()
|
|
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
|
scroll.set_child(self._stack)
|
|
|
|
spinner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
spinner_box.set_valign(Gtk.Align.CENTER)
|
|
spinner_box.set_halign(Gtk.Align.CENTER)
|
|
spinner_box.set_vexpand(True)
|
|
spinner_box.set_hexpand(True)
|
|
sp = Gtk.Spinner()
|
|
sp.start()
|
|
sp.set_size_request(48, 48)
|
|
spinner_box.append(sp)
|
|
self._stack.add_named(spinner_box, "loading")
|
|
|
|
self._picture = Gtk.Picture()
|
|
self._picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
|
self._picture.set_can_shrink(True)
|
|
self._picture.set_vexpand(True)
|
|
self._picture.set_hexpand(True)
|
|
self._stack.add_named(self._picture, "image")
|
|
self._stack.set_visible_child_name("loading")
|
|
|
|
threading.Thread(target=self._load, args=(path,), daemon=True).start()
|
|
|
|
def _load(self, path):
|
|
pb = load_preview_pixbuf(path, 1200)
|
|
GLib.idle_add(self._show, pb)
|
|
|
|
def _show(self, pb):
|
|
if pb:
|
|
self._picture.set_pixbuf(pb)
|
|
else:
|
|
self._picture.set_icon_name("image-missing")
|
|
self._stack.set_visible_child_name("image")
|
|
|
|
|
|
# ── Main window ───────────────────────────────────────────────────────────────
|
|
|
|
class PixlitWindow(Adw.ApplicationWindow):
|
|
def __init__(self, app):
|
|
super().__init__(application=app)
|
|
self.set_title("Pixlit")
|
|
self.set_default_size(960, 620)
|
|
|
|
self._files: list[ThumbnailCard] = []
|
|
self._out_dir: str = str(Path.home() / "Pictures")
|
|
self._converting = False
|
|
|
|
self._build_ui()
|
|
|
|
def _build_ui(self):
|
|
toolbar_view = Adw.ToolbarView()
|
|
self.set_content(toolbar_view)
|
|
|
|
header = Adw.HeaderBar()
|
|
header.set_centering_policy(Adw.CenteringPolicy.STRICT)
|
|
header.set_title_widget(Adw.WindowTitle(title="Pixlit", subtitle="Image Converter"))
|
|
about_btn = Gtk.Button(icon_name="help-about-symbolic")
|
|
about_btn.add_css_class("flat")
|
|
about_btn.connect("clicked", self._show_about)
|
|
header.pack_end(about_btn)
|
|
toolbar_view.add_top_bar(header)
|
|
|
|
paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
|
paned.set_position(340)
|
|
paned.set_shrink_start_child(False)
|
|
paned.set_shrink_end_child(False)
|
|
toolbar_view.set_content(paned)
|
|
|
|
paned.set_start_child(self._build_left_panel())
|
|
paned.set_end_child(self._build_right_panel())
|
|
|
|
def _build_left_panel(self):
|
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
outer.set_size_request(300, -1)
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
box.set_margin_top(16)
|
|
box.set_margin_bottom(16)
|
|
box.set_margin_start(16)
|
|
box.set_margin_end(12)
|
|
box.set_vexpand(True)
|
|
outer.append(box)
|
|
|
|
box.append(self._build_drop_zone())
|
|
box.append(self._build_options_section())
|
|
box.append(self._build_output_section())
|
|
box.append(self._build_convert_button())
|
|
box.append(self._build_progress_section())
|
|
|
|
return outer
|
|
|
|
def _build_right_panel(self):
|
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
|
|
panel_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
panel_header.set_margin_top(12)
|
|
panel_header.set_margin_bottom(8)
|
|
panel_header.set_margin_start(12)
|
|
panel_header.set_margin_end(12)
|
|
|
|
self._file_count_lbl = Gtk.Label(label="No images added", xalign=0)
|
|
self._file_count_lbl.set_hexpand(True)
|
|
self._file_count_lbl.add_css_class("heading")
|
|
panel_header.append(self._file_count_lbl)
|
|
|
|
self._clear_btn = Gtk.Button(label="Clear All")
|
|
self._clear_btn.add_css_class("flat")
|
|
self._clear_btn.add_css_class("destructive-action")
|
|
self._clear_btn.connect("clicked", self._clear_all_files)
|
|
self._clear_btn.set_visible(False)
|
|
panel_header.append(self._clear_btn)
|
|
|
|
outer.append(panel_header)
|
|
outer.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
|
|
# Stack: empty state vs grid
|
|
self._right_stack = Gtk.Stack()
|
|
self._right_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
|
self._right_stack.set_vexpand(True)
|
|
|
|
empty_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
empty_box.set_vexpand(True)
|
|
empty_lbl = Gtk.Label(
|
|
label="Add images using the panel on the left\nor drag and drop them here"
|
|
)
|
|
empty_lbl.add_css_class("dim-label")
|
|
empty_lbl.set_justify(Gtk.Justification.CENTER)
|
|
empty_lbl.set_valign(Gtk.Align.CENTER)
|
|
empty_lbl.set_halign(Gtk.Align.CENTER)
|
|
empty_lbl.set_vexpand(True)
|
|
empty_box.append(empty_lbl)
|
|
self._right_stack.add_named(empty_box, "empty")
|
|
|
|
scroll = Gtk.ScrolledWindow()
|
|
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
scroll.set_vexpand(True)
|
|
|
|
self._flowbox = Gtk.FlowBox()
|
|
self._flowbox.set_valign(Gtk.Align.START)
|
|
self._flowbox.set_homogeneous(True)
|
|
self._flowbox.set_column_spacing(8)
|
|
self._flowbox.set_row_spacing(8)
|
|
self._flowbox.set_margin_top(12)
|
|
self._flowbox.set_margin_bottom(12)
|
|
self._flowbox.set_margin_start(12)
|
|
self._flowbox.set_margin_end(12)
|
|
self._flowbox.set_min_children_per_line(2)
|
|
self._flowbox.set_max_children_per_line(8)
|
|
self._flowbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
|
scroll.set_child(self._flowbox)
|
|
self._right_stack.add_named(scroll, "grid")
|
|
|
|
self._right_stack.set_visible_child_name("empty")
|
|
outer.append(self._right_stack)
|
|
|
|
# Drag-and-drop on right panel
|
|
drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY)
|
|
drop_target.connect("drop", self._on_drop)
|
|
outer.add_controller(drop_target)
|
|
|
|
return outer
|
|
|
|
def _build_drop_zone(self):
|
|
group = Adw.PreferencesGroup(title="Add Images")
|
|
|
|
drop_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
drop_box.add_css_class("card")
|
|
drop_box.set_margin_top(4)
|
|
|
|
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
inner.set_margin_top(16)
|
|
inner.set_margin_bottom(16)
|
|
inner.set_margin_start(16)
|
|
inner.set_margin_end(16)
|
|
drop_box.append(inner)
|
|
|
|
icon = Gtk.Image.new_from_icon_name("insert-image-symbolic")
|
|
icon.set_pixel_size(32)
|
|
icon.add_css_class("dim-label")
|
|
inner.append(icon)
|
|
|
|
lbl = Gtk.Label(label="Drop images here")
|
|
lbl.add_css_class("body")
|
|
lbl.set_halign(Gtk.Align.CENTER)
|
|
inner.append(lbl)
|
|
|
|
btn = Gtk.Button(label="Choose Files…")
|
|
btn.add_css_class("suggested-action")
|
|
btn.add_css_class("pill")
|
|
btn.set_halign(Gtk.Align.CENTER)
|
|
btn.connect("clicked", self._pick_files)
|
|
inner.append(btn)
|
|
|
|
drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY)
|
|
drop_target.connect("drop", self._on_drop)
|
|
drop_box.add_controller(drop_target)
|
|
|
|
group.add(drop_box)
|
|
return group
|
|
|
|
def _build_options_section(self):
|
|
group = Adw.PreferencesGroup(title="Options")
|
|
|
|
fmt_row = Adw.ActionRow(title="Output Format")
|
|
self._fmt_combo = Gtk.DropDown.new_from_strings(["JPEG", "PNG", "WebP"])
|
|
self._fmt_combo.set_valign(Gtk.Align.CENTER)
|
|
self._fmt_combo.connect("notify::selected", self._on_format_changed)
|
|
fmt_row.add_suffix(self._fmt_combo)
|
|
fmt_row.set_activatable_widget(self._fmt_combo)
|
|
group.add(fmt_row)
|
|
|
|
quality_row = Adw.ActionRow(title="Quality")
|
|
quality_row.set_subtitle("Higher = better quality, larger file")
|
|
q_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
q_box.set_valign(Gtk.Align.CENTER)
|
|
q_box.set_size_request(180, -1)
|
|
|
|
self._quality_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 100, 1)
|
|
self._quality_scale.set_value(85)
|
|
self._quality_scale.set_hexpand(True)
|
|
self._quality_scale.set_draw_value(False)
|
|
self._quality_scale.connect("value-changed", self._on_quality_changed)
|
|
|
|
self._quality_lbl = Gtk.Label(label="85")
|
|
self._quality_lbl.set_width_chars(3)
|
|
|
|
q_box.append(self._quality_scale)
|
|
q_box.append(self._quality_lbl)
|
|
quality_row.add_suffix(q_box)
|
|
group.add(quality_row)
|
|
|
|
self._png_note_row = Adw.ActionRow(title="PNG is lossless")
|
|
self._png_note_row.set_subtitle("Slider controls compression speed only")
|
|
self._png_note_row.add_prefix(Gtk.Image.new_from_icon_name("dialog-information-symbolic"))
|
|
self._png_note_row.set_visible(False)
|
|
group.add(self._png_note_row)
|
|
|
|
return group
|
|
|
|
def _build_output_section(self):
|
|
group = Adw.PreferencesGroup(title="Output")
|
|
self._out_row = Adw.ActionRow(title="Save To")
|
|
self._out_row.set_subtitle(self._out_dir)
|
|
btn = Gtk.Button(label="Choose…")
|
|
btn.add_css_class("flat")
|
|
btn.set_valign(Gtk.Align.CENTER)
|
|
btn.connect("clicked", self._pick_output_dir)
|
|
self._out_row.add_suffix(btn)
|
|
group.add(self._out_row)
|
|
return group
|
|
|
|
def _build_convert_button(self):
|
|
box = Gtk.Box()
|
|
box.set_margin_top(4)
|
|
self._convert_btn = Gtk.Button(label="Convert Images")
|
|
self._convert_btn.add_css_class("suggested-action")
|
|
self._convert_btn.add_css_class("pill")
|
|
self._convert_btn.set_hexpand(True)
|
|
self._convert_btn.set_size_request(-1, 44)
|
|
self._convert_btn.connect("clicked", self._start_conversion)
|
|
self._convert_btn.set_sensitive(False)
|
|
box.append(self._convert_btn)
|
|
return box
|
|
|
|
def _build_progress_section(self):
|
|
self._progress_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
self._progress_box.set_visible(False)
|
|
self._progress_bar = Gtk.ProgressBar()
|
|
self._progress_bar.set_show_text(True)
|
|
self._progress_box.append(self._progress_bar)
|
|
self._result_lbl = Gtk.Label(label="")
|
|
self._result_lbl.set_halign(Gtk.Align.CENTER)
|
|
self._result_lbl.add_css_class("caption")
|
|
self._progress_box.append(self._result_lbl)
|
|
return self._progress_box
|
|
|
|
# ── Signals & logic ───────────────────────────────────────────────────────
|
|
|
|
def _on_format_changed(self, combo, _param):
|
|
fmt = ["JPEG", "PNG", "WEBP"][combo.get_selected()]
|
|
self._png_note_row.set_visible(fmt == "PNG")
|
|
|
|
def _on_quality_changed(self, scale):
|
|
self._quality_lbl.set_label(str(int(scale.get_value())))
|
|
|
|
def _pick_files(self, _btn):
|
|
dialog = Gtk.FileDialog()
|
|
dialog.set_title("Choose Images")
|
|
dialog.set_modal(True)
|
|
filters = Gio.ListStore.new(Gtk.FileFilter)
|
|
f = Gtk.FileFilter()
|
|
f.set_name("Images (HEIC, JPEG, PNG, WebP…)")
|
|
for pat in ["*.heic", "*.heif", "*.jpg", "*.jpeg", "*.png", "*.webp",
|
|
"*.bmp", "*.tiff", "*.tif", "*.gif",
|
|
"*.HEIC", "*.HEIF", "*.JPG", "*.JPEG", "*.PNG", "*.WEBP"]:
|
|
f.add_pattern(pat)
|
|
filters.append(f)
|
|
af = Gtk.FileFilter()
|
|
af.set_name("All Files")
|
|
af.add_pattern("*")
|
|
filters.append(af)
|
|
dialog.set_filters(filters)
|
|
dialog.open_multiple(self, None, self._on_files_picked)
|
|
|
|
def _on_files_picked(self, dialog, result):
|
|
try:
|
|
files = dialog.open_multiple_finish(result)
|
|
except GLib.Error:
|
|
return
|
|
if files:
|
|
for i in range(files.get_n_items()):
|
|
gf = files.get_item(i)
|
|
if gf and gf.get_path():
|
|
self._add_file(gf.get_path())
|
|
|
|
def _on_drop(self, target, value, _x, _y):
|
|
if isinstance(value, Gio.File) and value.get_path():
|
|
self._add_file(value.get_path())
|
|
return True
|
|
return False
|
|
|
|
def _add_file(self, path: str):
|
|
if Path(path).suffix.lower() not in SUPPORTED_INPUT:
|
|
self._show_toast(f"Unsupported format: {Path(path).name}")
|
|
return
|
|
if any(c.path == path for c in self._files):
|
|
return
|
|
card = ThumbnailCard(path, self._remove_card, self._open_preview)
|
|
self._flowbox.append(card)
|
|
self._files.append(card)
|
|
self._update_ui()
|
|
|
|
def _remove_card(self, card: ThumbnailCard):
|
|
self._files.remove(card)
|
|
parent = card.get_parent()
|
|
self._flowbox.remove(parent)
|
|
self._update_ui()
|
|
|
|
def _clear_all_files(self, _btn=None):
|
|
for card in list(self._files):
|
|
self._flowbox.remove(card.get_parent())
|
|
self._files.clear()
|
|
self._update_ui()
|
|
|
|
def _update_ui(self):
|
|
n = len(self._files)
|
|
if n == 0:
|
|
self._file_count_lbl.set_label("No images added")
|
|
self._right_stack.set_visible_child_name("empty")
|
|
self._clear_btn.set_visible(False)
|
|
else:
|
|
self._file_count_lbl.set_label(f"{n} image{'s' if n != 1 else ''} queued")
|
|
self._right_stack.set_visible_child_name("grid")
|
|
self._clear_btn.set_visible(True)
|
|
self._convert_btn.set_sensitive(n > 0 and not self._converting)
|
|
|
|
def _open_preview(self, path: str):
|
|
PreviewDialog(path).present(self)
|
|
|
|
def _pick_output_dir(self, _btn):
|
|
dialog = Gtk.FileDialog()
|
|
dialog.set_title("Choose Output Directory")
|
|
dialog.set_modal(True)
|
|
dialog.select_folder(self, None, self._on_output_dir_picked)
|
|
|
|
def _on_output_dir_picked(self, dialog, result):
|
|
try:
|
|
folder = dialog.select_folder_finish(result)
|
|
except GLib.Error:
|
|
return
|
|
if folder:
|
|
self._out_dir = folder.get_path()
|
|
self._out_row.set_subtitle(self._out_dir)
|
|
|
|
def _get_fmt(self) -> str:
|
|
return ["JPEG", "PNG", "WEBP"][self._fmt_combo.get_selected()]
|
|
|
|
def _start_conversion(self, _btn):
|
|
if self._converting or not self._files:
|
|
return
|
|
self._converting = True
|
|
self._convert_btn.set_sensitive(False)
|
|
self._progress_box.set_visible(True)
|
|
self._progress_bar.set_fraction(0)
|
|
self._progress_bar.set_text("Starting…")
|
|
self._result_lbl.set_label("")
|
|
for card in self._files:
|
|
card.set_status("", True)
|
|
|
|
fmt = self._get_fmt()
|
|
quality = int(self._quality_scale.get_value())
|
|
snapshot = list(self._files)
|
|
out_dir = self._out_dir
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
threading.Thread(
|
|
target=self._conversion_worker,
|
|
args=(snapshot, fmt, quality, out_dir),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _conversion_worker(self, cards, fmt, quality, out_dir):
|
|
total = len(cards)
|
|
ok = fail = 0
|
|
for i, card in enumerate(cards):
|
|
GLib.idle_add(self._progress_bar.set_fraction, i / total)
|
|
GLib.idle_add(self._progress_bar.set_text, f"{i}/{total} converted")
|
|
try:
|
|
dst = build_output_path(card.path, out_dir, fmt)
|
|
convert_image(card.path, dst, fmt, quality)
|
|
GLib.idle_add(card.set_status, "✓ Done", True)
|
|
ok += 1
|
|
except Exception as e:
|
|
GLib.idle_add(card.set_status, "✗ Failed", False)
|
|
print(f"[pixlit] error converting {card.path}: {e}", file=sys.stderr)
|
|
fail += 1
|
|
|
|
GLib.idle_add(self._conversion_done, ok, fail, total)
|
|
|
|
def _conversion_done(self, ok, fail, total):
|
|
self._progress_bar.set_fraction(1.0)
|
|
self._progress_bar.set_text(f"Done — {ok}/{total} converted")
|
|
if fail == 0:
|
|
self._result_lbl.set_label(f"All {ok} image{'s' if ok != 1 else ''} saved")
|
|
self._result_lbl.remove_css_class("error")
|
|
self._result_lbl.add_css_class("success")
|
|
else:
|
|
self._result_lbl.set_label(f"{ok} succeeded, {fail} failed")
|
|
self._result_lbl.remove_css_class("success")
|
|
self._result_lbl.add_css_class("error")
|
|
self._converting = False
|
|
self._convert_btn.set_sensitive(len(self._files) > 0)
|
|
self._show_toast(f"Converted {ok} of {total} images → {self._out_dir}")
|
|
|
|
def _show_toast(self, message: str):
|
|
toast = Adw.Toast(title=message)
|
|
toast.set_timeout(4)
|
|
overlay = getattr(self, "_toast_overlay", None)
|
|
if overlay:
|
|
overlay.add_toast(toast)
|
|
|
|
def _show_about(self, _btn):
|
|
about = Adw.AboutDialog()
|
|
about.set_application_name("Pixlit")
|
|
about.set_version("1.0.0")
|
|
about.set_developer_name("Pixlit Contributors")
|
|
about.set_license_type(Gtk.License.MIT_X11)
|
|
about.set_comments("Convert images between HEIC, JPEG, PNG, and WebP formats")
|
|
about.set_application_icon("pixlit")
|
|
about.present(self)
|
|
|
|
|
|
# ── Application ───────────────────────────────────────────────────────────────
|
|
|
|
class PixlitApp(Adw.Application):
|
|
def __init__(self):
|
|
super().__init__(
|
|
application_id="io.github.pixlit",
|
|
flags=Gio.ApplicationFlags.FLAGS_NONE,
|
|
)
|
|
self.connect("activate", self._on_activate)
|
|
|
|
def _on_activate(self, app):
|
|
win = PixlitWindow(app)
|
|
toast_overlay = Adw.ToastOverlay()
|
|
win._toast_overlay = toast_overlay
|
|
content = win.get_content()
|
|
win.set_content(toast_overlay)
|
|
toast_overlay.set_child(content)
|
|
win.present()
|
|
|
|
|
|
def main():
|
|
app = PixlitApp()
|
|
return app.run(sys.argv)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|