feat: Implement initial Sprite Editor application with basic UI components
- Added main application class `SpriteEditorApp` with layout setup. - Created menu bar with placeholder options. - Implemented `BottomToolbarView`, `LeftSidebarView`, `MainEditorView`, `RightSidebarView` with basic UI elements. - Added dialog classes for `ExportSettingsDialog` and `FlipbookCreatorDialog` to manage export settings and flipbook creation. - Introduced support for loading and displaying images in the flipbook creator. - Established a structure for handling sprite sheet composition and preview rendering.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .export_settings_dialog import ExportSettings, ExportSettingsDialog
|
||||
from .flipbook_creator_dialog import FlipbookCreatorDialog
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from dataclasses import dataclass
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportSettings:
|
||||
orientation: str = "horizontal"
|
||||
spacing: int = 0
|
||||
margin: int = 0
|
||||
rows: int = 0
|
||||
columns: int = 0
|
||||
output_name: str = "flipbook.png"
|
||||
|
||||
|
||||
class ExportSettingsDialog(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Misc, initial_settings: ExportSettings | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.title("Default Export Settings")
|
||||
self.resizable(False, False)
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
self.result: ExportSettings | None = None
|
||||
|
||||
settings = initial_settings or ExportSettings()
|
||||
self.orientation_var = tk.StringVar(value=settings.orientation)
|
||||
self.spacing_var = tk.StringVar(value=str(settings.spacing))
|
||||
self.margin_var = tk.StringVar(value=str(settings.margin))
|
||||
self.rows_var = tk.StringVar(value=str(settings.rows))
|
||||
self.columns_var = tk.StringVar(value=str(settings.columns))
|
||||
self.output_name_var = tk.StringVar(value=settings.output_name)
|
||||
|
||||
container = ttk.Frame(self, padding=10)
|
||||
container.grid(row=0, column=0, sticky="nsew")
|
||||
container.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(container, text="Orientation").grid(row=0, column=0, sticky="w", pady=4)
|
||||
orientation_combo = ttk.Combobox(
|
||||
container,
|
||||
state="readonly",
|
||||
values=["horizontal", "vertical"],
|
||||
textvariable=self.orientation_var,
|
||||
width=20,
|
||||
)
|
||||
orientation_combo.grid(row=0, column=1, sticky="ew", pady=4)
|
||||
|
||||
ttk.Label(container, text="Frame Spacing (px)").grid(row=1, column=0, sticky="w", pady=4)
|
||||
ttk.Spinbox(container, from_=0, to=999, textvariable=self.spacing_var, width=10).grid(
|
||||
row=1, column=1, sticky="w", pady=4
|
||||
)
|
||||
|
||||
ttk.Label(container, text="Margin (px)").grid(row=2, column=0, sticky="w", pady=4)
|
||||
ttk.Spinbox(container, from_=0, to=999, textvariable=self.margin_var, width=10).grid(
|
||||
row=2, column=1, sticky="w", pady=4
|
||||
)
|
||||
|
||||
ttk.Label(container, text="Rows (0 = auto)").grid(row=3, column=0, sticky="w", pady=4)
|
||||
ttk.Spinbox(container, from_=0, to=999, textvariable=self.rows_var, width=10).grid(
|
||||
row=3, column=1, sticky="w", pady=4
|
||||
)
|
||||
|
||||
ttk.Label(container, text="Columns (0 = auto)").grid(row=4, column=0, sticky="w", pady=4)
|
||||
ttk.Spinbox(container, from_=0, to=999, textvariable=self.columns_var, width=10).grid(
|
||||
row=4, column=1, sticky="w", pady=4
|
||||
)
|
||||
|
||||
ttk.Label(container, text="Default File Name").grid(row=5, column=0, sticky="w", pady=4)
|
||||
ttk.Entry(container, textvariable=self.output_name_var, width=24).grid(row=5, column=1, sticky="ew", pady=4)
|
||||
|
||||
actions = ttk.Frame(container)
|
||||
actions.grid(row=6, column=0, columnspan=2, sticky="e", pady=(10, 0))
|
||||
ttk.Button(actions, text="Cancel", command=self._cancel).grid(row=0, column=0, padx=4)
|
||||
ttk.Button(actions, text="Apply", command=self._apply).grid(row=0, column=1, padx=4)
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
|
||||
def show(self) -> ExportSettings | None:
|
||||
self.wait_window(self)
|
||||
return self.result
|
||||
|
||||
def _apply(self) -> None:
|
||||
try:
|
||||
spacing = int(self.spacing_var.get())
|
||||
margin = int(self.margin_var.get())
|
||||
rows = int(self.rows_var.get())
|
||||
columns = int(self.columns_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Invalid settings",
|
||||
"Spacing, margin, rows, and columns must be valid whole numbers.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
|
||||
if spacing < 0 or margin < 0 or rows < 0 or columns < 0:
|
||||
messagebox.showerror("Invalid settings", "Spacing, margin, rows, and columns cannot be negative.", parent=self)
|
||||
return
|
||||
|
||||
orientation = self.orientation_var.get()
|
||||
if orientation not in {"horizontal", "vertical"}:
|
||||
messagebox.showerror("Invalid settings", "Orientation must be horizontal or vertical.", parent=self)
|
||||
return
|
||||
|
||||
output_name = self.output_name_var.get().strip() or "flipbook.png"
|
||||
if not output_name.lower().endswith(".png"):
|
||||
output_name = f"{output_name}.png"
|
||||
|
||||
self.result = ExportSettings(
|
||||
orientation=orientation,
|
||||
spacing=spacing,
|
||||
margin=margin,
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
output_name=output_name,
|
||||
)
|
||||
self.destroy()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.result = None
|
||||
self.destroy()
|
||||
@@ -0,0 +1,511 @@
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from .export_settings_dialog import ExportSettings, ExportSettingsDialog
|
||||
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".png", ".gif", ".ppm", ".pgm"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameItem:
|
||||
path: Path
|
||||
image: tk.PhotoImage
|
||||
thumbnail: tk.PhotoImage
|
||||
|
||||
|
||||
def _natural_sort_key(path: Path):
|
||||
parts = re.split(r"(\d+)", path.name.lower())
|
||||
return [int(part) if part.isdigit() else part for part in parts]
|
||||
|
||||
|
||||
class FlipbookCreatorDialog(tk.Toplevel):
|
||||
def __init__(self, parent: tk.Misc) -> None:
|
||||
super().__init__(parent)
|
||||
self.title("Flipbook Creator")
|
||||
self.geometry("1100x700")
|
||||
self.minsize(900, 580)
|
||||
self.transient(parent)
|
||||
|
||||
self.frame_items: list[FrameItem] = []
|
||||
self.folder_path: Path | None = None
|
||||
self.drag_index: int | None = None
|
||||
self.export_settings = ExportSettings()
|
||||
|
||||
self.rendered_image: tk.PhotoImage | None = None
|
||||
self.preview_image: tk.PhotoImage | None = None
|
||||
|
||||
self.player_running = False
|
||||
self.player_after_id: str | None = None
|
||||
self.player_frame_index = 0
|
||||
|
||||
self.preview_mode_var = tk.StringVar(value="Rendered")
|
||||
self.fps_var = tk.StringVar(value="8")
|
||||
|
||||
self._build_ui()
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
container = ttk.Frame(self, padding=10)
|
||||
container.pack(fill="both", expand=True)
|
||||
container.columnconfigure(0, weight=1)
|
||||
container.rowconfigure(2, weight=1)
|
||||
container.rowconfigure(3, weight=1)
|
||||
|
||||
header = ttk.Frame(container)
|
||||
header.grid(row=0, column=0, sticky="ew")
|
||||
header.columnconfigure(1, weight=1)
|
||||
ttk.Button(header, text="Load Folder", command=self._load_folder).grid(row=0, column=0, padx=(0, 8))
|
||||
self.folder_label = ttk.Label(header, text="No folder loaded")
|
||||
self.folder_label.grid(row=0, column=1, sticky="w")
|
||||
|
||||
info_frame = ttk.LabelFrame(container, text="Basic Info")
|
||||
info_frame.grid(row=1, column=0, sticky="ew", pady=(10, 8))
|
||||
info_frame.columnconfigure(0, weight=1)
|
||||
self.info_label = ttk.Label(
|
||||
info_frame,
|
||||
text="Frames: 0 | Max Frame Size: - | Grid: auto | Supported: .png, .gif, .ppm, .pgm",
|
||||
)
|
||||
self.info_label.grid(row=0, column=0, sticky="w", padx=8, pady=8)
|
||||
|
||||
middle_panes = tk.PanedWindow(container, orient=tk.HORIZONTAL, sashrelief=tk.RAISED, sashwidth=6)
|
||||
middle_panes.grid(row=2, column=0, sticky="nsew", pady=(0, 8))
|
||||
|
||||
order_frame = ttk.LabelFrame(middle_panes, text="Frame Order (Drag to Sort)")
|
||||
order_frame.columnconfigure(0, weight=1)
|
||||
order_frame.rowconfigure(0, weight=1)
|
||||
self.order_listbox = tk.Listbox(order_frame, selectmode=tk.SINGLE, activestyle="none", exportselection=False)
|
||||
self.order_listbox.grid(row=0, column=0, sticky="nsew", padx=(8, 0), pady=8)
|
||||
order_scroll = ttk.Scrollbar(order_frame, orient=tk.VERTICAL, command=self.order_listbox.yview)
|
||||
order_scroll.grid(row=0, column=1, sticky="ns", padx=(0, 8), pady=8)
|
||||
self.order_listbox.configure(yscrollcommand=order_scroll.set)
|
||||
self.order_listbox.bind("<ButtonPress-1>", self._on_drag_start)
|
||||
self.order_listbox.bind("<B1-Motion>", self._on_drag_motion)
|
||||
self.order_listbox.bind("<ButtonRelease-1>", self._on_drag_end)
|
||||
|
||||
all_preview_frame = ttk.LabelFrame(middle_panes, text="All Images Preview")
|
||||
all_preview_frame.columnconfigure(0, weight=1)
|
||||
all_preview_frame.rowconfigure(0, weight=1)
|
||||
self.all_preview_canvas = tk.Canvas(all_preview_frame, highlightthickness=0)
|
||||
self.all_preview_canvas.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 0))
|
||||
all_preview_vsb = ttk.Scrollbar(all_preview_frame, orient=tk.VERTICAL, command=self.all_preview_canvas.yview)
|
||||
all_preview_vsb.grid(row=0, column=1, sticky="ns", padx=(0, 8), pady=(8, 0))
|
||||
all_preview_hsb = ttk.Scrollbar(all_preview_frame, orient=tk.HORIZONTAL, command=self.all_preview_canvas.xview)
|
||||
all_preview_hsb.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
|
||||
self.all_preview_canvas.configure(yscrollcommand=all_preview_vsb.set, xscrollcommand=all_preview_hsb.set)
|
||||
self.thumbnail_content = ttk.Frame(self.all_preview_canvas)
|
||||
self.thumbnail_window = self.all_preview_canvas.create_window((0, 0), window=self.thumbnail_content, anchor="nw")
|
||||
self.thumbnail_content.bind("<Configure>", self._on_thumbnail_content_configure)
|
||||
self.all_preview_canvas.bind("<Configure>", self._on_thumbnail_canvas_configure)
|
||||
|
||||
middle_panes.add(order_frame, minsize=260, width=340)
|
||||
middle_panes.add(all_preview_frame, minsize=450)
|
||||
|
||||
output_panes = tk.PanedWindow(container, orient=tk.HORIZONTAL, sashrelief=tk.RAISED, sashwidth=6)
|
||||
output_panes.grid(row=3, column=0, sticky="nsew")
|
||||
|
||||
preview_frame = ttk.LabelFrame(output_panes, text="Preview Field")
|
||||
preview_frame.columnconfigure(0, weight=1)
|
||||
preview_frame.rowconfigure(1, weight=1)
|
||||
|
||||
preview_toolbar = ttk.Frame(preview_frame)
|
||||
preview_toolbar.grid(row=0, column=0, sticky="ew", padx=8, pady=(8, 4))
|
||||
ttk.Label(preview_toolbar, text="Mode").grid(row=0, column=0, padx=(0, 6))
|
||||
mode_combo = ttk.Combobox(
|
||||
preview_toolbar,
|
||||
state="readonly",
|
||||
values=["Rendered", "Player"],
|
||||
textvariable=self.preview_mode_var,
|
||||
width=12,
|
||||
)
|
||||
mode_combo.grid(row=0, column=1, padx=(0, 10))
|
||||
mode_combo.bind("<<ComboboxSelected>>", self._on_preview_mode_changed)
|
||||
|
||||
self.player_controls = ttk.Frame(preview_toolbar)
|
||||
self.player_controls.grid(row=0, column=2)
|
||||
ttk.Label(self.player_controls, text="FPS").grid(row=0, column=0, padx=(0, 4))
|
||||
ttk.Spinbox(self.player_controls, from_=1, to=120, textvariable=self.fps_var, width=5).grid(
|
||||
row=0, column=1, padx=(0, 8)
|
||||
)
|
||||
ttk.Button(self.player_controls, text="Play", command=self._start_player).grid(row=0, column=2, padx=(0, 4))
|
||||
ttk.Button(self.player_controls, text="Stop", command=self._stop_player).grid(row=0, column=3)
|
||||
|
||||
self.preview_canvas = tk.Canvas(preview_frame, bg="#202020", width=520, height=230, highlightthickness=0)
|
||||
self.preview_canvas.grid(row=1, column=0, sticky="nsew", padx=8, pady=(4, 8))
|
||||
|
||||
actions_frame = ttk.LabelFrame(output_panes, text="Actions")
|
||||
actions_frame.columnconfigure(0, weight=1)
|
||||
ttk.Button(actions_frame, text="Export Settings", command=self._open_export_settings).grid(
|
||||
row=0, column=0, sticky="ew", padx=8, pady=(10, 4)
|
||||
)
|
||||
ttk.Button(actions_frame, text="Render Preview", command=self._render_preview).grid(
|
||||
row=1, column=0, sticky="ew", padx=8, pady=4
|
||||
)
|
||||
ttk.Button(actions_frame, text="Export PNG", command=self._export_png).grid(
|
||||
row=2, column=0, sticky="ew", padx=8, pady=(4, 10)
|
||||
)
|
||||
|
||||
output_panes.add(preview_frame, minsize=520)
|
||||
output_panes.add(actions_frame, minsize=170, width=210)
|
||||
|
||||
self._apply_preview_mode()
|
||||
|
||||
def _load_folder(self) -> None:
|
||||
chosen_folder = filedialog.askdirectory(parent=self, title="Select folder with frames")
|
||||
if not chosen_folder:
|
||||
return
|
||||
|
||||
folder = Path(chosen_folder)
|
||||
valid_paths = [path for path in sorted(folder.iterdir(), key=_natural_sort_key) if path.suffix.lower() in SUPPORTED_EXTENSIONS]
|
||||
if not valid_paths:
|
||||
messagebox.showerror(
|
||||
"No valid images",
|
||||
"No supported images were found in this folder.\nSupported: .png, .gif, .ppm, .pgm",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
|
||||
frame_items: list[FrameItem] = []
|
||||
skipped_files: list[str] = []
|
||||
for file_path in valid_paths:
|
||||
try:
|
||||
image = tk.PhotoImage(file=str(file_path))
|
||||
except tk.TclError:
|
||||
skipped_files.append(file_path.name)
|
||||
continue
|
||||
thumbnail = self._build_thumbnail(image, 72)
|
||||
frame_items.append(FrameItem(path=file_path, image=image, thumbnail=thumbnail))
|
||||
|
||||
if not frame_items:
|
||||
messagebox.showerror(
|
||||
"Load failed",
|
||||
"Image files were found, but none could be loaded.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
|
||||
self.folder_path = folder
|
||||
self.frame_items = frame_items
|
||||
self.player_frame_index = 0
|
||||
self._stop_player()
|
||||
self._clear_render_state()
|
||||
self.folder_label.config(text=str(folder))
|
||||
self._refresh_frame_order()
|
||||
self._refresh_thumbnail_preview()
|
||||
self._update_info()
|
||||
|
||||
if skipped_files:
|
||||
messagebox.showwarning(
|
||||
"Some files skipped",
|
||||
f"{len(skipped_files)} file(s) could not be loaded:\n" + "\n".join(skipped_files[:10]),
|
||||
parent=self,
|
||||
)
|
||||
|
||||
def _build_thumbnail(self, image: tk.PhotoImage, max_size: int) -> tk.PhotoImage:
|
||||
width = max(1, image.width())
|
||||
height = max(1, image.height())
|
||||
divisor = max(1, math.ceil(max(width / max_size, height / max_size)))
|
||||
return image.subsample(divisor, divisor)
|
||||
|
||||
def _refresh_frame_order(self, selected_index: int | None = None) -> None:
|
||||
self.order_listbox.delete(0, tk.END)
|
||||
digits = max(2, len(str(len(self.frame_items))))
|
||||
for idx, item in enumerate(self.frame_items, start=1):
|
||||
self.order_listbox.insert(tk.END, f"{idx:0{digits}d} - {item.path.name}")
|
||||
|
||||
if selected_index is not None and self.frame_items:
|
||||
bounded_index = max(0, min(selected_index, len(self.frame_items) - 1))
|
||||
self.order_listbox.selection_clear(0, tk.END)
|
||||
self.order_listbox.selection_set(bounded_index)
|
||||
self.order_listbox.activate(bounded_index)
|
||||
|
||||
def _refresh_thumbnail_preview(self) -> None:
|
||||
for child in self.thumbnail_content.winfo_children():
|
||||
child.destroy()
|
||||
|
||||
digits = max(2, len(str(len(self.frame_items))))
|
||||
for idx, item in enumerate(self.frame_items):
|
||||
card = ttk.Frame(self.thumbnail_content, padding=4)
|
||||
card.grid(row=idx, column=0, sticky="w")
|
||||
thumb_label = ttk.Label(card, image=item.thumbnail)
|
||||
thumb_label.grid(row=0, column=0, rowspan=2, padx=(0, 8))
|
||||
thumb_label.image = item.thumbnail
|
||||
ttk.Label(card, text=f"{idx + 1:0{digits}d}. {item.path.name}").grid(row=0, column=1, sticky="w")
|
||||
ttk.Label(card, text=f"{item.image.width()}x{item.image.height()} px").grid(row=1, column=1, sticky="w")
|
||||
|
||||
def _update_info(self) -> None:
|
||||
if not self.frame_items:
|
||||
self.info_label.config(text="Frames: 0 | Max Frame Size: - | Grid: auto | Supported: .png, .gif, .ppm, .pgm")
|
||||
return
|
||||
|
||||
max_width = max(item.image.width() for item in self.frame_items)
|
||||
max_height = max(item.image.height() for item in self.frame_items)
|
||||
rows = self.export_settings.rows if self.export_settings.rows > 0 else "auto"
|
||||
columns = self.export_settings.columns if self.export_settings.columns > 0 else "auto"
|
||||
self.info_label.config(
|
||||
text=f"Frames: {len(self.frame_items)} | Max Frame Size: {max_width}x{max_height} | "
|
||||
f"Grid: rows={rows}, columns={columns} | Supported: .png, .gif, .ppm, .pgm"
|
||||
)
|
||||
|
||||
def _on_drag_start(self, event) -> None:
|
||||
if not self.frame_items:
|
||||
self.drag_index = None
|
||||
return
|
||||
self.drag_index = self.order_listbox.nearest(event.y)
|
||||
|
||||
def _on_drag_motion(self, event) -> None:
|
||||
if self.drag_index is None or not self.frame_items:
|
||||
return
|
||||
target_index = self.order_listbox.nearest(event.y)
|
||||
if target_index == self.drag_index or target_index < 0 or target_index >= len(self.frame_items):
|
||||
return
|
||||
|
||||
item = self.frame_items.pop(self.drag_index)
|
||||
self.frame_items.insert(target_index, item)
|
||||
self.drag_index = target_index
|
||||
self.player_frame_index = target_index
|
||||
self._refresh_frame_order(selected_index=target_index)
|
||||
self._refresh_thumbnail_preview()
|
||||
self._stop_player()
|
||||
self._clear_render_state()
|
||||
|
||||
def _on_drag_end(self, _event) -> None:
|
||||
self.drag_index = None
|
||||
|
||||
def _resolve_grid(self, count: int) -> tuple[int, int]:
|
||||
rows = self.export_settings.rows
|
||||
columns = self.export_settings.columns
|
||||
|
||||
if rows < 0 or columns < 0:
|
||||
raise ValueError("Rows and columns must be zero or positive.")
|
||||
|
||||
if rows == 0 and columns == 0:
|
||||
if self.export_settings.orientation == "vertical":
|
||||
return count, 1
|
||||
return 1, count
|
||||
|
||||
if rows == 0:
|
||||
rows = math.ceil(count / columns)
|
||||
elif columns == 0:
|
||||
columns = math.ceil(count / rows)
|
||||
|
||||
if rows * columns < count:
|
||||
raise ValueError("Rows and columns are too small for the current number of frames.")
|
||||
|
||||
return rows, columns
|
||||
|
||||
def _compose_sprite_sheet(self) -> tk.PhotoImage | None:
|
||||
if not self.frame_items:
|
||||
return None
|
||||
|
||||
max_width = max(item.image.width() for item in self.frame_items)
|
||||
max_height = max(item.image.height() for item in self.frame_items)
|
||||
count = len(self.frame_items)
|
||||
spacing = self.export_settings.spacing
|
||||
margin = self.export_settings.margin
|
||||
rows, columns = self._resolve_grid(count)
|
||||
|
||||
sheet_width = (2 * margin) + (columns * max_width) + ((columns - 1) * spacing)
|
||||
sheet_height = (2 * margin) + (rows * max_height) + ((rows - 1) * spacing)
|
||||
composed = tk.PhotoImage(width=sheet_width, height=sheet_height)
|
||||
|
||||
for idx, item in enumerate(self.frame_items):
|
||||
row = idx // columns
|
||||
column = idx % columns
|
||||
x_pos = margin + (column * (max_width + spacing))
|
||||
y_pos = margin + (row * (max_height + spacing))
|
||||
composed.tk.call(str(composed), "copy", str(item.image), "-to", x_pos, y_pos)
|
||||
|
||||
return composed
|
||||
|
||||
def _fit_image_to_preview(self, image: tk.PhotoImage) -> tk.PhotoImage:
|
||||
canvas_width = max(1, self.preview_canvas.winfo_width())
|
||||
canvas_height = max(1, self.preview_canvas.winfo_height())
|
||||
if canvas_width <= 1:
|
||||
canvas_width = int(self.preview_canvas.cget("width"))
|
||||
if canvas_height <= 1:
|
||||
canvas_height = int(self.preview_canvas.cget("height"))
|
||||
|
||||
divisor = max(
|
||||
1,
|
||||
math.ceil(image.width() / canvas_width),
|
||||
math.ceil(image.height() / canvas_height),
|
||||
)
|
||||
if divisor > 1:
|
||||
return image.subsample(divisor, divisor)
|
||||
return image
|
||||
|
||||
def _show_image_on_preview(self, image: tk.PhotoImage) -> None:
|
||||
canvas_width = max(1, self.preview_canvas.winfo_width())
|
||||
canvas_height = max(1, self.preview_canvas.winfo_height())
|
||||
if canvas_width <= 1:
|
||||
canvas_width = int(self.preview_canvas.cget("width"))
|
||||
if canvas_height <= 1:
|
||||
canvas_height = int(self.preview_canvas.cget("height"))
|
||||
|
||||
self.preview_image = self._fit_image_to_preview(image)
|
||||
self.preview_canvas.delete("all")
|
||||
self.preview_canvas.create_image(canvas_width // 2, canvas_height // 2, image=self.preview_image, anchor="center")
|
||||
|
||||
def _render_preview(self) -> None:
|
||||
if not self.frame_items:
|
||||
messagebox.showerror("Cannot render", "Load a folder with frames before rendering.", parent=self)
|
||||
return
|
||||
|
||||
if self.preview_mode_var.get() == "Player":
|
||||
self._stop_player()
|
||||
self._render_player_frame(self.player_frame_index)
|
||||
return
|
||||
|
||||
try:
|
||||
composed = self._compose_sprite_sheet()
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Cannot render", str(exc), parent=self)
|
||||
return
|
||||
|
||||
if composed is None:
|
||||
messagebox.showerror("Cannot render", "Load a folder with frames before rendering.", parent=self)
|
||||
return
|
||||
|
||||
self._stop_player()
|
||||
self.rendered_image = composed
|
||||
self._show_image_on_preview(composed)
|
||||
|
||||
def _render_player_frame(self, index: int) -> None:
|
||||
if not self.frame_items:
|
||||
return
|
||||
|
||||
frame_count = len(self.frame_items)
|
||||
self.player_frame_index = index % frame_count
|
||||
frame = self.frame_items[self.player_frame_index].image
|
||||
self._show_image_on_preview(frame)
|
||||
|
||||
def _start_player(self) -> None:
|
||||
if not self.frame_items:
|
||||
messagebox.showerror("Cannot play", "Load a folder with frames before playing.", parent=self)
|
||||
return
|
||||
if self.preview_mode_var.get() != "Player":
|
||||
self.preview_mode_var.set("Player")
|
||||
self._apply_preview_mode()
|
||||
|
||||
try:
|
||||
fps = int(self.fps_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror("Invalid FPS", "FPS must be a whole number.", parent=self)
|
||||
return
|
||||
if fps < 1 or fps > 120:
|
||||
messagebox.showerror("Invalid FPS", "FPS must be between 1 and 120.", parent=self)
|
||||
return
|
||||
|
||||
self.player_running = True
|
||||
self._schedule_next_player_tick(fps)
|
||||
|
||||
def _schedule_next_player_tick(self, fps: int) -> None:
|
||||
if not self.player_running:
|
||||
return
|
||||
|
||||
self._render_player_frame(self.player_frame_index)
|
||||
self.player_frame_index = (self.player_frame_index + 1) % len(self.frame_items)
|
||||
delay_ms = max(1, int(1000 / fps))
|
||||
self.player_after_id = self.after(delay_ms, self._player_tick)
|
||||
|
||||
def _player_tick(self) -> None:
|
||||
if not self.player_running:
|
||||
return
|
||||
try:
|
||||
fps = int(self.fps_var.get())
|
||||
except ValueError:
|
||||
self._stop_player()
|
||||
messagebox.showerror("Invalid FPS", "FPS must be a whole number.", parent=self)
|
||||
return
|
||||
if fps < 1 or fps > 120:
|
||||
self._stop_player()
|
||||
messagebox.showerror("Invalid FPS", "FPS must be between 1 and 120.", parent=self)
|
||||
return
|
||||
self._schedule_next_player_tick(fps)
|
||||
|
||||
def _stop_player(self) -> None:
|
||||
self.player_running = False
|
||||
if self.player_after_id is not None:
|
||||
self.after_cancel(self.player_after_id)
|
||||
self.player_after_id = None
|
||||
|
||||
def _on_preview_mode_changed(self, _event) -> None:
|
||||
self._apply_preview_mode()
|
||||
self._clear_preview_canvas()
|
||||
|
||||
def _apply_preview_mode(self) -> None:
|
||||
if self.preview_mode_var.get() == "Player":
|
||||
self.player_controls.grid(row=0, column=2)
|
||||
else:
|
||||
self.player_controls.grid_remove()
|
||||
self._stop_player()
|
||||
|
||||
def _open_export_settings(self) -> None:
|
||||
dialog = ExportSettingsDialog(self, self.export_settings)
|
||||
updated_settings = dialog.show()
|
||||
if updated_settings is None:
|
||||
return
|
||||
self.export_settings = updated_settings
|
||||
self._update_info()
|
||||
self._stop_player()
|
||||
self._clear_render_state()
|
||||
|
||||
def _export_png(self) -> None:
|
||||
if not self.frame_items:
|
||||
messagebox.showerror("Cannot export", "Load frames before exporting.", parent=self)
|
||||
return
|
||||
|
||||
try:
|
||||
composed = self._compose_sprite_sheet()
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Cannot export", str(exc), parent=self)
|
||||
return
|
||||
|
||||
if composed is None:
|
||||
messagebox.showerror("Cannot export", "Load frames before exporting.", parent=self)
|
||||
return
|
||||
|
||||
start_directory = str(self.folder_path) if self.folder_path else str(Path.home())
|
||||
output_path = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Export flipbook PNG",
|
||||
initialdir=start_directory,
|
||||
initialfile=self.export_settings.output_name,
|
||||
defaultextension=".png",
|
||||
filetypes=[("PNG Image", "*.png")],
|
||||
)
|
||||
if not output_path:
|
||||
return
|
||||
|
||||
try:
|
||||
composed.write(output_path, format="png")
|
||||
except tk.TclError as exc:
|
||||
messagebox.showerror("Export failed", f"PNG export failed: {exc}", parent=self)
|
||||
return
|
||||
|
||||
self.rendered_image = composed
|
||||
messagebox.showinfo("Export complete", f"Saved:\n{output_path}", parent=self)
|
||||
|
||||
def _clear_preview_canvas(self) -> None:
|
||||
self.preview_image = None
|
||||
self.preview_canvas.delete("all")
|
||||
|
||||
def _clear_render_state(self) -> None:
|
||||
self.rendered_image = None
|
||||
self._clear_preview_canvas()
|
||||
|
||||
def _on_thumbnail_content_configure(self, _event) -> None:
|
||||
self.all_preview_canvas.configure(scrollregion=self.all_preview_canvas.bbox("all"))
|
||||
|
||||
def _on_thumbnail_canvas_configure(self, event) -> None:
|
||||
self.all_preview_canvas.itemconfigure(self.thumbnail_window, width=event.width)
|
||||
|
||||
def _on_close(self) -> None:
|
||||
self._stop_player()
|
||||
self.destroy()
|
||||
|
||||
Reference in New Issue
Block a user