commit 911d210074298c0572002137e03d5184c6a78544 Author: Dolobarsch Date: Mon Aug 31 17:53:13 2026 +0200 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. diff --git a/sprite_editor.py b/sprite_editor.py new file mode 100644 index 0000000..b608995 --- /dev/null +++ b/sprite_editor.py @@ -0,0 +1,66 @@ +import tkinter as tk +from tkinter import ttk + +from views import ( + BottomToolbarView, + FlipbookCreatorDialog, + LeftSidebarView, + MainEditorView, + RightSidebarView, + create_menu_bar, +) + + +class SpriteEditorApp: + def __init__(self, root: tk.Tk) -> None: + self.root = root + self.root.title("Sprite Editor (Placeholder)") + self.root.geometry("1200x750") + + create_menu_bar(self.root, on_open_flipbook_creator=self._open_flipbook_creator) + self._create_layout() + + def _create_layout(self) -> None: + container = ttk.Frame(self.root, padding=6) + container.pack(fill="both", expand=True) + + vertical_panes = tk.PanedWindow( + container, + orient=tk.VERTICAL, + sashrelief=tk.RAISED, + sashwidth=6, + ) + vertical_panes.pack(fill="both", expand=True) + + horizontal_panes = tk.PanedWindow( + vertical_panes, + orient=tk.HORIZONTAL, + sashrelief=tk.RAISED, + sashwidth=6, + ) + + left_sidebar = LeftSidebarView(horizontal_panes) + main_editor = MainEditorView(horizontal_panes) + right_sidebar = RightSidebarView(horizontal_panes) + bottom_toolbar = BottomToolbarView(vertical_panes) + + horizontal_panes.add(left_sidebar, minsize=160, width=220) + horizontal_panes.add(main_editor, minsize=400) + horizontal_panes.add(right_sidebar, minsize=160, width=220) + + vertical_panes.add(horizontal_panes, minsize=280, stretch="always") + vertical_panes.add(bottom_toolbar, minsize=70, height=140) + + def _open_flipbook_creator(self) -> None: + dialog = FlipbookCreatorDialog(self.root) + dialog.focus() + + +def main() -> None: + root = tk.Tk() + SpriteEditorApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/views/__init__.py b/views/__init__.py new file mode 100644 index 0000000..36fdf3a --- /dev/null +++ b/views/__init__.py @@ -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 diff --git a/views/bottom_toolbar.py b/views/bottom_toolbar.py new file mode 100644 index 0000000..5fa48fd --- /dev/null +++ b/views/bottom_toolbar.py @@ -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") + diff --git a/views/dialogs/__init__.py b/views/dialogs/__init__.py new file mode 100644 index 0000000..2369a42 --- /dev/null +++ b/views/dialogs/__init__.py @@ -0,0 +1,3 @@ +from .export_settings_dialog import ExportSettings, ExportSettingsDialog +from .flipbook_creator_dialog import FlipbookCreatorDialog + diff --git a/views/dialogs/export_settings_dialog.py b/views/dialogs/export_settings_dialog.py new file mode 100644 index 0000000..e4c5f73 --- /dev/null +++ b/views/dialogs/export_settings_dialog.py @@ -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() diff --git a/views/dialogs/flipbook_creator_dialog.py b/views/dialogs/flipbook_creator_dialog.py new file mode 100644 index 0000000..dc79fc3 --- /dev/null +++ b/views/dialogs/flipbook_creator_dialog.py @@ -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("", self._on_drag_start) + self.order_listbox.bind("", self._on_drag_motion) + self.order_listbox.bind("", 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("", self._on_thumbnail_content_configure) + self.all_preview_canvas.bind("", 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("<>", 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() + diff --git a/views/left_sidebar.py b/views/left_sidebar.py new file mode 100644 index 0000000..177d2fd --- /dev/null +++ b/views/left_sidebar.py @@ -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) + diff --git a/views/main_editor.py b/views/main_editor.py new file mode 100644 index 0000000..bd778c6 --- /dev/null +++ b/views/main_editor.py @@ -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), + ) + diff --git a/views/menu_bar.py b/views/menu_bar.py new file mode 100644 index 0000000..948a21c --- /dev/null +++ b/views/menu_bar.py @@ -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) diff --git a/views/right_sidebar.py b/views/right_sidebar.py new file mode 100644 index 0000000..e5cc142 --- /dev/null +++ b/views/right_sidebar.py @@ -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) +