#!/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())