work in progress
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
result
|
||||
80
README.md
Normal file
80
README.md
Normal file
@@ -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.
|
||||
4
default.nix
Normal file
4
default.nix
Normal file
@@ -0,0 +1,4 @@
|
||||
# Entry point for local builds: nix-build / nix-env -f .
|
||||
# The actual derivation lives in pixlit.nix
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.callPackage ./pixlit.nix {}
|
||||
BIN
pixlit-128.png
Normal file
BIN
pixlit-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
pixlit-256.png
Normal file
BIN
pixlit-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
pixlit-512.png
Normal file
BIN
pixlit-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
77
pixlit.nix
Normal file
77
pixlit.nix
Normal file
@@ -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 <<EOF
|
||||
[Desktop Entry]
|
||||
Name=Pixlit
|
||||
Comment=Convert images between HEIC, JPEG, PNG, and WebP
|
||||
Exec=pixlit
|
||||
Icon=pixlit
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;Photography;
|
||||
MimeType=image/heic;image/heif;image/jpeg;image/png;image/webp;
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Image converter supporting HEIC, JPEG, PNG, and WebP";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "pixlit";
|
||||
maintainers = [];
|
||||
};
|
||||
}
|
||||
717
pixlit.py
Normal file
717
pixlit.py
Normal file
@@ -0,0 +1,717 @@
|
||||
#!/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())
|
||||
104
pixlit.svg
Normal file
104
pixlit.svg
Normal file
@@ -0,0 +1,104 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="arrow-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#4fc3f7"/>
|
||||
<stop offset="100%" stop-color="#7c4dff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="img-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#263238"/>
|
||||
<stop offset="100%" stop-color="#1c2832"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sun-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffca28"/>
|
||||
<stop offset="100%" stop-color="#ff8f00"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Dark rounded background -->
|
||||
<rect width="512" height="512" rx="96" ry="96" fill="url(#bg)"/>
|
||||
|
||||
<!-- Subtle grid lines for depth -->
|
||||
<line x1="0" y1="256" x2="512" y2="256" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="256" y1="0" x2="256" y2="512" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
|
||||
<!-- Left image frame (source) -->
|
||||
<rect x="72" y="148" width="148" height="148" rx="16" fill="url(#img-grad)" stroke="#37474f" stroke-width="2"/>
|
||||
<!-- Mountain landscape inside left frame -->
|
||||
<clipPath id="clip-left">
|
||||
<rect x="72" y="148" width="148" height="148" rx="16"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#clip-left)">
|
||||
<!-- Sky area -->
|
||||
<rect x="72" y="148" width="148" height="90" fill="#1a2744"/>
|
||||
<!-- Mountains -->
|
||||
<polygon points="72,238 112,178 152,238" fill="#2a3f6e"/>
|
||||
<polygon points="110,238 155,168 200,238" fill="#1e3259"/>
|
||||
<polygon points="160,238 190,195 220,238" fill="#2a3f6e"/>
|
||||
<!-- Ground -->
|
||||
<rect x="72" y="238" width="148" height="58" fill="#1b2e1f"/>
|
||||
<!-- HEIC label pill -->
|
||||
<rect x="82" y="268" width="52" height="22" rx="6" fill="#0d1117" fill-opacity="0.85"/>
|
||||
<text x="108" y="283" font-family="monospace" font-size="11" fill="#4fc3f7" text-anchor="middle" font-weight="bold">HEIC</text>
|
||||
<!-- Small sun -->
|
||||
<circle cx="196" cy="168" r="10" fill="url(#sun-grad)" filter="url(#glow)"/>
|
||||
</g>
|
||||
<!-- Frame border glow -->
|
||||
<rect x="72" y="148" width="148" height="148" rx="16" fill="none" stroke="#37474f" stroke-width="2"/>
|
||||
|
||||
<!-- Conversion arrow cluster (center) -->
|
||||
<g filter="url(#glow)" transform="translate(256,222)">
|
||||
<!-- Arrow body -->
|
||||
<rect x="-22" y="-6" width="44" height="12" rx="6" fill="url(#arrow-grad)"/>
|
||||
<!-- Arrowhead -->
|
||||
<polygon points="22,-14 42,0 22,14" fill="url(#arrow-grad)"/>
|
||||
<!-- Small sparkle dots above/below -->
|
||||
<circle cx="0" cy="-22" r="3" fill="#4fc3f7" opacity="0.7"/>
|
||||
<circle cx="10" cy="25" r="2.5" fill="#7c4dff" opacity="0.7"/>
|
||||
<circle cx="-10" cy="26" r="2" fill="#4fc3f7" opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Right image frame (output) -->
|
||||
<rect x="292" y="148" width="148" height="148" rx="16" fill="url(#img-grad)" stroke="#37474f" stroke-width="2"/>
|
||||
<clipPath id="clip-right">
|
||||
<rect x="292" y="148" width="148" height="148" rx="16"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#clip-right)">
|
||||
<!-- Slightly brighter — processed feel -->
|
||||
<rect x="292" y="148" width="148" height="90" fill="#1e2f55"/>
|
||||
<!-- Same landscape, brighter/richer -->
|
||||
<polygon points="292,238 332,178 372,238" fill="#2e4a82"/>
|
||||
<polygon points="330,238 375,168 420,238" fill="#24407a"/>
|
||||
<polygon points="380,238 410,195 440,238" fill="#2e4a82"/>
|
||||
<rect x="292" y="238" width="148" height="58" fill="#1f3624"/>
|
||||
<!-- Sun, brighter -->
|
||||
<circle cx="416" cy="168" r="10" fill="url(#sun-grad)" filter="url(#glow)" opacity="1"/>
|
||||
<!-- Output format label - JPG default -->
|
||||
<rect x="302" y="268" width="48" height="22" rx="6" fill="#0d1117" fill-opacity="0.85"/>
|
||||
<text x="326" y="283" font-family="monospace" font-size="11" fill="#a5d6a7" text-anchor="middle" font-weight="bold">JPG</text>
|
||||
</g>
|
||||
<rect x="292" y="148" width="148" height="148" rx="16" fill="none" stroke="#546e7a" stroke-width="2"/>
|
||||
|
||||
<!-- Bottom label -->
|
||||
<text x="256" y="358" font-family="'Arial Rounded MT Bold', 'Nunito', sans-serif"
|
||||
font-size="38" font-weight="bold" fill="white" text-anchor="middle"
|
||||
letter-spacing="4">PIXLIT</text>
|
||||
<text x="256" y="384" font-family="monospace" font-size="15" fill="#546e7a"
|
||||
text-anchor="middle" letter-spacing="2">IMAGE CONVERTER</text>
|
||||
|
||||
<!-- Corner accent dots -->
|
||||
<circle cx="56" cy="56" r="4" fill="#4fc3f7" opacity="0.3"/>
|
||||
<circle cx="456" cy="56" r="4" fill="#7c4dff" opacity="0.3"/>
|
||||
<circle cx="56" cy="456" r="4" fill="#7c4dff" opacity="0.3"/>
|
||||
<circle cx="456" cy="456" r="4" fill="#4fc3f7" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Reference in New Issue
Block a user