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:
2026-08-31 17:53:13 +02:00
commit 911d210074
10 changed files with 814 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from .bottom_toolbar import BottomToolbarView
from .dialogs import ExportSettings, ExportSettingsDialog, FlipbookCreatorDialog
from .left_sidebar import LeftSidebarView
from .main_editor import MainEditorView
from .menu_bar import create_menu_bar
from .right_sidebar import RightSidebarView
+40
View File
@@ -0,0 +1,40 @@
from tkinter import ttk
class BottomToolbarView(ttk.LabelFrame):
def __init__(self, parent) -> None:
super().__init__(parent, text="Bottom Toolbar (Placeholder)")
self.columnconfigure(0, weight=1)
self.toolbar_expanded = True
toolbar_header = ttk.Frame(self)
toolbar_header.grid(row=0, column=0, sticky="ew", padx=8, pady=(6, 2))
toolbar_header.columnconfigure(0, weight=1)
ttk.Label(toolbar_header, text="Animation / Timeline / Quick Tools").grid(row=0, column=0, sticky="w")
self.toggle_button = ttk.Button(toolbar_header, text="Collapse", command=self._toggle)
self.toggle_button.grid(row=0, column=1, sticky="e")
self.toolbar_content = ttk.Frame(self)
self.toolbar_content.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
for index, label in enumerate(
[
"Play (Placeholder)",
"Stop (Placeholder)",
"Add Frame (Placeholder)",
"Remove Frame (Placeholder)",
"Onion Skin (Placeholder)",
]
):
ttk.Button(self.toolbar_content, text=label).grid(row=0, column=index, padx=4, pady=4)
def _toggle(self) -> None:
self.toolbar_expanded = not self.toolbar_expanded
if self.toolbar_expanded:
self.toolbar_content.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
self.toggle_button.config(text="Collapse")
else:
self.toolbar_content.grid_forget()
self.toggle_button.config(text="Expand")
+3
View File
@@ -0,0 +1,3 @@
from .export_settings_dialog import ExportSettings, ExportSettingsDialog
from .flipbook_creator_dialog import FlipbookCreatorDialog
+120
View File
@@ -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()
+511
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
from tkinter import ttk
class LeftSidebarView(ttk.LabelFrame):
def __init__(self, parent) -> None:
super().__init__(parent, text="Left Sidebar (Placeholder)")
ttk.Label(self, text="Layers / Assets / Tools").pack(anchor="nw", padx=10, pady=10)
+20
View File
@@ -0,0 +1,20 @@
import tkinter as tk
from tkinter import ttk
class MainEditorView(ttk.LabelFrame):
def __init__(self, parent) -> None:
super().__init__(parent, text="Main Editor (Placeholder)")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
canvas = tk.Canvas(self, bg="#2c2c2c", highlightthickness=0)
canvas.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
canvas.create_text(
220,
160,
text="Sprite Editing Area Placeholder",
fill="#f0f0f0",
font=("Segoe UI", 12),
)
+32
View File
@@ -0,0 +1,32 @@
import tkinter as tk
def create_menu_bar(root: tk.Tk, on_open_flipbook_creator=None) -> None:
menu_bar = tk.Menu(root)
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="New (Placeholder)")
file_menu.add_command(label="Open (Placeholder)")
file_menu.add_command(label="Save (Placeholder)")
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
menu_bar.add_cascade(label="File", menu=file_menu)
edit_menu = tk.Menu(menu_bar, tearoff=0)
edit_menu.add_command(label="Undo (Placeholder)")
edit_menu.add_command(label="Redo (Placeholder)")
menu_bar.add_cascade(label="Edit", menu=edit_menu)
view_menu = tk.Menu(menu_bar, tearoff=0)
view_menu.add_command(label="Zoom In (Placeholder)")
view_menu.add_command(label="Zoom Out (Placeholder)")
menu_bar.add_cascade(label="View", menu=view_menu)
tools_menu = tk.Menu(menu_bar, tearoff=0)
if on_open_flipbook_creator is None:
tools_menu.add_command(label="Flipbook Creator", state=tk.DISABLED)
else:
tools_menu.add_command(label="Flipbook Creator", command=on_open_flipbook_creator)
menu_bar.add_cascade(label="Tools", menu=tools_menu)
root.config(menu=menu_bar)
+8
View File
@@ -0,0 +1,8 @@
from tkinter import ttk
class RightSidebarView(ttk.LabelFrame):
def __init__(self, parent) -> None:
super().__init__(parent, text="Right Sidebar (Placeholder)")
ttk.Label(self, text="Properties / Preview").pack(anchor="nw", padx=10, pady=10)