Files
pixlit-nix/pixlit.py
2026-05-16 16:08:25 -04:00

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())