Effector: C++/CMake image effects app

Port of the Python prototype to C++20 with a CMake build:

- effector_core: GUI-free static library (effects, image IO via gdk-pixbuf,
  declarative param specs, registry, portable seeded RNG, thread helper)
- scanlines effect: plans strips from the seeded RNG, then copies pixels in
  parallel with memcpy and doubling horizontal tiling
- gtkmm 4 interface: native file dialogs, widgets generated from the param
  specs, coalesced background renders, preview as a GDK memory texture
- CLI: flags generated from the effect registry, headless rendering with no
  display required
- ctest suite covering the RNG, registry, effect geometry and image round trip

The Python prototype is kept under python-reference/.
This commit is contained in:
hans
2026-09-18 14:28:13 +02:00
commit 925a719da0
36 changed files with 2859 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""effector - a pluggable image effects app."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
import sys
from .cli import main
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Command line entry point: launches the GUI, or renders headlessly."""
from __future__ import annotations
import argparse
import random
import sys
from .effects import DEFAULT_EFFECT, EffectError, get_effect, list_effects, load_sources
from .params import BoolParam, ChoiceParam, FloatParam, IntParam
def parse_size(text: str) -> tuple[int, int]:
for sep in ("x", "X", "*", ","):
if sep in text:
a, _, b = text.partition(sep)
try:
w, h = int(a), int(b)
except ValueError:
break
if w > 0 and h > 0:
return w, h
break
raise argparse.ArgumentTypeError(f"invalid size {text!r}, expected e.g. 1920x1080")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="effector",
description="Image effects app. Runs the GTK interface unless --output is given.",
)
parser.add_argument("images", nargs="*", help="source image files")
parser.add_argument(
"-e", "--effect", default=DEFAULT_EFFECT, choices=sorted(e.name for e in list_effects())
)
parser.add_argument("-o", "--output", help="render headlessly to this file and exit")
parser.add_argument(
"-s", "--size", type=parse_size, default=(1920, 1080), help="canvas size, e.g. 1920x1080"
)
parser.add_argument("--seed", type=int, help="random seed (default: random)")
parser.add_argument(
"--list-effects", action="store_true", help="list available effects and exit"
)
# Effect-specific flags, generated from the declarative param specs. Every
# effect's params share one namespace; defaults stay None so that the
# effect's own default applies when a flag is not given.
seen: set[str] = set()
for cls in list_effects():
group = parser.add_argument_group(f"{cls.title} options")
for p in cls.params:
if p.key in seen:
continue
seen.add(p.key)
help_text = p.help or p.label
if isinstance(p, BoolParam):
group.add_argument(p.flag, dest=p.key, action="store_true", default=None,
help=help_text)
group.add_argument("--no-" + p.flag[2:], dest=p.key, action="store_false",
default=None, help=argparse.SUPPRESS)
elif isinstance(p, IntParam):
group.add_argument(p.flag, dest=p.key, type=int, default=None, help=help_text)
elif isinstance(p, FloatParam):
group.add_argument(p.flag, dest=p.key, type=float, default=None, help=help_text)
elif isinstance(p, ChoiceParam):
group.add_argument(p.flag, dest=p.key, choices=list(p.choices), default=None,
help=help_text)
return parser
def render_headless(args) -> int:
effect = get_effect(args.effect)
images, failures = load_sources(args.images)
for path, message in failures:
print(f"effector: skipping {path}: {message}", file=sys.stderr)
if not images:
print("effector: no usable source images", file=sys.stderr)
return 2
seed = args.seed if args.seed is not None else random.randrange(2**32)
width, height = args.size
values = {p.key: getattr(args, p.key, None) for p in effect.params}
image = effect.render(images, width, height, random.Random(seed), **values)
image.save(args.output)
print(f"{args.output} {width}x{height} effect={effect.name} seed={seed}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.list_effects:
for cls in list_effects():
print(f"{cls.name}\t{cls.title} - {cls.description}")
return 0
try:
if args.output:
return render_headless(args)
from .gui.app import run_gui
return run_gui(args)
except EffectError as exc:
print(f"effector: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,39 @@
"""Effect registry.
To add an effect: drop a module in this package, import its class below and add
it to ``_EFFECT_CLASSES``. The GUI and CLI pick it up automatically.
"""
from __future__ import annotations
from .base import Effect, EffectError, load_sources
from .scanlines import ScanlinesEffect
_EFFECT_CLASSES: tuple[type[Effect], ...] = (ScanlinesEffect,)
REGISTRY: dict[str, type[Effect]] = {cls.name: cls for cls in _EFFECT_CLASSES}
DEFAULT_EFFECT = ScanlinesEffect.name
def list_effects() -> list[type[Effect]]:
return list(_EFFECT_CLASSES)
def get_effect(name: str) -> Effect:
try:
return REGISTRY[name]()
except KeyError:
known = ", ".join(sorted(REGISTRY))
raise EffectError(f"Unknown effect {name!r} (available: {known})") from None
__all__ = [
"REGISTRY",
"DEFAULT_EFFECT",
"Effect",
"EffectError",
"get_effect",
"list_effects",
"load_sources",
]
+75
View File
@@ -0,0 +1,75 @@
"""Effect plug-in contract and source-image loading."""
from __future__ import annotations
import random
from abc import ABC, abstractmethod
from pathlib import Path
from PIL import Image, ImageOps
from ..params import Param
class EffectError(Exception):
"""Raised when an effect cannot produce an image from the given input."""
class Effect(ABC):
"""Base class for all effects.
Subclasses set ``name``/``title``/``description``/``params`` and implement
:meth:`render`. Canvas size and the random source are common to every
effect; ``params`` covers only the effect-specific knobs.
"""
name: str = ""
title: str = ""
description: str = ""
params: tuple[Param, ...] = ()
@classmethod
def defaults(cls) -> dict:
return {p.key: p.default for p in cls.params}
@classmethod
def clean(cls, values: dict) -> dict:
"""Fill in missing keys with defaults and drop unknown ones."""
merged = cls.defaults()
for p in cls.params:
if values.get(p.key) is not None:
merged[p.key] = values[p.key]
return merged
@abstractmethod
def render(
self,
sources: list[Image.Image],
width: int,
height: int,
rng: random.Random,
**values,
) -> Image.Image:
"""Return a new ``width`` x ``height`` RGB image built from ``sources``."""
def load_sources(paths) -> tuple[list[Image.Image], list[tuple[Path, str]]]:
"""Load images as fully decoded RGB.
Returns ``(images, failures)`` where each failure is ``(path, message)``;
unreadable files are skipped rather than aborting the whole batch.
"""
images: list[Image.Image] = []
failures: list[tuple[Path, str]] = []
for raw in paths:
path = Path(raw)
try:
with Image.open(path) as im:
im = ImageOps.exif_transpose(im)
im = im.convert("RGB")
im.load()
im.filename = str(path)
images.append(im)
except Exception as exc: # noqa: BLE001 - report, don't crash the batch
failures.append((path, str(exc)))
return images, failures
@@ -0,0 +1,88 @@
"""Scanlines: fill a canvas with horizontal strips cut from random sources."""
from __future__ import annotations
import random
from PIL import Image
from ..params import BoolParam, IntParam
from .base import Effect, EffectError
class ScanlinesEffect(Effect):
name = "scanlines"
title = "Scanlines"
description = (
"Stacks horizontal strips cut at random positions from the source "
"images, line by line, until the canvas is full."
)
params = (
IntParam(
"strip_height",
"Strip height",
default=8,
minimum=1,
maximum=512,
help="Height in pixels of each extracted line.",
),
IntParam(
"jitter",
"Height jitter",
default=0,
minimum=0,
maximum=256,
help="Random +/- variation added to the strip height (0 = uniform).",
),
BoolParam(
"keep_order",
"Cycle images in order",
default=False,
help="Take strips from the images in order instead of picking randomly.",
),
)
def render(
self,
sources: list[Image.Image],
width: int,
height: int,
rng: random.Random,
**values,
) -> Image.Image:
if not sources:
raise EffectError("No source images.")
if width < 1 or height < 1:
raise EffectError("Canvas size must be at least 1x1 pixels.")
opts = self.clean(values)
strip_height = max(1, int(opts["strip_height"]))
jitter = max(0, int(opts["jitter"]))
keep_order = bool(opts["keep_order"])
canvas = Image.new("RGB", (width, height))
y = 0
index = 0
while y < height:
if keep_order:
src = sources[index % len(sources)]
index += 1
else:
src = rng.choice(sources)
h = strip_height
if jitter:
h += rng.randint(-jitter, jitter)
h = max(1, min(h, height - y, src.height))
sy = rng.randint(0, src.height - h)
sx = rng.randint(0, max(0, src.width - width))
strip = src.crop((sx, sy, min(sx + width, src.width), sy + h))
x = 0
while x < width:
canvas.paste(strip, (x, y))
x += strip.width
y += h
return canvas
+30
View File
@@ -0,0 +1,30 @@
"""GTK application wrapper."""
from __future__ import annotations
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from .window import EffectorWindow # noqa: E402
class EffectorApp(Gtk.Application):
def __init__(self, initial=None):
super().__init__(application_id="org.effector.Effector")
self.initial = initial
def do_activate(self):
window = self.props.active_window
if window is None:
window = EffectorWindow(self, self.initial)
window.present()
def run_gui(args=None) -> int:
# argv is handled by our own parser, so keep GTK out of it.
return EffectorApp(args).run([sys.argv[0]])
+430
View File
@@ -0,0 +1,430 @@
"""Main window: sources, parameters, preview, recreate and save."""
from __future__ import annotations
import random
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GLib, Gio, Gtk, Pango # noqa: E402
from ..effects import EffectError, get_effect, list_effects, load_sources # noqa: E402
from ..params import BoolParam, ChoiceParam, FloatParam, IntParam # noqa: E402
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".ppm")
def pil_to_texture(image) -> Gdk.Texture:
"""Wrap a PIL RGB image as a GDK texture without touching the disk."""
data = GLib.Bytes.new(image.tobytes())
return Gdk.MemoryTexture.new(
image.width, image.height, Gdk.MemoryFormat.R8G8B8, data, image.width * 3
)
class EffectorWindow(Gtk.ApplicationWindow):
def __init__(self, app, initial=None):
super().__init__(application=app, title="Effector")
self.set_default_size(1180, 760)
self.sources: list = [] # loaded PIL images
self.source_paths: list[Path] = []
self.result = None # full-resolution PIL result
self.seed = random.randrange(2**32)
self.param_widgets: dict = {}
self._render_token = 0
self._suspend_render = True
self.effects = list_effects()
self.effect = get_effect(self.effects[0].name)
self._build_ui()
if initial is not None:
self._apply_initial(initial)
self._suspend_render = False
if self.sources:
self.render()
# ---------------------------------------------------------------- UI ---
def _build_ui(self):
header = Gtk.HeaderBar()
self.set_titlebar(header)
add_button = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Add images")
add_button.connect("clicked", self.on_add_images)
header.pack_start(add_button)
self.effect_dropdown = Gtk.DropDown.new_from_strings([e.title for e in self.effects])
self.effect_dropdown.set_tooltip_text("Effect")
self.effect_dropdown.connect("notify::selected", self.on_effect_changed)
header.pack_start(self.effect_dropdown)
self.save_button = Gtk.Button(label="Save As…")
self.save_button.connect("clicked", self.on_save)
self.save_button.set_sensitive(False)
header.pack_end(self.save_button)
self.recreate_button = Gtk.Button(icon_name="view-refresh-symbolic",
tooltip_text="Recreate with a new seed")
self.recreate_button.connect("clicked", self.on_recreate)
header.pack_end(self.recreate_button)
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.set_child(root)
self.banner = Gtk.Label(xalign=0, wrap=True, visible=False)
self.banner.add_css_class("error")
self.banner.set_margin_top(6)
self.banner.set_margin_bottom(6)
self.banner.set_margin_start(12)
self.banner.set_margin_end(12)
root.append(self.banner)
paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, vexpand=True)
paned.set_position(320)
paned.set_shrink_start_child(False)
root.append(paned)
paned.set_start_child(self._build_sidebar())
paned.set_end_child(self._build_preview())
def _build_sidebar(self) -> Gtk.Widget:
sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
sidebar.set_margin_top(12)
sidebar.set_margin_bottom(12)
sidebar.set_margin_start(12)
sidebar.set_margin_end(12)
sidebar.append(self._section_label("Source images"))
self.source_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.source_list.add_css_class("boxed-list")
self.source_list.set_placeholder(
Gtk.Label(label="No images yet", margin_top=18, margin_bottom=18)
)
scroller = Gtk.ScrolledWindow(vexpand=True)
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.set_child(self.source_list)
scroller.set_size_request(-1, 160)
sidebar.append(scroller)
sidebar.append(self._section_label("Canvas"))
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
self.width_spin = Gtk.SpinButton.new_with_range(1, 20000, 1)
self.width_spin.set_value(1920)
self.height_spin = Gtk.SpinButton.new_with_range(1, 20000, 1)
self.height_spin.set_value(1080)
for spin in (self.width_spin, self.height_spin):
spin.set_hexpand(True)
spin.connect("value-changed", self.on_param_changed)
grid.attach(Gtk.Label(label="Width", xalign=0), 0, 0, 1, 1)
grid.attach(self.width_spin, 1, 0, 1, 1)
grid.attach(Gtk.Label(label="Height", xalign=0), 0, 1, 1, 1)
grid.attach(self.height_spin, 1, 1, 1, 1)
sidebar.append(grid)
self.params_label = self._section_label("Effect settings")
sidebar.append(self.params_label)
self.params_box = Gtk.Grid(column_spacing=8, row_spacing=6)
sidebar.append(self.params_box)
self._build_param_widgets()
seed_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
seed_box.append(Gtk.Label(label="Seed", xalign=0))
self.seed_entry = Gtk.Entry(hexpand=True)
self.seed_entry.set_text(str(self.seed))
self.seed_entry.connect("activate", self.on_seed_entered)
seed_box.append(self.seed_entry)
sidebar.append(seed_box)
return sidebar
def _build_preview(self) -> Gtk.Widget:
self.picture = Gtk.Picture()
self.picture.set_can_shrink(True)
self.picture.set_content_fit(Gtk.ContentFit.CONTAIN)
self.placeholder = Gtk.Label(
label="Add source images to start.", wrap=True, justify=Gtk.Justification.CENTER
)
self.placeholder.add_css_class("dim-label")
self.spinner = Gtk.Spinner(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
self.preview_stack = Gtk.Stack()
self.preview_stack.add_named(self.placeholder, "placeholder")
self.preview_stack.add_named(self.picture, "picture")
self.preview_stack.set_visible_child_name("placeholder")
overlay = Gtk.Overlay(hexpand=True, vexpand=True)
overlay.set_child(self.preview_stack)
overlay.add_overlay(self.spinner)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.append(overlay)
self.status = Gtk.Label(xalign=0.5, margin_top=6, margin_bottom=8)
self.status.add_css_class("dim-label")
box.append(self.status)
return box
@staticmethod
def _section_label(text: str) -> Gtk.Label:
label = Gtk.Label(label=text, xalign=0)
label.add_css_class("heading")
return label
def _build_param_widgets(self):
while (child := self.params_box.get_first_child()) is not None:
self.params_box.remove(child)
self.param_widgets.clear()
for row, p in enumerate(self.effect.params):
label = Gtk.Label(label=p.label, xalign=0)
if p.help:
label.set_tooltip_text(p.help)
if isinstance(p, BoolParam):
widget = Gtk.Switch(halign=Gtk.Align.START)
widget.set_active(bool(p.default))
widget.connect("notify::active", self.on_param_changed)
elif isinstance(p, IntParam):
widget = Gtk.SpinButton.new_with_range(p.minimum, p.maximum, p.step)
widget.set_value(p.default)
widget.connect("value-changed", self.on_param_changed)
elif isinstance(p, FloatParam):
widget = Gtk.SpinButton.new_with_range(p.minimum, p.maximum, p.step)
widget.set_digits(p.digits)
widget.set_value(p.default)
widget.connect("value-changed", self.on_param_changed)
elif isinstance(p, ChoiceParam):
widget = Gtk.DropDown.new_from_strings(list(p.choices))
if p.default in p.choices:
widget.set_selected(p.choices.index(p.default))
widget.connect("notify::selected", self.on_param_changed)
else:
continue
widget.set_hexpand(True)
self.params_box.attach(label, 0, row, 1, 1)
self.params_box.attach(widget, 1, row, 1, 1)
self.param_widgets[p.key] = (p, widget)
def _param_values(self) -> dict:
values = {}
for key, (p, widget) in self.param_widgets.items():
if isinstance(p, BoolParam):
values[key] = widget.get_active()
elif isinstance(p, IntParam):
values[key] = int(widget.get_value())
elif isinstance(p, FloatParam):
values[key] = widget.get_value()
elif isinstance(p, ChoiceParam):
values[key] = p.choices[widget.get_selected()]
return values
def _apply_initial(self, args):
if getattr(args, "effect", None):
for i, cls in enumerate(self.effects):
if cls.name == args.effect:
self.effect_dropdown.set_selected(i)
break
if getattr(args, "size", None):
self.width_spin.set_value(args.size[0])
self.height_spin.set_value(args.size[1])
if getattr(args, "seed", None) is not None:
self.seed = int(args.seed)
self.seed_entry.set_text(str(self.seed))
for key, (_p, widget) in self.param_widgets.items():
value = getattr(args, key, None)
if value is None:
continue
if isinstance(widget, Gtk.Switch):
widget.set_active(bool(value))
elif isinstance(widget, Gtk.SpinButton):
widget.set_value(value)
elif isinstance(widget, Gtk.DropDown):
choices = list(self.param_widgets[key][0].choices)
if value in choices:
widget.set_selected(choices.index(value))
if getattr(args, "images", None):
self.add_paths(args.images)
# ------------------------------------------------------------ sources ---
def add_paths(self, paths):
images, failures = load_sources(paths)
loaded_paths = [Path(getattr(im, "filename", "")) for im in images]
self.sources.extend(images)
self.source_paths.extend(loaded_paths)
for path in loaded_paths:
self._append_source_row(path)
if failures:
names = ", ".join(p.name for p, _ in failures)
self.show_error(f"Could not read: {names}")
elif images:
self.clear_error()
if images and not self._suspend_render:
self.render()
def _append_source_row(self, path: Path):
row = Gtk.ListBoxRow()
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.set_margin_top(4)
box.set_margin_bottom(4)
box.set_margin_start(8)
box.set_margin_end(4)
label = Gtk.Label(label=path.name, xalign=0, hexpand=True,
ellipsize=Pango.EllipsizeMode.END)
label.set_tooltip_text(str(path))
box.append(label)
remove = Gtk.Button(icon_name="window-close-symbolic")
remove.add_css_class("flat")
remove.connect("clicked", self.on_remove_source, row)
box.append(remove)
row.set_child(box)
self.source_list.append(row)
def on_remove_source(self, _button, row):
index = row.get_index()
if 0 <= index < len(self.sources):
self.sources.pop(index)
self.source_paths.pop(index)
self.source_list.remove(row)
if self.sources:
self.render()
else:
self.result = None
self.save_button.set_sensitive(False)
self.preview_stack.set_visible_child_name("placeholder")
self.status.set_text("")
def on_add_images(self, _button):
dialog = Gtk.FileDialog(title="Select source images")
image_filter = Gtk.FileFilter()
image_filter.set_name("Images")
for suffix in IMAGE_SUFFIXES:
image_filter.add_pattern("*" + suffix)
image_filter.add_pattern("*" + suffix.upper())
filters = Gio.ListStore.new(Gtk.FileFilter)
filters.append(image_filter)
dialog.set_filters(filters)
dialog.set_default_filter(image_filter)
dialog.open_multiple(self, None, self._on_images_chosen)
def _on_images_chosen(self, dialog, result):
try:
files = dialog.open_multiple_finish(result)
except GLib.Error:
return # cancelled
paths = [files.get_item(i).get_path() for i in range(files.get_n_items())]
self.add_paths([p for p in paths if p])
# ------------------------------------------------------------ rendering ---
def on_effect_changed(self, *_args):
self.effect = get_effect(self.effects[self.effect_dropdown.get_selected()].name)
self._build_param_widgets()
if self.sources:
self.render()
def on_param_changed(self, *_args):
if not self._suspend_render and self.sources:
self.render()
def on_seed_entered(self, entry):
try:
self.seed = int(entry.get_text().strip())
except ValueError:
entry.set_text(str(self.seed))
return
if self.sources:
self.render()
def on_recreate(self, _button):
self.seed = random.randrange(2**32)
self.seed_entry.set_text(str(self.seed))
if self.sources:
self.render()
else:
self.show_error("Add at least one source image first.")
def render(self):
if not self.sources:
return
self._render_token += 1
token = self._render_token
effect = self.effect
sources = list(self.sources)
width = int(self.width_spin.get_value())
height = int(self.height_spin.get_value())
seed = self.seed
values = self._param_values()
self.spinner.start()
self.recreate_button.set_sensitive(False)
def work():
try:
image = effect.render(sources, width, height, random.Random(seed), **values)
GLib.idle_add(self._render_done, token, image, None)
except Exception as exc: # noqa: BLE001 - surfaced in the UI
GLib.idle_add(self._render_done, token, None, exc)
threading.Thread(target=work, daemon=True).start()
def _render_done(self, token, image, error):
if token != self._render_token:
return GLib.SOURCE_REMOVE
self.spinner.stop()
self.recreate_button.set_sensitive(True)
if error is not None:
self.result = None
self.save_button.set_sensitive(False)
self.show_error(str(error) if isinstance(error, EffectError) else f"Render failed: {error}")
return GLib.SOURCE_REMOVE
self.clear_error()
self.result = image
self.picture.set_paintable(pil_to_texture(image))
self.preview_stack.set_visible_child_name("picture")
self.save_button.set_sensitive(True)
self.status.set_text(
f"{self.effect.title} · {image.width}×{image.height} · "
f"{len(self.sources)} source{'s' if len(self.sources) != 1 else ''} · seed {self.seed}"
)
return GLib.SOURCE_REMOVE
# --------------------------------------------------------------- saving ---
def on_save(self, _button):
if self.result is None:
return
dialog = Gtk.FileDialog(title="Save image")
dialog.set_initial_name(f"effector-{self.effect.name}-{self.seed}.png")
dialog.save(self, None, self._on_save_chosen)
def _on_save_chosen(self, dialog, result):
try:
file = dialog.save_finish(result)
except GLib.Error:
return # cancelled
path = Path(file.get_path())
if not path.suffix:
path = path.with_suffix(".png")
try:
image = self.result
if path.suffix.lower() in (".jpg", ".jpeg"):
image.save(path, quality=95)
else:
image.save(path)
except Exception as exc: # noqa: BLE001 - surfaced in the UI
self.show_error(f"Could not save: {exc}")
return
self.clear_error()
self.status.set_text(f"Saved {path}")
# --------------------------------------------------------------- errors ---
def show_error(self, message: str):
self.banner.set_text(message)
self.banner.set_visible(True)
def clear_error(self):
self.banner.set_visible(False)
+50
View File
@@ -0,0 +1,50 @@
"""Declarative parameter specs.
An effect describes its knobs with these small dataclasses; the GUI turns them
into widgets and the CLI turns them into argparse flags, so neither has to know
anything about a particular effect.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Param:
key: str
label: str
default: object
help: str = ""
@property
def flag(self) -> str:
return "--" + self.key.replace("_", "-")
@dataclass(frozen=True)
class IntParam(Param):
default: int = 0
minimum: int = 0
maximum: int = 1_000_000
step: int = 1
@dataclass(frozen=True)
class FloatParam(Param):
default: float = 0.0
minimum: float = 0.0
maximum: float = 1.0
step: float = 0.05
digits: int = 2
@dataclass(frozen=True)
class BoolParam(Param):
default: bool = False
@dataclass(frozen=True)
class ChoiceParam(Param):
default: str = ""
choices: tuple[str, ...] = field(default_factory=tuple)
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "effector"
version = "0.1.0"
description = "A pluggable image effects app with a GTK4 interface and a CLI"
requires-python = ">=3.10"
dependencies = ["pillow>=9", "pygobject>=3.42"]
[project.scripts]
effector = "effector.cli:main"
[tool.setuptools.packages.find]
include = ["effector*"]