commit da90e0da81c09906de1ccbe1e825c3075ede84bf Author: brian Date: Sat May 16 16:08:25 2026 -0400 work in progress diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2f5dd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d709357 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Pixlit — Image Converter + +A clean GTK4/libadwaita desktop application for converting images between formats, with first-class support for HEIC/HEIF files from iPhones and modern cameras. + +## Features + +- **HEIC / HEIF → JPEG, PNG, WebP** (and vice-versa) +- Supports: HEIC, HEIF, JPEG, PNG, WebP, BMP, TIFF, GIF as inputs +- Output formats: **JPEG**, **PNG**, **WebP** +- Quality slider (1–100) with per-format behaviour +- Batch conversion — add as many files as you like +- Drag-and-drop file support +- Output directory picker +- Progress tracking per file +- Native GNOME look via libadwaita + +## Building + +### Quick local build + +```bash +cd pixlit-nix +nix-build +./result/bin/pixlit +``` + +### Installing into your profile + +```bash +nix-env -f . -i +pixlit +``` + +### NixOS system package (flake or configuration.nix) + +```nix +# configuration.nix +environment.systemPackages = [ + (pkgs.callPackage /path/to/pixlit-nix {}) +]; +``` + +## Requirements (handled automatically by Nix) + +| Dependency | Purpose | +|---|---| +| Python 3 | Runtime | +| Pillow | Image decoding/encoding | +| pillow-heif (via Pillow HEIC plugin) | HEIC/HEIF support | +| PyGObject | GTK4 + GLib bindings | +| GTK 4 | Widget toolkit | +| libadwaita | GNOME HIG widgets | +| gobject-introspection | GObject type system | + +## File Layout + +``` +pixlit-nix/ +├── default.nix ← Nix derivation +├── pixlit.py ← Application source (GTK4/libadwaita) +├── pixlit.svg ← Scalable app icon +├── pixlit.png ← 512×512 icon +├── pixlit-512.png ← 512×512 icon +├── pixlit-256.png ← 256×256 icon +├── pixlit-128.png ← 128×128 icon +└── README.md ← This file +``` + +## Usage + +1. Launch Pixlit +2. Click **Choose Files…** or drag images onto the drop zone +3. Select your desired output format (JPEG / PNG / WebP) +4. Adjust the quality slider +5. Choose where to save converted files +6. Click **Convert Images** + +## License + +MIT — see `default.nix` meta block. diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..651a67d --- /dev/null +++ b/default.nix @@ -0,0 +1,4 @@ +# Entry point for local builds: nix-build / nix-env -f . +# The actual derivation lives in pixlit.nix +{ pkgs ? import {} }: +pkgs.callPackage ./pixlit.nix {} diff --git a/pixlit-128.png b/pixlit-128.png new file mode 100644 index 0000000..fce2bc6 Binary files /dev/null and b/pixlit-128.png differ diff --git a/pixlit-256.png b/pixlit-256.png new file mode 100644 index 0000000..6e1436a Binary files /dev/null and b/pixlit-256.png differ diff --git a/pixlit-512.png b/pixlit-512.png new file mode 100644 index 0000000..750d273 Binary files /dev/null and b/pixlit-512.png differ diff --git a/pixlit.nix b/pixlit.nix new file mode 100644 index 0000000..acef92c --- /dev/null +++ b/pixlit.nix @@ -0,0 +1,77 @@ +{ lib +, python3Packages +, gobject-introspection +, wrapGAppsHook4 +, libadwaita +, gtk4 +, gdk-pixbuf +}: + +python3Packages.buildPythonApplication { + pname = "pixlit"; + version = "1.0.0"; + + src = ./.; + + format = "other"; # plain script — no setup.py / pyproject.toml + + # No compile step needed for pure Python + dontBuild = true; + + nativeBuildInputs = [ + gobject-introspection + wrapGAppsHook4 + ]; + + buildInputs = [ + gtk4 + libadwaita + gdk-pixbuf + ]; + + propagatedBuildInputs = with python3Packages; [ + pillow + pillow-heif # registers HEIC/HEIF opener with Pillow + pygobject3 + ]; + + installPhase = '' + runHook preInstall + + # ── Binary ─────────────────────────────────────────────────────────────── + install -Dm755 pixlit.py $out/bin/pixlit + + # ── Icons ──────────────────────────────────────────────────────────────── + install -Dm644 pixlit.svg \ + $out/share/icons/hicolor/scalable/apps/pixlit.svg + + for size in 128 256 512; do + install -Dm644 pixlit-''${size}.png \ + $out/share/icons/hicolor/''${size}x''${size}/apps/pixlit.png + done + + # ── Desktop entry ───────────────────────────────────────────────────────── + install -Dm644 /dev/stdin $out/share/applications/pixlit.desktop < 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()) diff --git a/pixlit.svg b/pixlit.svg new file mode 100644 index 0000000..e78fb4d --- /dev/null +++ b/pixlit.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HEIC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JPG + + + + + PIXLIT + IMAGE CONVERTER + + + + + + +