From ffa9c1725be72de2e8aea5cbae016f0db5ca7e62 Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 10:11:23 +0700 Subject: [PATCH 01/18] fix: port js-project atlas correctness fixes to Python (B1/B2/B3, A1/A2, A4/A5, C) Port behavioural fixes from the js-project rewrite to the pywebview Python code, verified byte-for-byte against the real JS via a Node golden-diff harness. atlas_extracter.py: - B1: duplicate region names get unique keys (arm#2) + atlas_name field, instead of silently overwriting each other in the regions dict. - B2: parse and keep split/pad/pma and unknown region keys (extra_pairs). - B3: a region 'size:' after 'bounds:' no longer clobbers w/h. atlas_modifier.py: - A1: non-proportional replacement is treated as its own full canvas rather than padded into the original size, fixing top/right clipping. - A2: offset-aware sprite placement when padding (uses left/bottom offsets with scale instead of a flush bottom-left paste). - A4: parse_atlas/RegionInfo/repack carry atlas_name/index/split/pad/extra_pairs so metadata and duplicate names survive a repack. - A5: rebuild_atlas_text emits minimal/spec-clean output (no leading blank line, default page keys and default offsets omitted, pma only when set). atlas_converter.py: - C: unknown region keys are re-emitted during old->new conversion. A3 (multi-page repack + target_page threading) is intentionally deferred. Co-Authored-By: Claude Opus 4.8 --- atlas_converter.py | 13 +++- atlas_extracter.py | 41 ++++++++-- atlas_modifier.py | 186 ++++++++++++++++++++++++++++++++------------- 3 files changed, 180 insertions(+), 60 deletions(-) diff --git a/atlas_converter.py b/atlas_converter.py index befd392..c14cdff 100644 --- a/atlas_converter.py +++ b/atlas_converter.py @@ -12,6 +12,12 @@ # Keys that are old format at region level (not page) _OLD_REGION_KEYS = {"xy", "size", "orig", "offset"} +# Region keys handled explicitly by flush_region (everything else is preserved) +_REGION_CORE_KEYS = { + "index", "rotate", "bounds", "xy", "size", "offsets", "orig", "offset", + "split", "pad", +} + # Keys that are page-level (don't touch) _PAGE_KEYS = {"size", "format", "filter", "repeat", "pma"} @@ -103,12 +109,17 @@ def flush_region() -> None: result.append(f" offsets: {off_x}, {off_y}, {orig_w}, {orig_h}") # If no pair → Do not output offsets (spine format default is packed = original) - # --- split / pad และ key อื่นๆ --- + # --- split / pad --- for key in ("split", "pad"): if key in region_kv: vals = ", ".join(region_kv[key]) result.append(f" {key}: {vals}") + # Unknown region keys → re-emit so they survive conversion + for key, values in region_kv.items(): + if key not in _REGION_CORE_KEYS: + result.append(f" {key}: {', '.join(values)}") + # Extra lines that we don't know result.extend(region_extra_lines) diff --git a/atlas_extracter.py b/atlas_extracter.py index cbd365c..d1b7b13 100644 --- a/atlas_extracter.py +++ b/atlas_extracter.py @@ -1,6 +1,6 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from io import BytesIO from pathlib import Path from typing import Dict, List, Mapping, Optional, Tuple, Union @@ -11,7 +11,8 @@ @dataclass class AtlasRegion: - name: str + name: str # unique key (e.g. "arm#2" for a duplicate region name) + atlas_name: str # real name written to the atlas (e.g. "arm") page_filename: str index: int = -1 x: int = 0 @@ -20,6 +21,9 @@ class AtlasRegion: h: int = 0 offsets: Optional[Tuple[int, int, int, int]] = None rotate: int = 0 + split: Optional[List[int]] = None + pad: Optional[List[int]] = None + extra_pairs: List[Tuple[str, List[str]]] = field(default_factory=list) @dataclass class AtlasPage: @@ -28,6 +32,7 @@ class AtlasPage: format: str = "RGBA8888" filter: Tuple[str, str] = ("Nearest", "Nearest") repeat: str = "none" + pma: bool = False scale_x: float = 1.0 scale_y: float = 1.0 @@ -50,6 +55,12 @@ def _parse_atlas(self) -> None: current_page: Optional[AtlasPage] = None current_region: Optional[AtlasRegion] = None + region_name_counts: Dict[str, int] = {} + + def get_unique_region_key(atlas_name: str) -> str: + nxt = region_name_counts.get(atlas_name, 0) + 1 + region_name_counts[atlas_name] = nxt + return atlas_name if nxt == 1 else f"{atlas_name}#{nxt}" while True: try: @@ -88,7 +99,8 @@ def _parse_atlas(self) -> None: elif key == 'xy': # Support LibGDX old format current_region.x = int(values[0]) current_region.y = int(values[1]) - elif key == 'size': # Support LibGDX old format + elif key == 'size' and current_region.w == 0: + # Only apply size to region if we haven't got bounds yet current_region.w = int(values[0]) current_region.h = int(values[1]) elif key == 'rotate': @@ -104,9 +116,15 @@ def _parse_atlas(self) -> None: current_region.rotate = 0 elif key == 'offsets': if len(values) >= 4: - current_region.offsets = tuple(map(int, values)) # type: ignore + current_region.offsets = tuple(map(int, values[:4])) # type: ignore elif key == 'index': current_region.index = int(values[0]) + elif key == 'split' and len(values) >= 4: + current_region.split = [int(v) for v in values] + elif key == 'pad' and len(values) >= 4: + current_region.pad = [int(v) for v in values] + else: + current_region.extra_pairs.append((key, list(values))) elif current_page: if key == 'size': @@ -117,15 +135,24 @@ def _parse_atlas(self) -> None: current_page.filter = (values[0], values[1]) elif key == 'repeat': current_page.repeat = values[0] + elif key == 'pma': + current_page.pma = str(values[0]).strip().lower() == 'true' else: # 3. If no colon and not .png -> It's a Region Name if current_page is None: continue - # Found a new region name -> Create object - current_region = AtlasRegion(name=line, page_filename=current_page.filename) - self.regions[line] = current_region + # Found a new region name -> Create object. + # Duplicate names (common with Spine index: multi-region sprites) + # get a unique dict key; the real name is kept in atlas_name. + region_key = get_unique_region_key(line) + current_region = AtlasRegion( + name=region_key, + atlas_name=line, + page_filename=current_page.filename, + ) + self.regions[region_key] = current_region def _load_images(self, loader: Mapping[str, Union[str, bytes, Path, Image.Image]]) -> None: for page in self.pages: diff --git a/atlas_modifier.py b/atlas_modifier.py index 30a8e82..c16aed5 100644 --- a/atlas_modifier.py +++ b/atlas_modifier.py @@ -25,17 +25,23 @@ class RegionInfo(NamedTuple): """Information about a region parsed from atlas file.""" - name: str + name: str # unique key (e.g. "arm#2") bounds: Tuple[int, int, int, int] # x, y, w, h offsets: Optional[Tuple[int, int, int, int]] # off_x, off_y, orig_w, orig_h rotate: int # 0, 90, 180, 270 + atlas_name: str = "" # real name emitted to the atlas + page: str = "" + index: int = -1 + split: Optional[List[int]] = None + pad: Optional[List[int]] = None + extra_pairs: List[Tuple[str, List[str]]] = [] -def parse_atlas(atlas_text: str) -> Tuple[Dict[str, str], List[str], Dict[str, RegionInfo]]: +def parse_atlas(atlas_text: str) -> Tuple[Dict[str, object], List[str], Dict[str, RegionInfo]]: from atlas_extracter import AtlasProcessor processor = AtlasProcessor(atlas_text, {}) - - page_info = {} + + page_info: Dict[str, object] = {} if processor.pages: p = processor.pages[0] page_info["page"] = p.filename @@ -43,7 +49,8 @@ def parse_atlas(atlas_text: str) -> Tuple[Dict[str, str], List[str], Dict[str, R page_info["format"] = p.format page_info["filter"] = f"{p.filter[0]}, {p.filter[1]}" page_info["repeat"] = p.repeat - + page_info["pma"] = bool(p.pma) + region_names = list(processor.regions.keys()) regions = {} for name, r in processor.regions.items(): @@ -52,8 +59,14 @@ def parse_atlas(atlas_text: str) -> Tuple[Dict[str, str], List[str], Dict[str, R bounds=(r.x, r.y, r.w, r.h), offsets=r.offsets, rotate=r.rotate, + atlas_name=r.atlas_name or name, + page=r.page_filename, + index=r.index, + split=r.split, + pad=r.pad, + extra_pairs=list(r.extra_pairs), ) - + return page_info, region_names, regions # Type alias for updated region data: (bounds, offsets, rotate) @@ -77,6 +90,33 @@ def _format_rotate(rotate_val: int) -> Optional[str]: return None +def _is_default_offsets( + offsets: Optional[Tuple[int, int, int, int]], + bounds: Tuple[int, int, int, int], +) -> bool: + """True when offsets carry no information (0,0,w,h) and can be omitted.""" + if not offsets or not bounds: + return True + return ( + offsets[0] == 0 + and offsets[1] == 0 + and offsets[2] == bounds[2] + and offsets[3] == bounds[3] + ) + + +def _is_default_page_format(fmt: object) -> bool: + return str(fmt or "").upper() == "RGBA8888" + + +def _is_default_page_filter(flt: object) -> bool: + return "".join(str(flt or "").split()).lower() == "nearest,nearest" + + +def _is_default_page_repeat(repeat: object) -> bool: + return str(repeat or "").lower() == "none" + + def _flush_pending_rotate( result: List[str], region: Optional[str], @@ -205,10 +245,10 @@ def update_atlas_text( return "\n".join(result) def rebuild_atlas_text( - page_info: Dict[str, str], + page_info: Dict[str, object], new_size: Tuple[int, int], region_names: List[str], - region_data: Dict[str, Tuple[Tuple[int, int, int, int], Optional[Tuple[int, int, int, int]], int]], + region_data: Dict[str, tuple], ) -> str: """ Build a complete atlas text from scratch. @@ -217,48 +257,66 @@ def rebuild_atlas_text( page_info: Original page metadata (must contain 'page'). new_size: (width, height) of the new canvas. region_names: Ordered list of region names. - region_data: Mapping of name → (bounds, offsets, rotate). - """ - lines: List[str] = [] - - # Blank line before page header (Spine convention) - lines.append("") - lines.append(page_info.get("page", "atlas.png")) - lines.append(f"size: {new_size[0]},{new_size[1]}") + region_data: Mapping of name → (bounds, offsets, rotate[, meta]). + ``meta`` is an optional dict carrying atlas_name, index, split, + pad, extra_pairs so duplicate names and unknown keys survive. - # Reproduce other page-level keys (filter, format, repeat, etc.) - for key, value in page_info.items(): - if key in ("page", "size"): - continue - lines.append(f"{key}: {value}") + Default page keys (format RGBA8888, filter nearest,nearest, repeat none), + default offsets (0,0,w,h) and pma:false are omitted for clean output. + """ + lines: List[str] = [ + str(page_info.get("page", "atlas.png")), + f"size: {new_size[0]},{new_size[1]}", + ] + if not _is_default_page_format(page_info.get("format")): + lines.append(f"format: {page_info.get('format')}") + if not _is_default_page_filter(page_info.get("filter")): + lines.append(f"filter: {page_info.get('filter')}") + if not _is_default_page_repeat(page_info.get("repeat")): + lines.append(f"repeat: {page_info.get('repeat')}") + if page_info.get("pma") is True: + lines.append("pma: true") for name in region_names: if name not in region_data: continue - bounds, offsets, rotate_val = region_data[name] - lines.append(name) - if rotate_val == 90: - rotate_str = "true" - elif rotate_val == 180: - rotate_str = "180" - elif rotate_val == 270: - rotate_str = "270" - else: - rotate_str = None + entry = region_data[name] + bounds, offsets, rotate_val = entry[0], entry[1], entry[2] + meta: Dict[str, object] = entry[3] if len(entry) > 3 and entry[3] else {} + + atlas_name = meta.get("atlas_name") or meta.get("name") or name + lines.append(str(atlas_name)) + + index = meta.get("index") + if isinstance(index, int) and index != -1: + lines.append(f" index: {index}") + + rotate_str = _format_rotate(rotate_val) if rotate_str: lines.append(f" rotate: {rotate_str}") + lines.append( f" bounds: {bounds[0]}, {bounds[1]}, {bounds[2]}, {bounds[3]}" ) - if offsets: + if offsets and not _is_default_offsets(offsets, bounds): lines.append( f" offsets: {offsets[0]}, {offsets[1]}, " f"{offsets[2]}, {offsets[3]}" ) - else: - lines.append( - f" offsets: 0, 0, {bounds[2]}, {bounds[3]}" - ) + + split = meta.get("split") + if isinstance(split, (list, tuple)) and len(split) >= 4: + lines.append(" split: " + ", ".join(str(v) for v in split)) + pad = meta.get("pad") + if isinstance(pad, (list, tuple)) and len(pad) >= 4: + lines.append(" pad: " + ", ".join(str(v) for v in pad)) + extra_pairs = meta.get("extra_pairs") + if isinstance(extra_pairs, (list, tuple)): + for pair in extra_pairs: + if not pair or not pair[0]: + continue + key, vals = pair[0], pair[1] if len(pair) > 1 else [] + lines.append(f" {key}: " + ", ".join(str(v) for v in vals)) return "\n".join(lines) @@ -411,13 +469,19 @@ def merge_mod_image( logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") - # Determine original canvas dimensions from the first selected region + # Determine original canvas dimensions from the first selected region. + # offsets format: [left, bottom, originalWidth, originalHeight] (Spine spec) orig_canvas_w, orig_canvas_h = mod_w, mod_h first_region = self.regions.get(selected_regions[0]) - if first_region and first_region.offsets: - orig_canvas_w = first_region.offsets[2] - orig_canvas_h = first_region.offsets[3] + has_offsets = bool(first_region and first_region.offsets) + off_x_orig = first_region.offsets[0] if has_offsets else 0 + off_y_orig = first_region.offsets[1] if has_offsets else 0 + base_orig_w = first_region.offsets[2] if has_offsets else mod_w + base_orig_h = first_region.offsets[3] if has_offsets else mod_h + if has_offsets: + orig_canvas_w = base_orig_w + orig_canvas_h = base_orig_h # Detect proportional scale (e.g. mod is 2x the expected canvas) if ( @@ -436,17 +500,28 @@ def merge_mod_image( f"Mod image scale: {mod_scale:.3f}x " f"(canvas → {orig_canvas_w}x{orig_canvas_h})" ) - - # Pad mod image to (possibly scaled) canvas size if needed + else: + # Non-proportional scale: treat the mod as a brand-new full + # canvas at its own dimensions so it is never clipped. [A1] + orig_canvas_w = mod_w + orig_canvas_h = mod_h + + # Pad mod image to (possibly scaled) canvas size if needed. + # Place the sprite at (left, origH - bottom - spriteH) per the Spine + # offset convention so whitespace-trimmed sprites stay aligned. [A2] if mod_w != orig_canvas_w or mod_h != orig_canvas_h: + scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 + scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 + paste_x = round(off_x_orig * scale_x) + paste_y = orig_canvas_h - mod_h - round(off_y_orig * scale_y) logging.info( f"Padding mod image to canvas: " - f"{orig_canvas_w}x{orig_canvas_h}" + f"{orig_canvas_w}x{orig_canvas_h} at ({paste_x}, {paste_y})" ) padded_mod = Image.new( "RGBA", (orig_canvas_w, orig_canvas_h), (0, 0, 0, 0) ) - padded_mod.paste(mod_img, (0, orig_canvas_h - mod_h)) + padded_mod.paste(mod_img, (paste_x, paste_y)) mod_img = padded_mod mod_w, mod_h = orig_canvas_w, orig_canvas_h @@ -737,10 +812,7 @@ def repack( canvas.paste(sprite, (px, py)) # ---- 6. Build region data for atlas text ---- - region_data: Dict[ - str, - Tuple[Tuple[int, int, int, int], Optional[Tuple[int, int, int, int]], int], - ] = {} + region_data: Dict[str, tuple] = {} for name in region_names: if name not in canonical_map: @@ -755,10 +827,20 @@ def repack( # Bounds always use ORIGINAL dimensions - no swap! bounds = (px, py, orig_w, orig_h) - - orig_offsets = regions[name].offsets - - region_data[name] = (bounds, orig_offsets, rotate_val) + + info = regions[name] + region_data[name] = ( + bounds, + info.offsets, + rotate_val, + { + "atlas_name": info.atlas_name or info.name, + "index": info.index, + "split": info.split, + "pad": info.pad, + "extra_pairs": info.extra_pairs, + }, + ) new_atlas_text = rebuild_atlas_text( page_info, (canvas_w, canvas_h), region_names, region_data From 803bb88a1e285f4b0cecf694523ad1910b1cbdda Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 11:19:19 +0700 Subject: [PATCH 02/18] feat: add repack_multi_page for multi-page atlas repack (A3 phase A) Port the JS repackMultiPage (atlas-modifier.js) to Python: greedy first-fit-decreasing assignment of all sprites into N pages, then shelf-pack each page. Unlike the single-page repack(), this does NOT deduplicate identical sprites. Region metadata (atlas_name/index/split/pad/extra_pairs from the phase-1 fields) is carried through, and empty pages emit a 1x1 placeholder. Backend only; not yet wired into main.py / UI (phases B and C). Co-Authored-By: Claude Opus 4.8 --- atlas_modifier.py | 131 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/atlas_modifier.py b/atlas_modifier.py index c16aed5..8150f78 100644 --- a/atlas_modifier.py +++ b/atlas_modifier.py @@ -846,4 +846,133 @@ def repack( page_info, (canvas_w, canvas_h), region_names, region_data ) - return canvas, new_atlas_text \ No newline at end of file + return canvas, new_atlas_text + + +# ---------------------------------------------------------------------------- # +# Multi-page repack # +# ---------------------------------------------------------------------------- # + + +def repack_multi_page( + all_sprites: Dict[str, Image.Image], + num_pages: int, + page_infos: List[Dict[str, object]], + region_metas: Dict[str, Dict[str, object]], +) -> Tuple[List[Image.Image], str]: + """Repack all sprites across *num_pages* pages. + + Greedy first-fit-decreasing assignment (largest sprite → least-filled page), + then shelf-pack each page. Mirrors the JS ``repackMultiPage``. + + Unlike the single-page :meth:`AtlasModifier.repack`, this does **not** + deduplicate identical sprites — every sprite is placed. + + Args: + all_sprites: name → sprite image (already whitespace-restored). + num_pages: number of output pages. + page_infos: per-page metadata dicts (page, format, filter, repeat, pma). + region_metas: name → dict(atlas_name, index, split, pad, extra_pairs). + + Returns: + (list of page images, atlas text). + """ + sprite_names = list(all_sprites.keys()) + if not sprite_names or num_pages == 0: + return [], "" + + # Greedy first-fit-decreasing: largest area first → least-filled group. + ordered = sorted( + sprite_names, + key=lambda n: all_sprites[n].width * all_sprites[n].height, + reverse=True, + ) + groups: List[Dict[str, object]] = [ + {"names": [], "area": 0} for _ in range(num_pages) + ] + for name in ordered: + s = all_sprites[name] + g = min(groups, key=lambda gr: gr["area"]) # type: ignore[index] + g["names"].append(name) # type: ignore[attr-defined] + g["area"] += s.width * s.height # type: ignore[operator] + + result_pages: List[Image.Image] = [] + atlas_lines: List[str] = [] + + def _emit_page_keys(pi: Dict[str, object]) -> None: + if not _is_default_page_format(pi.get("format")): + atlas_lines.append(f"format: {pi.get('format')}") + if not _is_default_page_filter(pi.get("filter")): + atlas_lines.append(f"filter: {pi.get('filter')}") + if not _is_default_page_repeat(pi.get("repeat")): + atlas_lines.append(f"repeat: {pi.get('repeat')}") + if pi.get("pma") is True: + atlas_lines.append("pma: true") + + for i in range(num_pages): + names: List[str] = groups[i]["names"] # type: ignore[assignment] + pi = page_infos[i] if i < len(page_infos) else page_infos[0] + + if i > 0: + atlas_lines.append("") # blank line between page sections + atlas_lines.append(str(pi.get("page"))) + + if not names: + atlas_lines.append("size: 1,1") + _emit_page_keys(pi) + result_pages.append(Image.new("RGBA", (1, 1), (0, 0, 0, 0))) + continue + + items = [(n, all_sprites[n].width, all_sprites[n].height) for n in names] + canvas_w, canvas_h, placements = AtlasModifier._shelf_pack(items) + placement_map = {p[0]: p for p in placements} + + canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) + for name in names: + placement = placement_map.get(name) + if not placement: + continue + _, px, py, _pw, _ph, rotated = placement + sprite = all_sprites[name] + if rotated: + sprite = sprite.transpose(Image.Transpose.ROTATE_90) + canvas.paste(sprite, (px, py)) + result_pages.append(canvas) + + atlas_lines.append(f"size: {canvas_w},{canvas_h}") + _emit_page_keys(pi) + + for name in names: + placement = placement_map.get(name) + if not placement: + continue + _, px, py, _pw, _ph, rotated = placement + sprite = all_sprites[name] # original (unrotated) dims for bounds + meta = region_metas.get(name, {}) + + atlas_lines.append(str(meta.get("atlas_name") or name)) + index = meta.get("index") + if isinstance(index, int) and index != -1: + atlas_lines.append(f" index: {index}") + rotate_str = _format_rotate(90 if rotated else 0) + if rotate_str: + atlas_lines.append(f" rotate: {rotate_str}") + atlas_lines.append( + f" bounds: {px}, {py}, {sprite.width}, {sprite.height}" + ) + split = meta.get("split") + if isinstance(split, (list, tuple)) and len(split) >= 4: + atlas_lines.append(" split: " + ", ".join(str(v) for v in split)) + pad = meta.get("pad") + if isinstance(pad, (list, tuple)) and len(pad) >= 4: + atlas_lines.append(" pad: " + ", ".join(str(v) for v in pad)) + extra_pairs = meta.get("extra_pairs") + if isinstance(extra_pairs, (list, tuple)): + for pair in extra_pairs: + if not pair or not pair[0]: + continue + key = pair[0] + vals = pair[1] if len(pair) > 1 else [] + atlas_lines.append(f" {key}: " + ", ".join(str(v) for v in vals)) + + return result_pages, "\n".join(atlas_lines) \ No newline at end of file From ddd5a112453aa4c587f782c5400685eef81ad6bf Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 11:24:45 +0700 Subject: [PATCH 03/18] feat: wire multi-page repack into the modify-mode controller (A3 phase B) Add the multi-page path to main.py, mirroring the JS multi-page branch: - process_mod_image now delegates to _process_mod_multi_page when the atlas has more than one page; the single-page path is unchanged. - _process_mod_multi_page extracts every region across all pages, swaps the mod image into the selected regions, and calls repack_multi_page. - enter_modify_mode return is extended with pages/regionPages/activePage for the upcoming page-switcher UI. - save_modified writes N page PNGs (named by their original page filenames) via _save_multi_page when a multi-page merge is active. - New _merged_pages state; shutil imported for the multi-page .skel copy. Backend only (no UI yet, phase C). Verified by a headless controller test: multi-page routing, enter metadata, N-page save, and a 1-page regression (single-page path takes the old route untouched). Co-Authored-By: Claude Opus 4.8 --- main.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 1261658..ca2fdb3 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import base64 import json import runpy +import shutil import subprocess import webbrowser import webview @@ -283,6 +284,8 @@ def __init__(self) -> None: self._modifier: Optional[AtlasModifier] = None self._merged_image: Optional[Image] = None self._merged_atlas_text: Optional[str] = None + # Multi-page merge output (set instead of _merged_image for >1 page atlases) + self._merged_pages: Optional[List[Image]] = None # Pre-repack state (merge output before repack was applied) self._pre_repack_image: Optional[Image] = None self._pre_repack_text: Optional[str] = None @@ -438,6 +441,7 @@ def _clear_modify_state(self) -> None: self._modifier = None self._merged_image = None self._merged_atlas_text = None + self._merged_pages = None self._pre_repack_image = None self._pre_repack_text = None @@ -555,17 +559,29 @@ def enter_modify_mode(self) -> Optional[dict[str, object]]: self._modifier = AtlasModifier(auto_convert_atlas(atlas_text), self._atlas_path, base_image) self._merged_image = None self._merged_atlas_text = None + self._merged_pages = None log.debug("Entered modify mode") - + # Build region bounds dict for client-side overlay # Each value: [x, y, w, h, rotate] region_bounds: dict[str, list[int]] = {} for name, info in self._modifier.regions.items(): region_bounds[name] = [*info.bounds, info.rotate] - + + # Multi-page metadata (consumed by the page switcher UI). For a + # single-page atlas these are a 1-element list / trivial map. + pages = [p.filename for p in self._processor.pages] + region_pages = { + name: r.page_filename + for name, r in self._processor.regions.items() + } + return { "image": self._image_to_base64(base_image), "regions": region_bounds, + "pages": pages, + "regionPages": region_pages, + "activePage": pages[0] if pages else None, } except Exception as e: @@ -601,11 +617,17 @@ def process_mod_image(self, path_str: str, selected_names: List[str], repack: bo """Run merge (and optional repack) and return dict with base64 preview + updated region bounds.""" if not self._modifier: return None - + + # Multi-page atlases take a dedicated path: extract every region from + # every page, swap in the mod for the selected regions, and repack all + # pages. (Mirrors the JS multi-page branch; repack flag is implied.) + if self._processor and len(self._processor.pages) > 1: + return self._process_mod_multi_page(path_str, selected_names) + try: mod_path = Path(path_str) log.debug("Processing mod image: %s", mod_path) - + merged_image, merged_atlas_text = self._modifier.merge_mod_image( mod_path, selected_names ) @@ -642,28 +664,133 @@ def process_mod_image(self, path_str: str, selected_names: List[str], repack: bo self._window.evaluate_js(f"showToast('Error: {str(e)}', 'error')") return None + def _process_mod_multi_page( + self, path_str: str, selected_names: List[str] + ) -> Optional[dict[str, object]]: + """Multi-page merge: extract every region (whitespace restored), replace + the selected regions with the mod image, then repack across all pages.""" + if not self._processor: + return None + + try: + from PIL import Image + from atlas_modifier import parse_atlas, repack_multi_page + + # 1. Extract every region from every page (offset padding restored). + all_sprites: dict[str, Image] = {} + for name in self._processor.regions: + sprite = self._processor.extract_region(name) + if sprite is not None: + all_sprites[name] = sprite + + # 2. Replace the selected regions' sprites with the mod image. + mod_img = Image.open(Path(path_str)).convert("RGBA") + for name in selected_names: + if name in all_sprites: + all_sprites[name] = mod_img + + # 3. Collect per-page infos and per-region metadata. + page_infos: list[dict[str, object]] = [ + { + "page": p.filename, + "format": p.format, + "filter": f"{p.filter[0]}, {p.filter[1]}", + "repeat": p.repeat, + "pma": p.pma, + } + for p in self._processor.pages + ] + region_metas: dict[str, dict[str, object]] = { + name: { + "atlas_name": r.atlas_name or name, + "index": r.index, + "split": r.split, + "pad": r.pad, + "extra_pairs": r.extra_pairs, + } + for name, r in self._processor.regions.items() + } + + # 4. Repack across all pages. + pages, atlas_text = repack_multi_page( + all_sprites, len(self._processor.pages), page_infos, region_metas + ) + + self._merged_pages = pages + self._merged_atlas_text = atlas_text + self._merged_image = None + self._pre_repack_image = None + self._pre_repack_text = None + + # 5. Region bounds for overlay (all regions across all pages). + _, _, merged_regions = parse_atlas(atlas_text) + region_bounds: dict[str, list[int]] = {} + for name, info in merged_regions.items(): + region_bounds[name] = [*info.bounds, info.rotate] + + return { + "image": self._image_to_base64(pages[0]) if pages else None, + "regions": region_bounds, + "pageCount": len(pages), + "previewPage": page_infos[0]["page"] if page_infos else None, + } + + except Exception as e: + log.error("Processing multi-page mod image: %s", e) + if self._window: + self._window.evaluate_js(f"showToast('Error: {str(e)}', 'error')") + return None + def save_modified(self) -> str: """Open a folder dialog and save the merged atlas files.""" - if not self._modifier or not self._merged_image or not self._merged_atlas_text or not self._window: + if not self._merged_atlas_text or not self._window: return "Error: No merged data to save." - + if not self._merged_pages and not (self._modifier and self._merged_image): + return "Error: No merged data to save." + default_dir = str(self._atlas_path.parent) if self._atlas_path else '' - + result = self._window.create_file_dialog( webview.FileDialog.FOLDER, directory=default_dir, ) - + if not result: return "Cancelled" - + try: output_dir = Path(result[0]) - self._modifier.save(output_dir, self._merged_image, self._merged_atlas_text) + if self._merged_pages is not None: + self._save_multi_page(output_dir) + else: + self._modifier.save(output_dir, self._merged_image, self._merged_atlas_text) return f"Saved to: {output_dir}" except Exception as e: return f"Error: {str(e)}" + def _save_multi_page(self, output_dir: Path) -> None: + """Write each repacked page PNG (named by its original page filename), + the updated atlas text, and copy the .skel if present.""" + if not self._processor or not self._atlas_path or self._merged_pages is None: + return + output_dir.mkdir(parents=True, exist_ok=True) + + for i, page_img in enumerate(self._merged_pages): + if i < len(self._processor.pages): + page_name = self._processor.pages[i].filename + else: + page_name = f"page{i}.png" + page_img.save(output_dir / Path(page_name).name) + + if self._merged_atlas_text is not None: + (output_dir / self._atlas_path.name).write_text( + self._merged_atlas_text, encoding="utf-8" + ) + + skel_path = self._atlas_path.with_suffix(".skel") + if skel_path.exists(): + shutil.copy(skel_path, output_dir / skel_path.name) + def toggle_repack(self, repack: bool) -> Optional[dict[str, object]]: """Re-apply or remove repack on the existing merge result.""" if not self._modifier or not self._pre_repack_image or not self._pre_repack_text: From 92d065f5ebb415446a5d41ad892b6607d3967fac Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 12:29:35 +0700 Subject: [PATCH 04/18] feat: multi-page atlas page switcher UI + preview API (A3 phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (main.py): - _process_mod_multi_page now returns the MERGED region→page map and the page list (repack redistributes regions, so the enter_modify_mode map goes stale). - New get_modify_page_preview(index): serves the repacked merged page images when a multi-page merge is active, else the original loaded page images — so the switcher works both before and after merging. Frontend (ui/): - Page switcher (‹ Page X / N ›) in the modify header, shown only for >1-page atlases; prev/next swap the preview via get_modify_page_preview. - Region overlay is filtered to the visible page using the region→page map. - enter/merge refresh the switcher; exit hides and resets it. Single-page atlases are unaffected (switcher stays hidden). Verified: headless controller test (regionPages/pages + preview accessor, pre- and post-merge) and a Playwright smoke against the real ui/index.html with a mocked pywebview API (switcher visibility, indicator, prev/next enabling, preview swap, correct preview-call index, exit-hides, 1-page regression). This completes A3 (multi-page) for v1. repack_mode='all' and the relocate/repack-all helpers remain intentionally out of scope. Co-Authored-By: Claude Opus 4.8 --- main.py | 33 +++++++++++++++++++++-- ui/index.html | 19 +++++++++++++ ui/script.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ ui/style.css | 36 +++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index ca2fdb3..06b39a8 100644 --- a/main.py +++ b/main.py @@ -722,17 +722,23 @@ def _process_mod_multi_page( self._pre_repack_image = None self._pre_repack_text = None - # 5. Region bounds for overlay (all regions across all pages). + # 5. Region bounds for overlay (all regions across all pages) plus + # the MERGED region→page map (repack redistributed them, so the + # enter_modify_mode map is now stale). _, _, merged_regions = parse_atlas(atlas_text) region_bounds: dict[str, list[int]] = {} + region_pages: dict[str, str] = {} for name, info in merged_regions.items(): region_bounds[name] = [*info.bounds, info.rotate] + region_pages[name] = info.page return { "image": self._image_to_base64(pages[0]) if pages else None, "regions": region_bounds, + "regionPages": region_pages, + "pages": [str(pi["page"]) for pi in page_infos], "pageCount": len(pages), - "previewPage": page_infos[0]["page"] if page_infos else None, + "previewPage": str(page_infos[0]["page"]) if page_infos else None, } except Exception as e: @@ -741,6 +747,29 @@ def _process_mod_multi_page( self._window.evaluate_js(f"showToast('Error: {str(e)}', 'error')") return None + def get_modify_page_preview(self, index: int) -> Optional[str]: + """Return a base64 data-URI for page *index* of the current modify view. + + Serves the repacked merged pages when a multi-page merge is active, + otherwise the originally-loaded atlas page images (so the page switcher + works both before and after merging). + """ + try: + if self._merged_pages is not None: + if 0 <= index < len(self._merged_pages): + return self._image_to_base64(self._merged_pages[index]) + return None + if self._processor and 0 <= index < len(self._processor.pages): + img = self._processor.get_page_image( + self._processor.pages[index].filename + ) + if img is not None: + return self._image_to_base64(img) + return None + except Exception as e: + log.error("get_modify_page_preview: %s", e) + return None + def save_modified(self) -> str: """Open a folder dialog and save the merged atlas files.""" if not self._merged_atlas_text or not self._window: diff --git a/ui/index.html b/ui/index.html index dea9b0c..f22b7ce 100644 --- a/ui/index.html +++ b/ui/index.html @@ -59,6 +59,25 @@ Back Modify Mode +
diff --git a/ui/script.js b/ui/script.js index c71dad6..0d1fb4f 100644 --- a/ui/script.js +++ b/ui/script.js @@ -7,6 +7,10 @@ let dragStartIndex = -1; let currentMode = "extract"; // 'extract' | 'modify' let modifyRegionBounds = {}; // {name: [x, y, w, h], ...} let hasModImage = false; +// Multi-page modify state +let modifyPages = []; // ordered page filenames +let modifyActivePageIndex = 0; +let modifyRegionPages = {}; // {name: pageFilename} let viewState = { scale: 1, x: 0, @@ -61,6 +65,7 @@ async function enterModifyMode() { if (data) { setMode("modify"); modifyRegionBounds = data.regions || {}; + setupModifyPages(data); hasModImage = false; document.getElementById("modify-status-text").innerText = "Select regions and click Modify Selected"; @@ -97,6 +102,10 @@ async function exitModifyMode() { } setMode("extract"); modifyRegionBounds = {}; + modifyPages = []; + modifyRegionPages = {}; + modifyActivePageIndex = 0; + document.getElementById("modify-page-switcher").classList.add("hidden"); hasModImage = false; clearOverlay(); // Restore preview from current selection @@ -135,6 +144,8 @@ function onModPreviewReceived(data) { if (data.regions) { modifyRegionBounds = data.regions; } + // Refresh multi-page state (regions were redistributed across pages by repack) + setupModifyPages(data); previewImg.src = data.image; previewImg.style.display = "block"; document.getElementById("modify-status-text").innerText = @@ -177,6 +188,64 @@ async function saveModified() { } } +// ========================================== +// MULTI-PAGE SWITCHER +// ========================================== +function setupModifyPages(data) { + modifyPages = Array.isArray(data.pages) ? data.pages : []; + modifyRegionPages = data.regionPages || {}; + modifyActivePageIndex = 0; + const switcher = document.getElementById("modify-page-switcher"); + if (modifyPages.length > 1) { + switcher.classList.remove("hidden"); + updatePageIndicator(); + } else { + switcher.classList.add("hidden"); + } +} + +function updatePageIndicator() { + const ind = document.getElementById("page-indicator"); + if (ind) + ind.innerText = `Page ${modifyActivePageIndex + 1} / ${modifyPages.length}`; + document.getElementById("page-prev").disabled = modifyActivePageIndex <= 0; + document.getElementById("page-next").disabled = + modifyActivePageIndex >= modifyPages.length - 1; +} + +async function showModifyPage(index) { + if (index < 0 || index >= modifyPages.length) return; + modifyActivePageIndex = index; + updatePageIndicator(); + try { + const dataUri = await pywebview.api.get_modify_page_preview(index); + if (!dataUri) return; + previewImg.src = dataUri; + previewImg.style.display = "block"; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + } + applyTransform(); // redraws overlay (filtered to this page) + previewImg.onload = null; + }; + } catch (e) { + console.error(e); + } +} + +function modifyPagePrev() { + showModifyPage(modifyActivePageIndex - 1); +} +function modifyPageNext() { + showModifyPage(modifyActivePageIndex + 1); +} + document.getElementById("chk-repack").addEventListener("change", async (e) => { pywebview.api.set_pref("repack", e.target.checked); if (!hasModImage) return; @@ -500,7 +569,13 @@ function drawRegionOverlay() { const lineWidth = 3; const names = getSelectedNames(); + // On multi-page atlases only draw regions that live on the visible page. + const activePage = + modifyPages.length > 1 ? modifyPages[modifyActivePageIndex] : null; + for (const name of names) { + if (activePage && modifyRegionPages[name] && modifyRegionPages[name] !== activePage) + continue; const bounds = modifyRegionBounds[name]; if (!bounds) continue; const [bx, by, bw, bh, rotate] = bounds; diff --git a/ui/style.css b/ui/style.css index 96a650d..b70e8ff 100644 --- a/ui/style.css +++ b/ui/style.css @@ -376,6 +376,42 @@ img#preview-img { background-color: #1e1e1e; } +/* Multi-page switcher */ +#modify-page-switcher { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +#page-indicator { + font-size: 12px; + color: #ccc; + white-space: nowrap; + min-width: 64px; + text-align: center; +} +.page-nav-btn { + height: 24px; + width: 24px; + padding: 0; + font-size: 16px; + line-height: 1; + background-color: #3c3c3c; + color: #ccc; + border-color: #555; +} +.page-nav-btn:hover:not(:disabled) { + background-color: #4c4c4c; + color: white; + border-color: #666; +} +.page-nav-btn:disabled { + background-color: #333; + color: #555; + cursor: not-allowed; + border-color: #444; +} + /* Modal Styling */ .hidden { display: none !important; From b8357ccf994733c007a1db400a2ea9f7837bcdbd Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 12:32:55 +0700 Subject: [PATCH 05/18] fix: hide repack toggle in multi-page modify mode The multi-page path always repacks all pages (ignores the repack flag), and toggling the checkbox after a multi-page merge hit toggle_repack's None guard (_pre_repack_image is unset on the multi-page path) and showed a misleading "No merged data to repack" error. Hide #repack-options for >1-page atlases in setupModifyPages; single-page modify still shows it. Asserted in the UI smoke. Co-Authored-By: Claude Opus 4.8 --- ui/script.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/script.js b/ui/script.js index 0d1fb4f..6db1968 100644 --- a/ui/script.js +++ b/ui/script.js @@ -196,11 +196,16 @@ function setupModifyPages(data) { modifyRegionPages = data.regionPages || {}; modifyActivePageIndex = 0; const switcher = document.getElementById("modify-page-switcher"); + const repackOptions = document.getElementById("repack-options"); if (modifyPages.length > 1) { switcher.classList.remove("hidden"); updatePageIndicator(); + // Multi-page always repacks all pages; the per-page repack toggle is + // inert here (and toggling it post-merge errors), so hide it. + repackOptions.classList.add("hidden"); } else { switcher.classList.add("hidden"); + repackOptions.classList.remove("hidden"); } } From f97a0b2987feeeeb3a41903f8ef325b9d8c6e0ea Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 13:04:45 +0700 Subject: [PATCH 06/18] feat: onedir standalone + Inno Setup installer, installer-based self-update Switch Windows packaging from a Nuitka onefile exe to a standalone (onedir) build wrapped in a per-user Inno Setup installer, and rework self-update to download and silently run that installer. Build (.github/workflows/build_release.yml): - Nuitka mode onefile -> standalone; rename main.exe -> AtlasToolkit.exe with a fail-fast guard (output-file is ignored in standalone mode). - Sign the standalone exe, build the installer with Inno Setup (ISCC), sign the installer, and produce a portable zip of the standalone dir. - Release assets: AtlasToolkit-Setup-x64.exe + AtlasToolkit-Windows-x64-portable.zip. Installer (AtlasToolkit.iss, new): - Per-user install (PrivilegesRequired=lowest, DefaultDirName={localappdata}), fixed AppId for in-place upgrades, [Run] relaunch flagged skipifsilent so the updater owns the silent-update relaunch. CloseApplications via Restart Manager. Self-update (updater.py, main.py): - Download the installer asset (find_windows_installer_asset / download_update_asset). - restart_and_install_update now writes a detached .cmd that waits for the app to exit, runs the installer /VERYSILENT (with /LOG), then relaunches the installed app or relaunches with a failure notice; the cmd self-cleans. Avoids the Restart-Manager-kills-the-helper problem of running the helper from the app exe. - Portable builds are gated out of silent self-update (_is_installed_build) and pointed at the releases page instead. - Delete the obsolete onefile self_update_helper.py and its launch dispatch. Verified on Linux: find_windows_installer_asset (mock release) and the _build_update_script shape (silent flags, pid-wait, relaunch + failure branch, self-delete); plus full atlas regression suite still green. The Windows build, Inno compile, and silent-update round-trip can only be verified on Windows. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build_release.yml | 112 ++++-- AtlasToolkit.iss | 57 +++ main.py | 226 +++++++----- self_update_helper.py | 528 ---------------------------- updater.py | 31 +- 5 files changed, 297 insertions(+), 657 deletions(-) create mode 100644 AtlasToolkit.iss delete mode 100644 self_update_helper.py diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index 776c842..a9188ee 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -72,18 +72,17 @@ jobs: $version | Out-File -FilePath VERSION -Encoding utf8 -NoNewline echo "Building version: $version" - - name: Build with Nuitka 🏗️ + - name: Build with Nuitka (standalone / onedir) 🏗️ uses: Nuitka/Nuitka-Action@main with: nuitka-version: main script-name: main.py - mode: onefile + mode: standalone windows-console-mode: ${{ env.CONSOLE_MODE }} windows-icon-from-ico: ui/icon.ico include-data-dir: ui=ui include-data-files: | VERSION=VERSION - self_update_helper.py=self_update_helper.py include-package-data: webview nofollow-import-to: | PyQt5 @@ -93,8 +92,22 @@ jobs: tkinter gi output-dir: dist - output-file: AtlasToolkit.exe - onefile-tempdir-spec: "{TEMP}/AtlasToolkit" + + - name: Normalize standalone exe name 🔧 + shell: pwsh + run: | + $dist = "dist/main.dist" + # standalone mode names the exe after the script (main.exe); output-file + # is ignored for standalone, so rename to the user-facing name. + if (Test-Path "$dist/main.exe") { + Rename-Item -Path "$dist/main.exe" -NewName "AtlasToolkit.exe" -Force + } + if (-not (Test-Path "$dist/AtlasToolkit.exe")) { + Write-Host "Expected exe not found. Contents of ${dist}:" + Get-ChildItem -Recurse $dist | Select-Object FullName + throw "AtlasToolkit.exe missing after standalone build" + } + Write-Host "OK: $dist/AtlasToolkit.exe present" - name: Check signing secrets id: check_secrets @@ -106,7 +119,7 @@ jobs: echo "available=false" >> $GITHUB_OUTPUT fi - - name: Sign Executable + - name: Sign Standalone Executable if: steps.check_secrets.outputs.available == 'true' shell: pwsh env: @@ -114,10 +127,10 @@ jobs: CERT_PASS: ${{ secrets.CERT_PASSWORD }} run: | $certPath = Join-Path $env:RUNNER_TEMP "cert.pfx" - $exePath = "dist/AtlasToolkit.exe" + $exePath = "dist/main.dist/AtlasToolkit.exe" - $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin\" -Filter signtool.exe -Recurse | - Where-Object { $_.FullName -match "x64" } | + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin\" -Filter signtool.exe -Recurse | + Where-Object { $_.FullName -match "x64" } | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName try { @@ -129,9 +142,9 @@ jobs: certutil -p $env:CERT_PASS -dump $certPath | Out-Null if ($LASTEXITCODE -ne 0) { throw "Incorrect password or corrupted PFX file." } - Write-Host "Signing executable..." + Write-Host "Signing standalone executable..." & $signtool sign /f $certPath /p "$env:CERT_PASS" /tr http://timestamp.digicert.com /td sha256 /fd sha256 /v $exePath - + if ($LASTEXITCODE -ne 0) { throw "SignTool failed with code: $LASTEXITCODE" } } catch { @@ -142,35 +155,76 @@ jobs: if (Test-Path $certPath) { Remove-Item $certPath -Force } } - - name: Prepare Assets for Upload 📦 - shell: powershell - id: prepare_assets + - name: Install Inno Setup 🧰 + shell: pwsh + run: choco install innosetup --no-progress -y + + - name: Build Installer (Inno Setup) 📦 + shell: pwsh run: | - $exePath = "dist/AtlasToolkit.exe" - # Release (from workflow_call or tag push) - if ("${{ env.IS_RELEASE }}" -eq 'true') { - Compress-Archive -Path $exePath -DestinationPath "AtlasToolkit-Windows-x64.zip" - echo "ARTIFACT_PATH=AtlasToolkit-Windows-x64.zip" >> $env:GITHUB_OUTPUT + $version = "${{ steps.build_version.outputs.version }}" + $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { throw "ISCC.exe not found at $iscc" } + & $iscc "/DMyAppVersion=$version" AtlasToolkit.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with code: $LASTEXITCODE" } + if (-not (Test-Path "installer/AtlasToolkit-Setup-x64.exe")) { + throw "Installer not produced at installer/AtlasToolkit-Setup-x64.exe" + } + + - name: Sign Installer + if: steps.check_secrets.outputs.available == 'true' + shell: pwsh + env: + CERT_DATA: ${{ secrets.CERT_BASE64 }} + CERT_PASS: ${{ secrets.CERT_PASSWORD }} + run: | + $certPath = Join-Path $env:RUNNER_TEMP "cert.pfx" + $exePath = "installer/AtlasToolkit-Setup-x64.exe" + + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin\" -Filter signtool.exe -Recurse | + Where-Object { $_.FullName -match "x64" } | + Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + + try { + $cleanBase64 = $env:CERT_DATA -replace '\s','' + $certBytes = [System.Convert]::FromBase64String($cleanBase64) + [System.IO.File]::WriteAllBytes($certPath, $certBytes) + + Write-Host "Signing installer..." + & $signtool sign /f $certPath /p "$env:CERT_PASS" /tr http://timestamp.digicert.com /td sha256 /fd sha256 /v $exePath + if ($LASTEXITCODE -ne 0) { throw "SignTool failed with code: $LASTEXITCODE" } } - # Manual (workflow_dispatch) - else { - echo "ARTIFACT_NAME=AtlasToolkit-Windows-x64" >> $env:GITHUB_OUTPUT - echo "ARTIFACT_PATH=$exePath" >> $env:GITHUB_OUTPUT + catch { + Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red + exit 1 } + finally { + if (Test-Path $certPath) { Remove-Item $certPath -Force } + } + + - name: Create portable zip 📁 + shell: pwsh + run: | + Compress-Archive -Path "dist/main.dist/*" -DestinationPath "AtlasToolkit-Windows-x64-portable.zip" -Force + if (-not (Test-Path "AtlasToolkit-Windows-x64-portable.zip")) { throw "Portable zip not produced" } - - name: Upload Artifact (Testing) 💾 + - name: Upload Artifacts (Testing) 💾 uses: actions/upload-artifact@v4 - if: ${{ steps.prepare_assets.outputs.ARTIFACT_NAME != '' }} + if: ${{ env.IS_RELEASE != 'true' }} with: - name: ${{ steps.prepare_assets.outputs.ARTIFACT_NAME }} - path: ${{ steps.prepare_assets.outputs.ARTIFACT_PATH }} + name: AtlasToolkit-Windows-x64 + path: | + installer/AtlasToolkit-Setup-x64.exe + AtlasToolkit-Windows-x64-portable.zip - - name: Upload Release Asset 📤 + - name: Upload Release Assets 📤 if: ${{ env.IS_RELEASE == 'true' }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} target_commitish: ${{ github.sha }} - files: AtlasToolkit-Windows-x64.zip + files: | + installer/AtlasToolkit-Setup-x64.exe + AtlasToolkit-Windows-x64-portable.zip generate_release_notes: true token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss new file mode 100644 index 0000000..fa0bad8 --- /dev/null +++ b/AtlasToolkit.iss @@ -0,0 +1,57 @@ +; AtlasToolkit — Inno Setup script (per-user install, no admin required). +; Version is injected by CI: ISCC.exe /DMyAppVersion=1.2.3 AtlasToolkit.iss +; Packages the Nuitka standalone output in dist\main.dist into a single Setup.exe. + +#ifndef MyAppVersion + #define MyAppVersion "0.0.0" +#endif + +#define MyAppName "AtlasToolkit" +#define MyAppExeName "AtlasToolkit.exe" +#define MyAppPublisher "com55" +#define MyAppURL "https://github.com/com55/AtlasToolkit" + +[Setup] +; AppId MUST stay constant across releases so upgrades replace in place (never change it). +AppId={{AADE8604-EC9B-491C-92FA-D0628C934556} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL}/releases +; Per-user install — no admin / no UAC, so silent self-update works unattended. +PrivilegesRequired=lowest +DefaultDirName={localappdata}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +OutputDir=installer +OutputBaseFilename=AtlasToolkit-Setup-x64 +SetupIconFile=ui\icon.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +; During a silent self-update, close the running app via Restart Manager; the +; updater cmd handles relaunch, so don't let Inno restart it (avoids double-launch). +CloseApplications=yes +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "dist\main.dist\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; Interactive install only — the silent self-update relaunch is owned by the updater cmd. +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent diff --git a/main.py b/main.py index 06b39a8..3046ae8 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,6 @@ import os import base64 import json -import runpy import shutil import subprocess import webbrowser @@ -19,8 +18,8 @@ from atlas_modifier import AtlasModifier from updater import ( check_for_updates, - download_update_zip, - find_windows_asset, + download_update_asset, + find_windows_installer_asset, get_current_version, get_latest_release_info, is_running_as_exe, @@ -30,37 +29,12 @@ def get_resource_path(path: str) -> Path: """Get path to a resource file embedded in the executable. - In Nuitka onefile mode, ``__file__`` resolves to the temporary directory - where embedded resources are unpacked. + In Nuitka standalone mode, ``__file__`` resolves to the install directory + where embedded resources sit next to the executable. """ return Path(__file__).parent / path -def _resolve_helper_launch() -> tuple[Optional[Path], list[str]]: - """Resolve helper script path and passthrough args for helper bootstrap.""" - if len(sys.argv) <= 1: - return None, [] - - arg1 = sys.argv[1] - if Path(arg1).name == "self_update_helper.py": - candidate = Path(arg1) - if not candidate.is_absolute(): - candidate = Path.cwd() / candidate - return candidate, sys.argv[2:] - - return None, [] - - -_helper_path, _helper_args = _resolve_helper_launch() -if _helper_path is not None: - if not _helper_path.exists(): - raise SystemExit(f"Update helper script not found: {_helper_path}") - - sys.argv = [str(_helper_path), *_helper_args] - runpy.run_path(str(_helper_path), run_name="__main__") - raise SystemExit(0) - - def _consume_launch_flags(argv: list[str]) -> tuple[list[str], Optional[dict[str, str]]]: """Consume internal launch flags and return user args + optional failure payload.""" clean_args: list[str] = [] @@ -275,6 +249,85 @@ def _get_running_executable_path() -> Path: return Path(sys.executable).resolve() +def _install_dir() -> Path: + """The per-user install directory the Inno Setup installer targets.""" + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return (base / "AtlasToolkit").resolve() + + +def _is_installed_build() -> bool: + """True when running from the installed location (vs a portable copy). + + Only installed builds get the silent installer self-update; portable copies + are pointed at the releases page instead (a silent install would replace a + different folder and orphan the portable exe). + """ + if not is_running_as_exe(): + return False + try: + exe = _get_running_executable_path().resolve() + install_dir = _install_dir() + return exe.parent == install_dir or install_dir in exe.parents + except Exception: + return False + + +# Silent installer flags: no UI, no msgboxes, don't reboot, close the running +# app via Restart Manager, and don't let Inno restart it (the script relaunches). +_INNO_SILENT_FLAGS = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS" + + +def _build_update_script( + installer_path: Path, + target_exe: Path, + pid: int, + relaunch_args: list[str], + release_url: str, + inno_log_path: Path, +) -> str: + """Build the detached .cmd that waits for the app to exit, runs the Inno + installer silently, then relaunches the freshly-installed app (or relaunches + with a failure notice). Returned as text so it can be unit-tested off-Windows. + """ + inst = str(installer_path) + exe = str(target_exe) + log = str(inno_log_path) + relaunch_suffix = " ".join(f'"{a}"' for a in relaunch_args) + success_launch = f'start "" "{exe}" {relaunch_suffix}'.rstrip() + + fail_message = "Update failed: the installer reported an error." + fail_args = [ + "--update-install-failed", + "--update-failed-message", f'"{fail_message}"', + "--update-failed-log", f'"{log}"', + ] + if release_url: + fail_args += ["--update-release-url", f'"{release_url}"'] + fail_launch = f'start "" "{exe}" ' + " ".join(fail_args) + + lines = [ + "@echo off", + "setlocal", + ":waitloop", + f'tasklist /FI "PID eq {pid}" 2>nul | find "{pid}" >nul', + "if not errorlevel 1 (", + " ping -n 2 127.0.0.1 >nul", + " goto waitloop", + ")", + f'"{inst}" {_INNO_SILENT_FLAGS} /LOG="{log}"', + "if errorlevel 1 goto failed", + success_launch, + "goto cleanup", + ":failed", + fail_launch, + ":cleanup", + f'del /f /q "{inst}" >nul 2>nul', + 'del /f /q "%~f0" >nul 2>nul', + "endlocal", + ] + return "\r\n".join(lines) + "\r\n" + + class Api: def __init__(self) -> None: self._atlas_path: Optional[Path] = None @@ -292,7 +345,7 @@ def __init__(self) -> None: # Persistent config self._config: dict[str, Any] = self._load_config() # Update state - self._update_zip_path: Optional[Path] = None + self._update_installer_path: Optional[Path] = None self._update_version: Optional[str] = None self._update_release_url: Optional[str] = None self._update_ready: bool = False @@ -936,14 +989,19 @@ def get_update_download_progress(self) -> dict[str, Any]: return dict(self._update_progress) def download_update(self) -> dict[str, Any]: - """Download the Windows update zip into a temp/update folder.""" + """Download the Windows installer (Setup.exe) into the update folder.""" if not is_running_as_exe(): return { "ok": False, "error": "Dev mode does not support self-update install flow.", } + if not _is_installed_build(): + return { + "ok": False, + "error": "Portable build does not support silent self-update. Use the releases page.", + } - self._update_zip_path = None + self._update_installer_path = None self._update_version = None self._update_release_url = None self._update_ready = False @@ -956,14 +1014,14 @@ def download_update(self) -> dict[str, Any]: try: latest = get_latest_release_info() - asset = find_windows_asset(latest.assets) + asset = find_windows_installer_asset(latest.assets) update_dir = _get_update_dir() safe_tag = "".join( c if c.isalnum() or c in "._-" else "_" for c in (latest.tag_name or latest.latest_version or "latest") ) - target_zip_path = update_dir / f"{safe_tag}-{asset.name}" + target_installer_path = update_dir / f"{safe_tag}-{asset.name}" def _progress(downloaded: int, total: Optional[int]) -> None: percent = int((downloaded * 100) / total) if total and total > 0 else 0 @@ -974,16 +1032,16 @@ def _progress(downloaded: int, total: Optional[int]) -> None: percent=max(0, min(100, percent)), ) - download_update_zip( + download_update_asset( download_url=asset.browser_download_url, - target_zip_path=target_zip_path, + target_path=target_installer_path, progress_cb=_progress, ) target_exe = _get_running_executable_path() metadata = { - "zip_path": str(target_zip_path), + "installer_path": str(target_installer_path), "target_exe_path": str(target_exe), "relaunch_args": sys.argv[1:], "version": latest.latest_version, @@ -995,11 +1053,11 @@ def _progress(downloaded: int, total: Optional[int]) -> None: encoding="utf-8", ) - self._update_zip_path = target_zip_path + self._update_installer_path = target_installer_path self._update_version = latest.latest_version self._update_release_url = latest.release_url self._update_ready = True - size = target_zip_path.stat().st_size + size = target_installer_path.stat().st_size self._set_update_progress( status="ready", downloaded_bytes=size, @@ -1010,7 +1068,7 @@ def _progress(downloaded: int, total: Optional[int]) -> None: return { "ok": True, "version": latest.latest_version, - "downloaded_path": str(target_zip_path), + "downloaded_path": str(target_installer_path), } except Exception as e: msg = str(e) or "Unknown update download error" @@ -1024,76 +1082,62 @@ def _progress(downloaded: int, total: Optional[int]) -> None: return {"ok": False, "error": msg} def restart_and_install_update(self) -> dict[str, Any]: - """Spawn detached helper process that installs zip and relaunches app.""" + """Spawn a detached cmd that runs the installer silently and relaunches the app.""" if not is_running_as_exe(): return { "ok": False, "error": "Dev mode does not support restart-and-install self-update.", } - if not self._update_ready or not self._update_zip_path: + if not self._update_ready or not self._update_installer_path: return { "ok": False, "error": "No downloaded update found. Please download update first.", } - zip_path = self._update_zip_path - if not zip_path.exists() or zip_path.stat().st_size <= 0: + installer_path = self._update_installer_path + if not installer_path.exists() or installer_path.stat().st_size <= 0: return { "ok": False, - "error": "Downloaded update zip is missing or invalid.", - } - - helper_path = get_resource_path("self_update_helper.py") - if not helper_path.exists(): - return { - "ok": False, - "error": f"Update helper script not found: {helper_path}", + "error": "Downloaded installer is missing or invalid.", } target_exe = _get_running_executable_path() - launcher_exe = target_exe - if not launcher_exe.exists() or not launcher_exe.is_file(): + if not target_exe.exists() or not target_exe.is_file(): return { "ok": False, - "error": f"Cannot locate executable for updater launch: {launcher_exe}", + "error": f"Cannot locate executable for relaunch: {target_exe}", } - relaunch_args_b64 = base64.b64encode( - json.dumps(sys.argv[1:], ensure_ascii=True).encode("utf-8") - ).decode("ascii") - release_url = self._update_release_url or "" - - cmd = [ - str(launcher_exe), - str(helper_path), - "--zip", - str(zip_path), - "--target-exe", - str(target_exe), - "--work-dir", - str(target_exe.parent), - "--relaunch-args-b64", - relaunch_args_b64, - "--pid", - str(os.getpid()), - ] - if release_url: - cmd.extend(["--release-url", release_url]) + update_dir = _get_update_dir() + timestamp = time.strftime("%Y%m%d_%H%M%S") + inno_log_path = update_dir / f"inno_install_{timestamp}.log" + script_text = _build_update_script( + installer_path=installer_path, + target_exe=target_exe, + pid=os.getpid(), + relaunch_args=list(sys.argv[1:]), + release_url=self._update_release_url or "", + inno_log_path=inno_log_path, + ) + script_path = update_dir / f"install_update_{timestamp}_{os.getpid()}.cmd" try: + script_path.write_text(script_text, encoding="utf-8") + + cmd_exe = os.environ.get("COMSPEC") or "cmd" popen_kwargs: dict[str, Any] = { - "cwd": str(launcher_exe.parent), + "cwd": str(update_dir), "close_fds": True, } - if sys.platform == "win32": - creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - popen_kwargs["creationflags"] = creationflags + popen_kwargs["creationflags"] = ( + subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + ) else: popen_kwargs["start_new_session"] = True - subprocess.Popen(cmd, **popen_kwargs) + subprocess.Popen([cmd_exe, "/d", "/c", str(script_path)], **popen_kwargs) if self._window: self._window.destroy() @@ -1102,7 +1146,7 @@ def restart_and_install_update(self) -> dict[str, Any]: except Exception as e: return { "ok": False, - "error": f"Failed to launch update helper: {e}", + "error": f"Failed to launch update installer: {e}", } def _run_update_check(self) -> None: @@ -1110,13 +1154,25 @@ def _run_update_check(self) -> None: try: info = check_for_updates() if info and self._window: + # Installed build → silent in-app installer update. + # Portable build → open the releases page (no silent install). + # Dev → open the source tree at the tag. + if _is_installed_build(): + action = "download" + source_tree_url = info.source_tree_url + elif is_running_as_exe(): + action = "open_source_tag" + source_tree_url = info.release_url + else: + action = "open_source_tag" + source_tree_url = info.source_tree_url payload = { "latestVersion": info.latest_version, "releaseName": info.release_name, "releaseUrl": info.release_url, "tagName": info.tag_name, - "sourceTreeUrl": info.source_tree_url, - "action": "download" if is_running_as_exe() else "open_source_tag", + "sourceTreeUrl": source_tree_url, + "action": action, } args_json = json.dumps(payload) self._window.evaluate_js( diff --git a/self_update_helper.py b/self_update_helper.py deleted file mode 100644 index 5acd012..0000000 --- a/self_update_helper.py +++ /dev/null @@ -1,528 +0,0 @@ -from __future__ import annotations - -import argparse -import base64 -import json -import logging -import os -import shutil -import subprocess -import sys -import tempfile -import time -import zipfile -from datetime import datetime -from pathlib import Path -from typing import Sequence - -log = logging.getLogger("atlas_update_helper") - - -def _get_update_dir() -> Path: - if os.name == "nt": - base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - else: - base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - d = base / "AtlasToolkit" / "update" - d.mkdir(parents=True, exist_ok=True) - return d - - -def setup_logging() -> Path: - update_dir = _get_update_dir() - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - log_path = update_dir / f"self_update_{timestamp}.log" - - formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s") - - root = logging.getLogger() - root.setLevel(logging.INFO) - root.handlers.clear() - - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.setFormatter(formatter) - root.addHandler(stdout_handler) - - file_handler = logging.FileHandler(log_path, encoding="utf-8") - file_handler.setFormatter(formatter) - root.addHandler(file_handler) - - return log_path - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="AtlasToolkit self-update helper") - parser.add_argument("--zip", dest="zip_path", required=True) - parser.add_argument("--target-exe", dest="target_exe", required=True) - parser.add_argument("--work-dir", dest="work_dir", required=True) - parser.add_argument("--relaunch-args-b64", dest="relaunch_args_b64", default="") - parser.add_argument("--pid", dest="pid", type=int, default=0) - parser.add_argument("--release-url", dest="release_url", default="") - return parser.parse_args() - - -def decode_relaunch_args(encoded: str) -> list[str]: - if not encoded: - return [] - try: - raw = base64.b64decode(encoded.encode("ascii"), validate=True) - parsed = json.loads(raw.decode("utf-8")) - if isinstance(parsed, list): - return [str(item) for item in parsed] - except Exception as e: - log.warning("Failed to decode relaunch args: %s", e) - return [] - - -def wait_for_process_exit(pid: int, timeout_seconds: int = 45) -> bool: - if pid <= 0: - return True - - deadline = time.time() + timeout_seconds - - if os.name == "nt": - try: - import ctypes - - SYNCHRONIZE = 0x00100000 - WAIT_OBJECT_0 = 0x00000000 - WAIT_TIMEOUT = 0x00000102 - - kernel32 = ctypes.windll.kernel32 - proc_handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) - if not proc_handle: - return True - try: - remaining_ms = max(0, int((deadline - time.time()) * 1000)) - result = kernel32.WaitForSingleObject(proc_handle, remaining_ms) - return result in (WAIT_OBJECT_0,) - finally: - kernel32.CloseHandle(proc_handle) - except Exception as e: - log.warning("Windows process wait failed; using polling fallback: %s", e) - - while time.time() < deadline: - try: - os.kill(pid, 0) - except ProcessLookupError: - return True - except PermissionError: - return True - time.sleep(0.2) - - return False - - -def extract_update_zip(zip_path: Path) -> Path: - extract_dir = Path(tempfile.mkdtemp(prefix="atlas_update_extract_")) - try: - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - except zipfile.BadZipFile: - shutil.rmtree(extract_dir, ignore_errors=True) - raise - return extract_dir - - -def find_extracted_exe(extract_dir: Path, expected_name: str = "AtlasToolkit.exe") -> Path: - direct = extract_dir / expected_name - if direct.exists(): - return direct - - matches = list(extract_dir.rglob(expected_name)) - if not matches: - raise FileNotFoundError(f"Could not find {expected_name} in extracted update") - return matches[0] - - -def replace_exe(new_exe: Path, target_exe: Path) -> Path | None: - backup_path = target_exe.with_name(target_exe.name + ".old") - backup_created = False - - if backup_path.exists(): - try: - backup_path.unlink() - except Exception: - pass - - if target_exe.exists(): - os.replace(target_exe, backup_path) - backup_created = True - - try: - try: - os.replace(new_exe, target_exe) - except Exception: - shutil.copy2(new_exe, target_exe) - except Exception: - if backup_created and backup_path.exists() and not target_exe.exists(): - try: - os.replace(backup_path, target_exe) - except Exception: - pass - raise - - return backup_path if backup_created else None - - -def _build_relaunch_env() -> dict[str, str]: - env = os.environ.copy() - removed = [k for k in list(env.keys()) if k.startswith("NUITKA_ONEFILE")] - for key in removed: - env.pop(key, None) - if removed: - log.info("Relaunch env stripped keys: %s", removed) - return env - - -def _write_windows_relaunch_script( - exe_path: Path, - work_dir: Path, - relaunch_args: Sequence[str], - backup_path: Path | None, - cleanup_zip_path: Path | None, - cleanup_extract_dir: Path | None, -) -> Path: - update_dir = _get_update_dir() - script_path = update_dir / f"relaunch_{int(time.time() * 1000)}_{os.getpid()}.cmd" - launch_cmd = subprocess.list2cmdline([str(exe_path), *[str(a) for a in relaunch_args]]) - - lines = [ - "@echo off", - "setlocal", - f'pushd "{str(work_dir)}" >nul 2>nul', - f"start \"\" {launch_cmd}", - ] - - if backup_path is not None: - backup_text = str(backup_path) - lines.extend( - [ - f'if not exist "{backup_text}" goto :after_cleanup', - "for /L %%I in (1,1,20) do (", - f' if not exist "{backup_text}" goto :after_cleanup', - f' del /f /q "{backup_text}" >nul 2>nul', - " timeout /t 1 /nobreak >nul", - ")", - ":after_cleanup", - ] - ) - - if cleanup_zip_path is not None: - lines.append(f'del /f /q "{str(cleanup_zip_path)}" >nul 2>nul') - - if cleanup_extract_dir is not None: - lines.append(f'rmdir /s /q "{str(cleanup_extract_dir)}" >nul 2>nul') - - lines.extend([ - 'del /f /q "%~f0" >nul 2>nul', - "endlocal", - ]) - - script_path.write_text("\r\n".join(lines) + "\r\n", encoding="utf-8") - return script_path - - -def relaunch( - exe_path: Path, - work_dir: Path, - relaunch_args: Sequence[str], - backup_path: Path | None = None, - cleanup_zip_path: Path | None = None, - cleanup_extract_dir: Path | None = None, -) -> bool: - full_cmd = [str(exe_path), *[str(a) for a in relaunch_args]] - bare_cmd = [str(exe_path)] - cwd = str(work_dir) - relaunch_env = _build_relaunch_env() - - if os.name == "nt": - cmd_exe = relaunch_env.get("COMSPEC") or "cmd" - - try: - script_path = _write_windows_relaunch_script( - exe_path, - work_dir, - relaunch_args, - backup_path, - cleanup_zip_path, - cleanup_extract_dir, - ) - primary_cmd = [cmd_exe, "/d", "/c", str(script_path)] - proc = subprocess.Popen( - primary_cmd, - cwd=cwd, - close_fds=True, - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, - env=relaunch_env, - ) - log.info( - "Relaunch primary via cmd script started pid=%s cmd=%s script=%s", - proc.pid, - primary_cmd, - script_path, - ) - return True - except Exception as e: - log.warning("Relaunch primary cmd script failed: %s", e) - - # Fallback to direct executable launch strategies. - attempts = [ - ( - "with-args detached", - full_cmd, - subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, - ), - ( - "with-args new-process-group", - full_cmd, - subprocess.CREATE_NEW_PROCESS_GROUP, - ), - ( - "no-args detached", - bare_cmd, - subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, - ), - ( - "no-args new-process-group", - bare_cmd, - subprocess.CREATE_NEW_PROCESS_GROUP, - ), - ] - - for idx, (label, cmd, creationflags) in enumerate(attempts, start=1): - proc = subprocess.Popen( - cmd, - cwd=cwd, - close_fds=True, - creationflags=creationflags, - env=relaunch_env, - ) - log.info( - "Relaunch attempt %s (%s) started pid=%s flags=0x%X cmd=%s", - idx, - label, - proc.pid, - creationflags, - cmd, - ) - - # If process exits immediately, try a different launch strategy. - time.sleep(1.0) - rc = proc.poll() - if rc is None: - return False - - log.warning( - "Relaunch attempt %s (%s) exited immediately with code %s", - idx, - label, - rc, - ) - - raise RuntimeError("All Windows relaunch attempts failed") - else: - proc = subprocess.Popen( - full_cmd, - cwd=cwd, - close_fds=True, - start_new_session=True, - env=relaunch_env, - ) - log.info("Relaunch started pid=%s", proc.pid) - return False - - -def relaunch_with_failure_notice( - exe_path: Path, - work_dir: Path, - relaunch_args: Sequence[str], - log_path: Path, - failure_message: str, - release_url: str, -) -> None: - args = [ - *[str(a) for a in relaunch_args], - "--update-install-failed", - "--update-failed-log", - str(log_path), - "--update-failed-message", - failure_message, - ] - if release_url: - args.extend(["--update-release-url", release_url]) - relaunch(exe_path, work_dir, args) - - -def safe_cleanup( - zip_path: Path, - extract_dir: Path, - backup_path: Path | None, - *, - remove_backup: bool, -) -> None: - try: - if zip_path.exists(): - zip_path.unlink() - except Exception: - pass - - shutil.rmtree(extract_dir, ignore_errors=True) - - if remove_backup and backup_path and backup_path.exists(): - try: - backup_path.unlink() - log.info("Removed backup executable: %s", backup_path) - except PermissionError: - if os.name == "nt": - cmd_exe = os.environ.get("COMSPEC") or "cmd" - delayed_delete = ( - f'timeout /t 3 /nobreak >nul & del /f /q "{str(backup_path)}"' - ) - try: - subprocess.Popen( - [cmd_exe, "/c", delayed_delete], - close_fds=True, - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, - ) - log.info("Scheduled delayed backup deletion: %s", backup_path) - except Exception as e: - log.warning("Failed to schedule delayed backup deletion %s: %s", backup_path, e) - else: - log.warning("Backup executable still exists after cleanup: %s", backup_path) - except Exception as e: - log.warning("Failed to remove backup executable %s: %s", backup_path, e) - - -def _pick_relaunch_target(target_exe: Path, backup_path: Path | None) -> Path: - if target_exe.exists(): - return target_exe - if backup_path and backup_path.exists(): - return backup_path - return target_exe - - -def main() -> int: - args = parse_args() - log_path = setup_logging() - - zip_path = Path(args.zip_path) - target_exe = Path(args.target_exe) - work_dir = Path(args.work_dir) - relaunch_args = decode_relaunch_args(args.relaunch_args_b64) - release_url = str(args.release_url or "") - - log.info("Starting self-update helper") - log.info("zip=%s target_exe=%s pid=%s", zip_path, target_exe, args.pid) - - if not zip_path.exists() or zip_path.stat().st_size <= 0: - msg = f"Update zip does not exist or is empty: {zip_path}" - log.error(msg) - try: - relaunch_with_failure_notice( - target_exe, - work_dir, - relaunch_args, - log_path, - "Update failed: downloaded package is missing or empty.", - release_url, - ) - except Exception as e: - log.error("Fallback relaunch failed: %s", e) - return 2 - - if not wait_for_process_exit(args.pid): - msg = f"Timed out waiting for old process to exit: pid={args.pid}" - log.error(msg) - try: - relaunch_with_failure_notice( - target_exe, - work_dir, - relaunch_args, - log_path, - "Update failed: timed out while waiting for app shutdown.", - release_url, - ) - except Exception as e: - log.error("Fallback relaunch failed: %s", e) - return 3 - - extract_dir: Path | None = None - backup_path: Path | None = None - succeeded = False - cleanup_delegated = False - - try: - extract_dir = extract_update_zip(zip_path) - new_exe = find_extracted_exe(extract_dir) - backup_path = replace_exe(new_exe, target_exe) - cleanup_delegated = relaunch( - target_exe, - work_dir, - relaunch_args, - backup_path=backup_path, - cleanup_zip_path=zip_path, - cleanup_extract_dir=extract_dir, - ) - succeeded = True - if cleanup_delegated: - log.info("Relaunch delegated to cmd script; exiting helper immediately") - return 0 - except zipfile.BadZipFile: - log.error("Downloaded update zip is corrupted") - try: - relaunch_with_failure_notice( - _pick_relaunch_target(target_exe, backup_path), - work_dir, - relaunch_args, - log_path, - "Update failed: update package is corrupted.", - release_url, - ) - except Exception as e: - log.error("Fallback relaunch failed: %s", e) - return 4 - except PermissionError as e: - log.error("Permission denied while replacing executable: %s", e) - try: - relaunch_with_failure_notice( - _pick_relaunch_target(target_exe, backup_path), - work_dir, - relaunch_args, - log_path, - "Update failed: no permission to replace executable.", - release_url, - ) - except Exception as e2: - log.error("Fallback relaunch failed: %s", e2) - return 5 - except Exception as e: - log.error("Update installation failed: %s", e) - try: - relaunch_with_failure_notice( - _pick_relaunch_target(target_exe, backup_path), - work_dir, - relaunch_args, - log_path, - "Update failed: unexpected installation error.", - release_url, - ) - except Exception as e2: - log.error("Fallback relaunch failed: %s", e2) - return 6 - finally: - if extract_dir and not cleanup_delegated: - safe_cleanup( - zip_path, - extract_dir, - backup_path, - remove_backup=succeeded, - ) - - log.info("Self-update completed successfully") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/updater.py b/updater.py index 44609db..6b63787 100644 --- a/updater.py +++ b/updater.py @@ -13,7 +13,8 @@ GITHUB_REPO = "com55/AtlasToolkit" GITHUB_API_LATEST = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" GITHUB_RELEASES_PAGE = f"https://github.com/{GITHUB_REPO}/releases/latest" -WINDOWS_ZIP_ASSET_NAME = "AtlasToolkit-Windows-x64.zip" +# The self-update flow downloads and silently runs the Inno Setup installer. +WINDOWS_INSTALLER_ASSET_NAME = "AtlasToolkit-Setup-x64.exe" class ReleaseAsset(NamedTuple): @@ -131,24 +132,24 @@ def get_latest_release_info() -> ReleaseInfo: ) -def find_windows_asset(assets: list[ReleaseAsset]) -> ReleaseAsset: - """Find the exact Windows zip asset produced by CI.""" +def find_windows_installer_asset(assets: list[ReleaseAsset]) -> ReleaseAsset: + """Find the Windows installer (Setup.exe) asset produced by CI.""" for asset in assets: - if asset.name == WINDOWS_ZIP_ASSET_NAME: + if asset.name == WINDOWS_INSTALLER_ASSET_NAME: return asset raise FileNotFoundError( - f"Release asset '{WINDOWS_ZIP_ASSET_NAME}' was not found in latest release" + f"Release asset '{WINDOWS_INSTALLER_ASSET_NAME}' was not found in latest release" ) -def download_update_zip( +def download_update_asset( download_url: str, - target_zip_path: Path, + target_path: Path, progress_cb: Optional[Callable[[int, Optional[int]], None]] = None, ) -> Path: - """Download update zip to a target path using streaming I/O.""" - target_zip_path.parent.mkdir(parents=True, exist_ok=True) - temp_path = target_zip_path.with_suffix(target_zip_path.suffix + ".part") + """Download an update asset (the installer exe) to a target path via streaming I/O.""" + target_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = target_path.with_suffix(target_path.suffix + ".part") downloaded = 0 total: Optional[int] = None @@ -179,15 +180,15 @@ def download_update_zip( pass raise - temp_path.replace(target_zip_path) + temp_path.replace(target_path) - if not target_zip_path.exists() or target_zip_path.stat().st_size <= 0: - raise IOError("Downloaded update zip is missing or empty") + if not target_path.exists() or target_path.stat().st_size <= 0: + raise IOError("Downloaded update asset is missing or empty") if progress_cb: - progress_cb(target_zip_path.stat().st_size, total) + progress_cb(target_path.stat().st_size, total) - return target_zip_path + return target_path def check_for_updates() -> UpdateInfo | None: From e75c7d8545512fa32e023e57d2ac7c6c842cabcc Mon Sep 17 00:00:00 2001 From: com55 Date: Wed, 24 Jun 2026 13:09:34 +0700 Subject: [PATCH 07/18] fix: pin installer dir (DisableDirPage) + document installer migration - AtlasToolkit.iss: DisableDirPage=yes so the install location matches what _is_installed_build() and the silent self-update assume; otherwise a user could install elsewhere and silently never receive auto-updates. - README: note the Windows installer/portable split and the one-time manual reinstall for pre-installer builds (their old auto-update can't find the new asset). Co-Authored-By: Claude Opus 4.8 --- AtlasToolkit.iss | 3 +++ README.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss index fa0bad8..c5f4efa 100644 --- a/AtlasToolkit.iss +++ b/AtlasToolkit.iss @@ -25,6 +25,9 @@ PrivilegesRequired=lowest DefaultDirName={localappdata}\{#MyAppName} DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes +; Force the fixed install location that _is_installed_build() / silent self-update +; assume — without this the user could install elsewhere and never get auto-updates. +DisableDirPage=yes ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 OutputDir=installer diff --git a/README.md b/README.md index e008049..093b868 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ View atlas regions, extract individual sprites, replace sprites with other image - Now, Web version is available [here](https://com55.github.io/AtlasToolkit/)! - You can use the pre-built executables or download stable version source code from [Releases](https://github.com/com55/AtlasToolkit/releases/latest). + - Windows is distributed as an **installer** (`AtlasToolkit-Setup-x64.exe`, per-user, no admin) plus a **portable** zip. The installed build updates itself silently in-app; the portable build links to the releases page. + - **One-time migration:** builds made before the installer switch used the old single-exe auto-update and will report "asset not found" when checking for this release — download and run the new installer once manually. - Or, clone the repository and run from source (in-development / unstable): ```bash git clone https://github.com/com55/AtlasToolkit.git From 641415e59ee843bf533308b2bc2e61e19f006903 Mon Sep 17 00:00:00 2001 From: com55 Date: Thu, 25 Jun 2026 10:13:03 +0700 Subject: [PATCH 08/18] feat(installer): optional .atlas file association MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in "Associate .atlas files with AtlasToolkit" task to the installer. When checked, registers a per-user (HKCU via HKA) AtlasToolkit.atlas ProgID with a friendly name, the app icon, and a shell open command that passes the file as %1 — the app already opens sys.argv[1] when it ends in .atlas (startup_check). ChangesAssociations=yes refreshes the shell; entries are removed on uninstall. Co-Authored-By: Claude Opus 4.8 --- AtlasToolkit.iss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss index c5f4efa..258aa61 100644 --- a/AtlasToolkit.iss +++ b/AtlasToolkit.iss @@ -8,6 +8,7 @@ #define MyAppName "AtlasToolkit" #define MyAppExeName "AtlasToolkit.exe" +#define MyAppProgId "AtlasToolkit.atlas" #define MyAppPublisher "com55" #define MyAppURL "https://github.com/com55/AtlasToolkit" @@ -41,16 +42,28 @@ WizardStyle=modern ; updater cmd handles relaunch, so don't let Inno restart it (avoids double-launch). CloseApplications=yes RestartApplications=no +; Notify the shell when the .atlas association changes (refreshes icons / Open With). +ChangesAssociations=yes [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "associate"; Description: "Associate .atlas files with {#MyAppName}"; GroupDescription: "File associations:" [Files] Source: "dist\main.dist\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion +[Registry] +; Per-user .atlas file association (HKA -> HKCU under PrivilegesRequired=lowest). +; Only written when the "associate" task is selected; cleaned up on uninstall. +Root: HKA; Subkey: "Software\Classes\.atlas\OpenWithProgids"; ValueType: string; ValueName: "{#MyAppProgId}"; ValueData: ""; Flags: uninsdeletevalue; Tasks: associate +Root: HKA; Subkey: "Software\Classes\.atlas"; ValueType: string; ValueName: ""; ValueData: "{#MyAppProgId}"; Flags: uninsdeletevalue; Tasks: associate +Root: HKA; Subkey: "Software\Classes\{#MyAppProgId}"; ValueType: string; ValueName: ""; ValueData: "Spine Atlas File"; Flags: uninsdeletekey; Tasks: associate +Root: HKA; Subkey: "Software\Classes\{#MyAppProgId}\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"; Tasks: associate +Root: HKA; Subkey: "Software\Classes\{#MyAppProgId}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Tasks: associate + [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon From 632e6cb3908933749ba2e7e6b4e27cbcf76d03b4 Mon Sep 17 00:00:00 2001 From: com55 Date: Thu, 25 Jun 2026 11:19:24 +0700 Subject: [PATCH 09/18] feat(installer): mutexes, version info, clean upgrade, quick launch + move install dir Add the requested Inno Setup options and fix a latent install-dir collision. Install dir collision fix (prerequisite for [InstallDelete]): - The old DefaultDirName {localappdata}\AtlasToolkit was the SAME folder the app uses for config.json and the update cache. Clearing it on upgrade would wipe config and an in-progress self-update. Move install to {userpf}\AtlasToolkit (= %LOCALAPPDATA%\Programs\AtlasToolkit), separate from the data dir, and update _install_dir() to match so _is_installed_build() still detects installed builds. Inno Setup additions (AtlasToolkit.iss): - AppMutex + a named single-instance mutex created by the app at startup, so the installer reliably detects a running instance during silent self-update. - SetupMutex to block concurrent installer runs. - VersionInfoVersion stamps the Setup.exe metadata. - [InstallDelete] clears {app}\* before [Files] so stale onedir files never linger. - [UninstallRun] removes the update cache on uninstall (keeps config.json). - AllowNoIcons=yes (+ show the Start Menu folder page) and a Quick Launch task. Co-Authored-By: Claude Opus 4.8 --- AtlasToolkit.iss | 25 +++++++++++++++++++++++-- main.py | 23 ++++++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss index 258aa61..5c3f216 100644 --- a/AtlasToolkit.iss +++ b/AtlasToolkit.iss @@ -9,6 +9,7 @@ #define MyAppName "AtlasToolkit" #define MyAppExeName "AtlasToolkit.exe" #define MyAppProgId "AtlasToolkit.atlas" +#define MyAppMutex "AtlasToolkitSingleInstanceMutex" #define MyAppPublisher "com55" #define MyAppURL "https://github.com/com55/AtlasToolkit" @@ -23,9 +24,11 @@ AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL}/releases ; Per-user install — no admin / no UAC, so silent self-update works unattended. PrivilegesRequired=lowest -DefaultDirName={localappdata}\{#MyAppName} +; {userpf} == %LOCALAPPDATA%\Programs — kept separate from the app's data dir +; (%LOCALAPPDATA%\AtlasToolkit) so [InstallDelete] never wipes config / updates. +DefaultDirName={userpf}\{#MyAppName} DefaultGroupName={#MyAppName} -DisableProgramGroupPage=yes +AllowNoIcons=yes ; Force the fixed install location that _is_installed_build() / silent self-update ; assume — without this the user could install elsewhere and never get auto-updates. DisableDirPage=yes @@ -35,9 +38,14 @@ OutputDir=installer OutputBaseFilename=AtlasToolkit-Setup-x64 SetupIconFile=ui\icon.ico UninstallDisplayIcon={app}\{#MyAppExeName} +VersionInfoVersion={#MyAppVersion} Compression=lzma2/max SolidCompression=yes WizardStyle=modern +; Detect a running instance (app holds this named mutex) and block a second +; installer from running concurrently. +AppMutex={#MyAppMutex} +SetupMutex=AtlasToolkitSetupMutex ; During a silent self-update, close the running app via Restart Manager; the ; updater cmd handles relaunch, so don't let Inno restart it (avoids double-launch). CloseApplications=yes @@ -50,8 +58,15 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "associate"; Description: "Associate .atlas files with {#MyAppName}"; GroupDescription: "File associations:" +[InstallDelete] +; Clear the previous install payload before copying the new one so stale Nuitka +; DLLs / data files from an older version never linger (onedir upgrades). +; {app} is the program dir only — the app's config/update data lives elsewhere. +Type: filesandordirs; Name: "{app}\*" + [Files] Source: "dist\main.dist\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion @@ -67,7 +82,13 @@ Root: HKA; Subkey: "Software\Classes\{#MyAppProgId}\shell\open\command"; ValueTy [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon [Run] ; Interactive install only — the silent self-update relaunch is owned by the updater cmd. Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +; On uninstall, drop the update cache (downloaded installers / logs / scripts); +; user config (config.json) in the same data dir is intentionally left intact. +Filename: "{cmd}"; Parameters: "/c rmdir /s /q ""{localappdata}\{#MyAppName}\update"""; Flags: runhidden; RunOnceId: "DelUpdateCache" diff --git a/main.py b/main.py index 3046ae8..12120e5 100644 --- a/main.py +++ b/main.py @@ -250,9 +250,14 @@ def _get_running_executable_path() -> Path: def _install_dir() -> Path: - """The per-user install directory the Inno Setup installer targets.""" + """The per-user install directory the Inno Setup installer targets. + + {userpf}\\AtlasToolkit == %LOCALAPPDATA%\\Programs\\AtlasToolkit — deliberately + separate from the config/update dir (%LOCALAPPDATA%\\AtlasToolkit) so the + installer's [InstallDelete] never wipes config or an in-progress update. + """ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - return (base / "AtlasToolkit").resolve() + return (base / "Programs" / "AtlasToolkit").resolve() def _is_installed_build() -> bool: @@ -1198,8 +1203,20 @@ def _no_op(e: Any) -> None: log.error("Failed to setup drop events: %s", e) if __name__ == '__main__': + # Named mutex so the Inno Setup installer's AppMutex can detect a running + # instance (used by CloseApplications during silent self-update). The handle + # is intentionally left open for the process lifetime. + if sys.platform == 'win32': + try: + import ctypes + _single_instance_mutex = ctypes.windll.kernel32.CreateMutexW( + None, False, "AtlasToolkitSingleInstanceMutex" + ) + except Exception: + _single_instance_mutex = None + api = Api() - + # Calculate center position for primary monitor window_width, window_height = 900, 600 if sys.platform == 'win32': From ac5daece53bbca6b46e36ad398b20ed9ec92dcd8 Mon Sep 17 00:00:00 2001 From: com55 Date: Thu, 25 Jun 2026 11:28:49 +0700 Subject: [PATCH 10/18] fix(installer): use x64compatible architecture identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISCC warned that "x64" is deprecated (substituting "x64os"). Use the preferred "x64compatible" — silences the warning and also allows install on ARM64 Windows via x64 emulation. Co-Authored-By: Claude Opus 4.8 --- AtlasToolkit.iss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss index 5c3f216..c0d37cf 100644 --- a/AtlasToolkit.iss +++ b/AtlasToolkit.iss @@ -32,8 +32,8 @@ AllowNoIcons=yes ; Force the fixed install location that _is_installed_build() / silent self-update ; assume — without this the user could install elsewhere and never get auto-updates. DisableDirPage=yes -ArchitecturesAllowed=x64 -ArchitecturesInstallIn64BitMode=x64 +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible OutputDir=installer OutputBaseFilename=AtlasToolkit-Setup-x64 SetupIconFile=ui\icon.ico From 9b5306a44748a19f7246ddff4b1915b4ddf02bc4 Mon Sep 17 00:00:00 2001 From: com55 Date: Thu, 25 Jun 2026 13:50:35 +0700 Subject: [PATCH 11/18] feat(installer): clean upgrades via previous-version uninstall (drop InstallDelete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blunt "[InstallDelete] {app}\*" with the surgical Inno pattern: a [Code] CurStepChanged(ssInstall) hook that runs the PREVIOUS version's uninstaller silently before the new files are copied. It removes only what the old version installed (per its uninstall log) — cleaning stale files from dropped dependencies — while leaving app-created data (config.json, update cache) intact. Because the uninstall is now surgical, the install-dir no longer needs to be separate from the data dir: revert DefaultDirName and _install_dir() back to %LOCALAPPDATA%\AtlasToolkit (matches the already-installed test build, so no manual uninstall-first step). A wait-loop handles the uninstaller's temp-copy relaunch so it can't delete freshly-copied files. Needs Windows verification (untestable on Linux); failure modes are graceful (no prior install / lookup miss -> plain overwrite). Co-Authored-By: Claude Opus 4.8 --- AtlasToolkit.iss | 53 ++++++++++++++++++++++++++++++++++++++++-------- main.py | 12 +++++------ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/AtlasToolkit.iss b/AtlasToolkit.iss index c0d37cf..2d58d92 100644 --- a/AtlasToolkit.iss +++ b/AtlasToolkit.iss @@ -24,9 +24,7 @@ AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL}/releases ; Per-user install — no admin / no UAC, so silent self-update works unattended. PrivilegesRequired=lowest -; {userpf} == %LOCALAPPDATA%\Programs — kept separate from the app's data dir -; (%LOCALAPPDATA%\AtlasToolkit) so [InstallDelete] never wipes config / updates. -DefaultDirName={userpf}\{#MyAppName} +DefaultDirName={localappdata}\{#MyAppName} DefaultGroupName={#MyAppName} AllowNoIcons=yes ; Force the fixed install location that _is_installed_build() / silent self-update @@ -61,12 +59,6 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "associate"; Description: "Associate .atlas files with {#MyAppName}"; GroupDescription: "File associations:" -[InstallDelete] -; Clear the previous install payload before copying the new one so stale Nuitka -; DLLs / data files from an older version never linger (onedir upgrades). -; {app} is the program dir only — the app's config/update data lives elsewhere. -Type: filesandordirs; Name: "{app}\*" - [Files] Source: "dist\main.dist\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion @@ -92,3 +84,46 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}} ; On uninstall, drop the update cache (downloaded installers / logs / scripts); ; user config (config.json) in the same data dir is intentionally left intact. Filename: "{cmd}"; Parameters: "/c rmdir /s /q ""{localappdata}\{#MyAppName}\update"""; Flags: runhidden; RunOnceId: "DelUpdateCache" + +[Code] +{ Clean upgrade without blunt deletion: run the PREVIOUS version's uninstaller + before installing the new files. It removes only what the old version installed + (per its uninstall log) — so stale files from a dropped dependency are cleaned — + while leaving app-created data (config.json, update cache) untouched. } + +function GetUninstallString(): String; +var + Key: String; + S: String; +begin + Key := 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{AADE8604-EC9B-491C-92FA-D0628C934556}_is1'; + S := ''; + if not RegQueryStringValue(HKCU, Key, 'UninstallString', S) then + RegQueryStringValue(HKLM, Key, 'UninstallString', S); + Result := S; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + UnInstStr, OldExe: String; + ResultCode, Waited: Integer; +begin + if CurStep <> ssInstall then + Exit; + UnInstStr := GetUninstallString(); + if UnInstStr = '' then + Exit; + UnInstStr := RemoveQuotes(UnInstStr); + OldExe := ExtractFilePath(UnInstStr) + '{#MyAppExeName}'; + if Exec(UnInstStr, '/VERYSILENT /NORESTART /SUPPRESSMSGBOXES', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + { The uninstaller relaunches a temp copy and returns early; wait until the old + exe is actually gone so it can't delete our freshly-copied files (cap ~20s). } + Waited := 0; + while FileExists(OldExe) and (Waited < 100) do + begin + Sleep(200); + Waited := Waited + 1; + end; + end; +end; diff --git a/main.py b/main.py index 12120e5..1773edf 100644 --- a/main.py +++ b/main.py @@ -250,14 +250,12 @@ def _get_running_executable_path() -> Path: def _install_dir() -> Path: - """The per-user install directory the Inno Setup installer targets. - - {userpf}\\AtlasToolkit == %LOCALAPPDATA%\\Programs\\AtlasToolkit — deliberately - separate from the config/update dir (%LOCALAPPDATA%\\AtlasToolkit) so the - installer's [InstallDelete] never wipes config or an in-progress update. - """ + """The per-user install directory the Inno Setup installer targets + (%LOCALAPPDATA%\\AtlasToolkit). The installer upgrades cleanly by running the + previous version's uninstaller first, which removes only installer-tracked + files and leaves the app's config / update cache (in the same dir) intact.""" base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - return (base / "Programs" / "AtlasToolkit").resolve() + return (base / "AtlasToolkit").resolve() def _is_installed_build() -> bool: From 5a6f6bfb5c7fb9326f6921727725e593258f6748 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:20:48 +0700 Subject: [PATCH 12/18] fix(modify): full-canvas merge, sequential mods, and repack toggle Correct placement for extract-style mod images (shared bounds, no offsets), allow stacking multiple mods per session, fix repack-off reverting to a stale layout, and add reset plus modified-region UI cues. Co-authored-by: Cursor --- atlas_modifier.py | 205 ++++++++++++++++++++++++++++++++++++---------- main.py | 187 +++++++++++++++++++++++++++++------------- ui/index.html | 9 ++ ui/script.js | 110 ++++++++++++++++++------- ui/style.css | 24 ++++++ 5 files changed, 403 insertions(+), 132 deletions(-) diff --git a/atlas_modifier.py b/atlas_modifier.py index 8150f78..c36a0f3 100644 --- a/atlas_modifier.py +++ b/atlas_modifier.py @@ -343,12 +343,18 @@ def __init__(self, atlas_text: str, atlas_path: Path, base_image: Image.Image) - self.atlas_text = self._scale_atlas_text(atlas_text) _, self.region_names, self.regions = parse_atlas(self.atlas_text) + def adopt_merge_result(self, image: Image.Image, atlas_text: str) -> None: + """Use a merge/repack output as the base for subsequent modifications.""" + self.base_image = image.convert("RGBA") + self.atlas_text = atlas_text + _, self.region_names, self.regions = parse_atlas(self.atlas_text) + def _scale_atlas_text(self, atlas_text: str) -> str: """If image size differs from atlas page size, return atlas text with all coordinates scaled to match the real image.""" page_info, _, regions = parse_atlas(atlas_text) size_str = page_info.get("size") - if not size_str: + if not isinstance(size_str, str): return atlas_text atlas_w, atlas_h = (int(v.strip()) for v in size_str.split(",")) @@ -382,7 +388,12 @@ def _scale_atlas_text(self, atlas_text: str) -> str: @staticmethod def _find_best_placement( - base_w: int, base_h: int, mod_w: int, mod_h: int + base_w: int, + base_h: int, + mod_w: int, + mod_h: int, + *, + allow_rotate: bool = True, ) -> _PlacementOption: """ Evaluate 4 placement strategies and return the one with the @@ -432,6 +443,9 @@ def _find_best_placement( ), ] + if not allow_rotate: + candidates = [c for c in candidates if not c.rotated] + best = min(candidates, key=lambda c: c.canvas_w * c.canvas_h) for c in candidates: @@ -443,6 +457,112 @@ def _find_best_placement( return best + @staticmethod + def _canvas_size_match( + mod_w: int, + mod_h: int, + canvas_w: int, + canvas_h: int, + tolerance: float = 0.02, + ) -> bool: + """True when *mod* dimensions match *canvas* within rounding tolerance.""" + if canvas_w <= 0 or canvas_h <= 0: + return False + dw = abs(mod_w - canvas_w) + dh = abs(mod_h - canvas_h) + return ( + dw <= max(2, round(canvas_w * tolerance)) + and dh <= max(2, round(canvas_h * tolerance)) + ) + + def _resolve_mod_canvas( + self, + selected_regions: List[str], + mod_w: int, + mod_h: int, + ) -> Tuple[int, int, int, int, int, int, bool]: + """ + Derive target canvas size and padding anchor for a mod image. + + Returns: + (orig_canvas_w, orig_canvas_h, base_orig_w, base_orig_h, + off_x, off_y, is_full_canvas) + """ + canvas_sizes: set[Tuple[int, int]] = set() + regions_with_offsets: List[RegionInfo] = [] + for name in selected_regions: + region = self.regions.get(name) + if region and region.offsets: + canvas_sizes.add((region.offsets[2], region.offsets[3])) + regions_with_offsets.append(region) + + if regions_with_offsets: + + def _anchor_key(r: RegionInfo) -> tuple[int, int, int]: + o = r.offsets + assert o is not None + return (o[0] + o[1], o[0], o[1]) + + base_orig_w, base_orig_h = next(iter(canvas_sizes)) + anchor = min(regions_with_offsets, key=_anchor_key) + anchor_off = anchor.offsets + assert anchor_off is not None + off_x, off_y = anchor_off[0], anchor_off[1] + orig_canvas_w, orig_canvas_h = base_orig_w, base_orig_h + else: + base_orig_w, base_orig_h = mod_w, mod_h + off_x, off_y = 0, 0 + orig_canvas_w, orig_canvas_h = mod_w, mod_h + + shared_canvas = len(canvas_sizes) == 1 and len(selected_regions) > 1 + + # Detect proportional scale (e.g. mod is 2x the expected canvas) + if ( + orig_canvas_w > 0 + and orig_canvas_h > 0 + and (mod_w != orig_canvas_w or mod_h != orig_canvas_h) + ): + ratio_w = mod_w / orig_canvas_w + ratio_h = mod_h / orig_canvas_h + if abs(ratio_w - ratio_h) < 0.05 and not (0.95 < ratio_w < 1.05): + mod_scale = (ratio_w + ratio_h) / 2 + orig_canvas_w = round(orig_canvas_w * mod_scale) + orig_canvas_h = round(orig_canvas_h * mod_scale) + logging.info( + f"Mod image scale: {mod_scale:.3f}x " + f"(canvas → {orig_canvas_w}x{orig_canvas_h})" + ) + else: + orig_canvas_w = mod_w + orig_canvas_h = mod_h + + is_full_canvas = shared_canvas or self._canvas_size_match( + mod_w, mod_h, orig_canvas_w, orig_canvas_h + ) + if is_full_canvas: + orig_canvas_w, orig_canvas_h = mod_w, mod_h + off_x, off_y = 0, 0 + + return ( + orig_canvas_w, + orig_canvas_h, + base_orig_w, + base_orig_h, + off_x, + off_y, + is_full_canvas, + ) + + def _selected_share_canvas(self, selected_regions: List[str]) -> bool: + """True when every selected region shares one logical canvas size.""" + sizes: set[Tuple[int, int]] = set() + for name in selected_regions: + region = self.regions.get(name) + if not region or not region.offsets: + return False + sizes.add((region.offsets[2], region.offsets[3])) + return len(sizes) == 1 and len(selected_regions) > 1 + # ------------------------------------------------------------------ # # Merge # # ------------------------------------------------------------------ # @@ -469,47 +589,26 @@ def merge_mod_image( logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") - # Determine original canvas dimensions from the first selected region. # offsets format: [left, bottom, originalWidth, originalHeight] (Spine spec) - orig_canvas_w, orig_canvas_h = mod_w, mod_h - - first_region = self.regions.get(selected_regions[0]) - has_offsets = bool(first_region and first_region.offsets) - off_x_orig = first_region.offsets[0] if has_offsets else 0 - off_y_orig = first_region.offsets[1] if has_offsets else 0 - base_orig_w = first_region.offsets[2] if has_offsets else mod_w - base_orig_h = first_region.offsets[3] if has_offsets else mod_h - if has_offsets: - orig_canvas_w = base_orig_w - orig_canvas_h = base_orig_h - - # Detect proportional scale (e.g. mod is 2x the expected canvas) - if ( - orig_canvas_w > 0 - and orig_canvas_h > 0 - and (mod_w != orig_canvas_w or mod_h != orig_canvas_h) + ( + orig_canvas_w, + orig_canvas_h, + base_orig_w, + base_orig_h, + off_x_orig, + off_y_orig, + is_full_canvas, + ) = self._resolve_mod_canvas(selected_regions, mod_w, mod_h) + + if is_full_canvas: + logging.info("Mod image treated as full canvas replacement") + + # Pad trimmed sprite mods onto the logical canvas. + # Full-canvas mods (combined multi-region sheets, or mod ≈ canvas size) + # are pasted at (0, 0) — trim offsets only apply to partial sprites. + if not is_full_canvas and ( + mod_w != orig_canvas_w or mod_h != orig_canvas_h ): - ratio_w = mod_w / orig_canvas_w - ratio_h = mod_h / orig_canvas_h - # Uniform scale and not ~1:1 → scale canvas target to match mod - if abs(ratio_w - ratio_h) < 0.05 and not (0.95 < ratio_w < 1.05): - mod_scale = (ratio_w + ratio_h) / 2 - orig_canvas_w = round(orig_canvas_w * mod_scale) - orig_canvas_h = round(orig_canvas_h * mod_scale) - logging.info( - f"Mod image scale: {mod_scale:.3f}x " - f"(canvas → {orig_canvas_w}x{orig_canvas_h})" - ) - else: - # Non-proportional scale: treat the mod as a brand-new full - # canvas at its own dimensions so it is never clipped. [A1] - orig_canvas_w = mod_w - orig_canvas_h = mod_h - - # Pad mod image to (possibly scaled) canvas size if needed. - # Place the sprite at (left, origH - bottom - spriteH) per the Spine - # offset convention so whitespace-trimmed sprites stay aligned. [A2] - if mod_w != orig_canvas_w or mod_h != orig_canvas_h: scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 paste_x = round(off_x_orig * scale_x) @@ -525,8 +624,22 @@ def merge_mod_image( mod_img = padded_mod mod_w, mod_h = orig_canvas_w, orig_canvas_h + shared_canvas_mod = ( + is_full_canvas and self._selected_share_canvas(selected_regions) + ) + if shared_canvas_mod: + logging.info( + "Shared logical canvas: all selected regions use one atlas area" + ) + # --- Find the best placement --- - best = self._find_best_placement(base_w, base_h, mod_w, mod_h) + best = self._find_best_placement( + base_w, + base_h, + mod_w, + mod_h, + allow_rotate=not shared_canvas_mod, + ) logging.info(f"Chosen placement: {best.label}") # Rotate the mod image if the best strategy requires it @@ -562,8 +675,14 @@ def merge_mod_image( atlas_bounds_w, atlas_bounds_h, ) + # Full-canvas mod (typical extract output): packed size equals + # original size — no whitespace stripping, omit offsets in atlas. new_offsets = (0, 0, atlas_bounds_w, atlas_bounds_h) - updated_regions_data[name] = (new_bounds, new_offsets, rotate_val) + updated_regions_data[name] = ( + new_bounds, + new_offsets, + rotate_val, + ) new_atlas_text = update_atlas_text( self.atlas_text, diff --git a/main.py b/main.py index 1773edf..43835d4 100644 --- a/main.py +++ b/main.py @@ -345,6 +345,7 @@ def __init__(self) -> None: # Pre-repack state (merge output before repack was applied) self._pre_repack_image: Optional[Image] = None self._pre_repack_text: Optional[str] = None + self._modified_regions: set[str] = set() # Persistent config self._config: dict[str, Any] = self._load_config() # Update state @@ -500,6 +501,66 @@ def _clear_modify_state(self) -> None: self._merged_pages = None self._pre_repack_image = None self._pre_repack_text = None + self._modified_regions = set() + + def _mark_regions_modified(self, names: List[str]) -> None: + self._modified_regions.update(names) + + def _build_modify_response( + self, + image: Image, + atlas_text: str, + extra: Optional[dict[str, object]] = None, + ) -> dict[str, object]: + from atlas_modifier import parse_atlas + + _, _, merged_regions = parse_atlas(atlas_text) + region_bounds: dict[str, list[int]] = {} + for name, info in merged_regions.items(): + region_bounds[name] = [*info.bounds, info.rotate] + + payload: dict[str, object] = { + "image": self._image_to_base64(image), + "regions": region_bounds, + "modifiedRegions": sorted(self._modified_regions), + } + if extra: + payload.update(extra) + return payload + + @staticmethod + def _parse_atlas_page_names(atlas_text: str) -> List[str]: + names: List[str] = [] + for line in atlas_text.splitlines(): + stripped = line.strip() + if ( + stripped + and ":" not in stripped + and stripped.lower().endswith(".png") + ): + names.append(stripped) + return names + + def _extract_sprites_from_merged_pages(self) -> dict[str, Image]: + from atlas_modifier import AtlasModifier, parse_atlas + + if not self._merged_pages or not self._merged_atlas_text: + return {} + + page_names = self._parse_atlas_page_names(self._merged_atlas_text) + page_images = { + name: self._merged_pages[i] + for i, name in enumerate(page_names) + if i < len(self._merged_pages) + } + + _, _, regions = parse_atlas(self._merged_atlas_text) + sprites: dict[str, Image] = {} + for name, info in regions.items(): + page_img = page_images.get(info.page) + if page_img is not None: + sprites[name] = AtlasModifier._extract_raw_sprite(page_img, info) + return sprites def get_region_names(self) -> List[str]: if not self._processor: return [] @@ -594,38 +655,35 @@ def extract_files(self, region_names: Optional[List[str]]) -> str: # MODIFY MODE API # ========================================== - def enter_modify_mode(self) -> Optional[dict[str, object]]: - """Prepare the AtlasModifier from the current loaded atlas. - - Returns: - Dict with 'image' (base64) and 'regions' ({name: [x,y,w,h]}), or None. - """ + def _build_modify_view(self, *, clear_modified: bool = False) -> Optional[dict[str, object]]: + """Rebuild modify mode from the originally-loaded atlas.""" if not self._processor or not self._atlas_path: return None - + try: atlas_text = self._atlas_path.read_text(encoding='utf-8') - - # Get the first loaded page image as the base base_image = self._processor.get_page_image() if not base_image: log.error("No loaded images in processor") return None - - self._modifier = AtlasModifier(auto_convert_atlas(atlas_text), self._atlas_path, base_image) + + if clear_modified: + self._modified_regions = set() + self._merged_image = None self._merged_atlas_text = None self._merged_pages = None - log.debug("Entered modify mode") + self._pre_repack_image = None + self._pre_repack_text = None + + self._modifier = AtlasModifier( + auto_convert_atlas(atlas_text), self._atlas_path, base_image + ) - # Build region bounds dict for client-side overlay - # Each value: [x, y, w, h, rotate] region_bounds: dict[str, list[int]] = {} for name, info in self._modifier.regions.items(): region_bounds[name] = [*info.bounds, info.rotate] - # Multi-page metadata (consumed by the page switcher UI). For a - # single-page atlas these are a 1-element list / trivial map. pages = [p.filename for p in self._processor.pages] region_pages = { name: r.page_filename @@ -638,12 +696,30 @@ def enter_modify_mode(self) -> Optional[dict[str, object]]: "pages": pages, "regionPages": region_pages, "activePage": pages[0] if pages else None, + "modifiedRegions": sorted(self._modified_regions), } - except Exception as e: - log.error("Entering modify mode: %s", e) + log.error("Building modify view: %s", e) return None + def enter_modify_mode(self) -> Optional[dict[str, object]]: + """Prepare the AtlasModifier from the current loaded atlas. + + Returns: + Dict with 'image' (base64) and 'regions' ({name: [x,y,w,h]}), or None. + """ + data = self._build_modify_view(clear_modified=False) + if data is not None: + log.debug("Entered modify mode") + return data + + def reset_modify_mode(self) -> Optional[dict[str, object]]: + """Discard all in-session modifications and restore the original atlas view.""" + data = self._build_modify_view(clear_modified=True) + if data is not None: + log.debug("Reset modify mode to original atlas") + return data + def exit_modify_mode(self) -> None: """Clean up modify mode state.""" self._clear_modify_state() @@ -701,18 +777,14 @@ def process_mod_image(self, path_str: str, selected_names: List[str], repack: bo self._merged_image = merged_image self._merged_atlas_text = merged_atlas_text - - # Parse updated bounds from merged atlas text - from atlas_modifier import parse_atlas - _, _, merged_regions = parse_atlas(merged_atlas_text) - region_bounds: dict[str, list[int]] = {} - for name, info in merged_regions.items(): - region_bounds[name] = [*info.bounds, info.rotate] - - return { - "image": self._image_to_base64(merged_image), - "regions": region_bounds, - } + self._mark_regions_modified(selected_names) + # Continue subsequent merges from the pre-repack canvas so toggling + # repack off still reflects every mod, not an earlier repacked layout. + self._modifier.adopt_merge_result( + self._pre_repack_image, self._pre_repack_text + ) + + return self._build_modify_response(merged_image, merged_atlas_text) except Exception as e: log.error("Processing mod image: %s", e) @@ -732,12 +804,15 @@ def _process_mod_multi_page( from PIL import Image from atlas_modifier import parse_atlas, repack_multi_page - # 1. Extract every region from every page (offset padding restored). - all_sprites: dict[str, Image] = {} - for name in self._processor.regions: - sprite = self._processor.extract_region(name) - if sprite is not None: - all_sprites[name] = sprite + # 1. Extract every region (from merged output if continuing a session). + if self._merged_pages and self._merged_atlas_text: + all_sprites = self._extract_sprites_from_merged_pages() + else: + all_sprites = {} + for name in self._processor.regions: + sprite = self._processor.extract_region(name) + if sprite is not None: + all_sprites[name] = sprite # 2. Replace the selected regions' sprites with the mod image. mod_img = Image.open(Path(path_str)).convert("RGBA") @@ -777,10 +852,8 @@ def _process_mod_multi_page( self._merged_image = None self._pre_repack_image = None self._pre_repack_text = None + self._mark_regions_modified(selected_names) - # 5. Region bounds for overlay (all regions across all pages) plus - # the MERGED region→page map (repack redistributed them, so the - # enter_modify_mode map is now stale). _, _, merged_regions = parse_atlas(atlas_text) region_bounds: dict[str, list[int]] = {} region_pages: dict[str, str] = {} @@ -788,14 +861,16 @@ def _process_mod_multi_page( region_bounds[name] = [*info.bounds, info.rotate] region_pages[name] = info.page - return { - "image": self._image_to_base64(pages[0]) if pages else None, - "regions": region_bounds, - "regionPages": region_pages, - "pages": [str(pi["page"]) for pi in page_infos], - "pageCount": len(pages), - "previewPage": str(page_infos[0]["page"]) if page_infos else None, - } + return self._build_modify_response( + pages[0] if pages else Image.new("RGBA", (1, 1)), + atlas_text, + extra={ + "regionPages": region_pages, + "pages": [str(pi["page"]) for pi in page_infos], + "pageCount": len(pages), + "previewPage": str(page_infos[0]["page"]) if page_infos else None, + }, + ) except Exception as e: log.error("Processing multi-page mod image: %s", e) @@ -848,6 +923,8 @@ def save_modified(self) -> str: if self._merged_pages is not None: self._save_multi_page(output_dir) else: + if not self._modifier or self._merged_image is None: + return "Error: No merged data to save." self._modifier.save(output_dir, self._merged_image, self._merged_atlas_text) return f"Saved to: {output_dir}" except Exception as e: @@ -894,17 +971,11 @@ def toggle_repack(self, repack: bool) -> Optional[dict[str, object]]: self._merged_image = image self._merged_atlas_text = text + self._modifier.adopt_merge_result( + self._pre_repack_image, self._pre_repack_text + ) - from atlas_modifier import parse_atlas - _, _, merged_regions = parse_atlas(text) - region_bounds: dict[str, list[int]] = {} - for name, info in merged_regions.items(): - region_bounds[name] = [*info.bounds, info.rotate] - - return { - "image": self._image_to_base64(image), - "regions": region_bounds, - } + return self._build_modify_response(image, text) except Exception as e: log.error("toggle_repack: %s", e) return None @@ -1216,7 +1287,7 @@ def _no_op(e: Any) -> None: api = Api() # Calculate center position for primary monitor - window_width, window_height = 900, 600 + window_width, window_height = 1200, 800 if sys.platform == 'win32': import ctypes screen_width = ctypes.windll.user32.GetSystemMetrics(0) # SM_CXSCREEN diff --git a/ui/index.html b/ui/index.html index f22b7ce..d90f9ea 100644 --- a/ui/index.html +++ b/ui/index.html @@ -117,6 +117,15 @@ Modify Selected No mod image loaded + - -
- - - -
-
    -
    - -
    -
    - +
    + +
    +
    - Ready + + +
    - + + +
    + + + + + Save As... + + +
    + + + + + +
    + +
    + + + +
    + +
      +
      + +
      + + + +
      + + +
      + + Ready + +
      + +
      + + Preview + + + +
      + +
      +
      -
      - Preview - -
      +
      + + + + + + + + + - -
      + + + + + diff --git a/ui/script.js b/ui/script.js index 1f1dd03..c816033 100644 --- a/ui/script.js +++ b/ui/script.js @@ -26,6 +26,15 @@ window.addEventListener("pywebviewready", async function () { const repackPref = await pywebview.api.get_pref("repack", false); document.getElementById("chk-repack").checked = repackPref; + document.getElementById("mode-extract").addEventListener("click", async () => { + if (currentMode === "extract") return; + await exitModifyMode(); + }); + document.getElementById("mode-modify").addEventListener("click", async () => { + if (currentMode === "modify") return; + await enterModifyMode(); + }); + const loaded = await pywebview.api.startup_check(); if (loaded) await loadRegions(); }); @@ -33,30 +42,57 @@ window.addEventListener("pywebviewready", async function () { // ========================================== // MODE SWITCHING // ========================================== +function setStatus(text) { + document.getElementById("status-text").innerText = text; +} + +function updateModeToggleUI() { + const extractBtn = document.getElementById("mode-extract"); + const modifyBtn = document.getElementById("mode-modify"); + extractBtn.classList.toggle("active", currentMode === "extract"); + modifyBtn.classList.toggle("active", currentMode === "modify"); + const count = parseInt(document.getElementById("count").innerText, 10) || 0; + modifyBtn.disabled = count === 0; +} + +function clearRegionSelection() { + selectedIndices.clear(); + lastClickIndex = -1; + renderSelection(); + updateButtons(); +} + function setMode(mode) { + if (mode !== currentMode) { + clearRegionSelection(); + } currentMode = mode; - const normalHeader = document.getElementById("normal-header"); - const modifyHeader = document.getElementById("modify-header"); const extractControls = document.getElementById("extract-controls"); const modifyControls = document.getElementById("modify-controls"); const repackOptions = document.getElementById("repack-options"); + const saveBtn = document.getElementById("btn-save-mod"); const dropMsg = document.getElementById("drop-message-text"); if (mode === "modify") { - normalHeader.classList.add("hidden"); - modifyHeader.classList.remove("hidden"); extractControls.classList.add("hidden"); modifyControls.classList.remove("hidden"); repackOptions.classList.remove("hidden"); + saveBtn.classList.remove("hidden"); dropMsg.textContent = "Drop image to modify, or .atlas to load"; } else { - normalHeader.classList.remove("hidden"); - modifyHeader.classList.add("hidden"); extractControls.classList.remove("hidden"); modifyControls.classList.add("hidden"); repackOptions.classList.add("hidden"); + saveBtn.classList.add("hidden"); dropMsg.textContent = "Drop .atlas file here to load"; - clearOverlay(); // Clear region overlay when exiting modify mode + clearOverlay(); + } + updateModeToggleUI(); + + if (mode === "extract") { + updatePreview(getSelectedNames()); + } else { + updateModifyPreview(getSelectedNames()); } } @@ -86,7 +122,7 @@ function applyModifyView(data, statusText) { setupModifyPages(data); modifiedRegionNames = new Set(data.modifiedRegions || []); renderRegionList(); - document.getElementById("modify-status-text").innerText = statusText; + setStatus(statusText); updateModifyActionButtons(); previewImg.src = data.image; previewImg.style.display = "block"; @@ -142,8 +178,7 @@ async function exitModifyMode() { // Restore preview from current selection previewImg.style.display = "none"; resetPreview(); - document.getElementById("status-text").innerText = "Ready"; - updatePreview(getSelectedNames()); + setStatus("Ready"); } async function modifySelected() { @@ -153,15 +188,13 @@ async function modifySelected() { return; } try { - document.getElementById("modify-status-text").innerText = - "Selecting mod image..."; + setStatus("Selecting mod image..."); const repack = document.getElementById("chk-repack").checked; const result = await pywebview.api.select_mod_image(names, repack); if (result) { onModPreviewReceived(result); } else { - document.getElementById("modify-status-text").innerText = - "Cancelled or no image selected."; + setStatus("Cancelled or no image selected."); } } catch (e) { console.error(e); @@ -183,8 +216,7 @@ function onModPreviewReceived(data) { setupModifyPages(data); previewImg.src = data.image; previewImg.style.display = "block"; - document.getElementById("modify-status-text").innerText = - "Mod image merged. Ready to save."; + setStatus("Mod image merged. Ready to save."); updateModifyActionButtons(); previewImg.onload = function () { @@ -194,8 +226,7 @@ function onModPreviewReceived(data) { const imgW = previewImg.naturalWidth; const imgH = previewImg.naturalHeight; - document.getElementById("modify-status-text").innerText = - `Merged preview (${imgW}x${imgH}). Ready to save.`; + setStatus(`Merged preview (${imgW}x${imgH}). Ready to save.`); if (imgW > containerW || imgH > containerH) { const scaleW = containerW / imgW; @@ -209,14 +240,14 @@ function onModPreviewReceived(data) { async function saveModified() { try { - document.getElementById("modify-status-text").innerText = "Saving..."; + setStatus("Saving..."); const result = await pywebview.api.save_modified(); if (result.startsWith("Error") || result === "Cancelled") { showToast(result, result === "Cancelled" ? "info" : "error"); } else { showToast(result, "success"); } - document.getElementById("modify-status-text").innerText = result; + setStatus(result); } catch (e) { console.error(e); showToast("Save failed.", "error"); @@ -289,10 +320,9 @@ function modifyPageNext() { document.getElementById("chk-repack").addEventListener("change", async (e) => { pywebview.api.set_pref("repack", e.target.checked); if (!hasModImage) return; - const statusEl = document.getElementById("modify-status-text"); - statusEl.innerText = e.target.checked - ? "Applying repack..." - : "Reverting repack..."; + setStatus( + e.target.checked ? "Applying repack..." : "Reverting repack...", + ); try { const result = await pywebview.api.toggle_repack(e.target.checked); if (result) { @@ -331,13 +361,12 @@ async function loadRegions() { document.getElementById("count").innerText = regionsData.length; renderRegionList(); if (regionsData.length > 0) { - document.getElementById("status-text").innerText = "Atlas loaded."; - document.getElementById("btn-enter-modify").disabled = false; + setStatus("Atlas loaded."); document.getElementById("btn-extract-all").disabled = false; } else { - document.getElementById("btn-enter-modify").disabled = true; document.getElementById("btn-extract-all").disabled = true; } + updateModeToggleUI(); } function regionDisplayName(name) { @@ -483,13 +512,17 @@ function updateModifyPreview(names) { // Pure client-side: just redraw overlay canvas drawRegionOverlay(); if (!names || names.length === 0) { - document.getElementById("modify-status-text").innerText = hasModImage - ? "Mod image merged. Ready to save." - : "Select regions and click Modify Selected"; + setStatus( + hasModImage + ? "Mod image merged. Ready to save." + : "Select regions and click Modify Selected", + ); } else { - document.getElementById("modify-status-text").innerText = hasModImage - ? `Merged preview. ${names.length} region(s) selected.` - : `${names.length} region(s) selected`; + setStatus( + hasModImage + ? `Merged preview. ${names.length} region(s) selected.` + : `${names.length} region(s) selected`, + ); } } @@ -685,6 +718,7 @@ async function updatePreview(names) { if (!names || names.length === 0) { previewImg.style.display = "none"; status.innerText = "No selection"; + updateButtons(); return; } @@ -717,11 +751,13 @@ async function updatePreview(names) { viewState.scale = Math.min(scaleW, scaleH); applyTransform(); } + updateButtons(); previewImg.onload = null; }; } else { previewImg.style.display = "none"; status.innerText = "Preview failed"; + updateButtons(); } } @@ -768,22 +804,81 @@ function updateButtons() { btnSel.disabled = selectedIndices.size === 0; btnSel.innerText = `Extract Selected (${selectedIndices.size})`; - // Update modify button state too const btnModSel = document.getElementById("btn-modify-sel"); if (btnModSel) { btnModSel.disabled = selectedIndices.size === 0; btnModSel.innerText = `Modify Selected (${selectedIndices.size})`; } + + const btnSaveMerged = document.getElementById("btn-save-merged"); + if (btnSaveMerged) { + const hasPreview = + previewImg.style.display !== "none" && + previewImg.naturalWidth > 0 && + previewImg.naturalHeight > 0; + btnSaveMerged.disabled = !hasPreview; + } +} + +function getPreviewPngBlob() { + const img = previewImg; + if (!img.naturalWidth || !img.naturalHeight) return null; + + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext("2d").drawImage(img, 0, 0); + + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} + +function previewSaveDefaultName(names) { + if (!names || names.length === 0) return "image.png"; + const safe = names.map((n) => n.replace(/[<>:"/\\|?*]/g, "_")); + if (safe.length === 1) return `${safe[0]}.png`; + if (safe.length <= 5) return `${safe.join("+")}.png`; + const more = safe.length - 5; + return `${safe.slice(0, 5).join("+")}+ ${more} more.png`; +} + +async function saveMergedImage() { + try { + const blob = await getPreviewPngBlob(); + if (!blob) { + showToast("No image to save.", "error"); + return; + } + + const reader = new FileReader(); + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + const defaultName = previewSaveDefaultName(getSelectedNames()); + const result = await pywebview.api.save_preview_image(dataUrl, defaultName); + if (result === "Cancelled") { + showToast(result, "info"); + } else if (result.startsWith("Error")) { + showToast(result, "error"); + } else { + showToast(result, "success"); + } + } catch (e) { + console.error(e); + showToast("Failed to save image.", "error"); + } } async function extractSelected() { if (selectedIndices.size === 0) return; const names = Array.from(selectedIndices).map((i) => regionsData[i]); - document.getElementById("status-text").innerText = "Extracting..."; + setStatus("Extracting..."); const result = await pywebview.api.extract_files(names); showToast(result, result.includes("Error") ? "error" : "success"); - document.getElementById("status-text").innerText = "Ready"; + setStatus("Ready"); } async function extractAll() { @@ -795,11 +890,11 @@ async function extractAll() { ); if (!confirmed) return; - document.getElementById("status-text").innerText = "Extracting ALL..."; + setStatus("Extracting ALL..."); const result = await pywebview.api.extract_files(null); showToast(result, result.includes("Error") ? "error" : "success"); - document.getElementById("status-text").innerText = "Ready"; + setStatus("Ready"); } // --- Modal Logic --- @@ -1011,24 +1106,9 @@ window.addEventListener("keydown", (e) => { async function copyPreviewImage() { contextMenu.classList.add("hidden"); try { - // Draw the preview img onto an offscreen canvas and copy as PNG - const img = previewImg; - if (!img.naturalWidth || !img.naturalHeight) { - showToast("No image to copy.", "error"); - return; - } - - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d"); - ctx.drawImage(img, 0, 0); - - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, "image/png"), - ); + const blob = await getPreviewPngBlob(); if (!blob) { - showToast("Failed to copy image.", "error"); + showToast("No image to copy.", "error"); return; } diff --git a/ui/style.css b/ui/style.css index e325f27..78a7fb1 100644 --- a/ui/style.css +++ b/ui/style.css @@ -1,69 +1,181 @@ +:root { + --sidebar-width: 300px; + --panel-header-h: 30px; +} + body { margin: 0; padding: 0; font-family: "Segoe UI", sans-serif; height: 100vh; display: flex; + flex-direction: column; background-color: #2b2b2b; color: #eee; overflow: hidden; user-select: none; } -/* Layout */ -#left-panel { - width: 300px; - min-width: 300px; - background-color: #1e1e1e; - border-right: 1px solid #444; - display: flex; - flex-direction: column; - position: relative; -} -#right-panel { - flex: 1; - min-width: 0; +/* App Bar */ +#app-bar { + height: 42px; + min-height: 42px; + padding: 0; + background-color: #252526; + border-bottom: 1px solid #444; display: flex; - flex-direction: column; - background-color: #333; - position: relative; + align-items: stretch; + flex-shrink: 0; + z-index: 10; + box-sizing: border-box; } -/* Header */ -.panel-header { - padding: 10px; - background-color: #252526; - font-weight: bold; - border-bottom: 1px solid #444; +.app-bar-left { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + padding: 0 10px; display: flex; - justify-content: space-between; align-items: center; - z-index: 10; - min-height: 40px; + gap: 8px; box-sizing: border-box; + flex-shrink: 0; } -#normal-header, -#modify-header { +.app-bar-right { + flex: 1; + min-width: 0; + padding: 0 10px; display: flex; - justify-content: space-between; align-items: center; - width: 100%; gap: 8px; } -#normal-header > span { +#btn-save-mod { + margin-left: auto; + flex-shrink: 0; +} + +#status-text { font-size: 12px; + color: #aaa; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.app-bar-divider { + width: 1px; + height: 20px; + background-color: #444; + flex-shrink: 0; +} + +.app-bar-panel-divider { + height: auto; + align-self: stretch; + width: 1px; + background-color: #444; + flex-shrink: 0; +} + +.mode-toggle { + display: flex; + flex: 1; + min-width: 0; + border: 1px solid #555; + border-radius: 3px; + overflow: hidden; +} + +.mode-toggle-btn { + flex: 1; min-width: 0; + height: 26px; + padding: 0 8px; + border: none; + border-radius: 0; + background-color: #3c3c3c; + color: #888; + font-size: 11px; + cursor: pointer; } -.panel-header-buttons { +.mode-toggle-btn.mode-view.active { + background-color: #3d7a8a; + color: white; +} + +.mode-toggle-btn.mode-edit.active { + background-color: #8a7344; + color: white; +} + +.mode-toggle-btn.active { + color: white; +} + +.mode-toggle-btn:disabled { + background-color: #333; + color: #555; + cursor: not-allowed; +} + +.mode-toggle-btn + .mode-toggle-btn { + border-left: 1px solid #555; +} + +/* Main Layout */ +#main-content { + flex: 1; + min-height: 0; display: flex; - gap: 6px; +} + +#left-panel { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background-color: #1e1e1e; + border-right: 1px solid #444; + display: flex; + flex-direction: column; + position: relative; +} + +#sidebar-head, +#repack-options, +#status-bar { + height: var(--panel-header-h); + min-height: var(--panel-header-h); + padding: 0 15px; + background-color: #252526; + border-bottom: 1px solid #444; + display: flex; + align-items: center; flex-shrink: 0; + box-sizing: border-box; +} + +#sidebar-head { + font-size: 12px; + font-weight: bold; + color: #aaa; +} + +#repack-options { + padding: 0 10px; +} + +#status-bar { + padding: 0 12px; +} + +#right-panel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background-color: #333; + position: relative; } /* --- BUTTON STYLES (Shared) --- */ @@ -193,36 +305,41 @@ button { color: #b8e8ff; } -/* Right Panel Controls */ -#controls { - padding: 10px; - background-color: #252526; - border-bottom: 1px solid #444; - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 10px; - align-items: center; - z-index: 10; - min-height: 40px; - box-sizing: border-box; -} - #extract-controls, #modify-controls { display: flex; align-items: center; - gap: 10px; - width: 100%; + gap: 8px; + min-width: 0; overflow: hidden; + flex: 1; } -/* Repack Options Row */ -#repack-options { - width: 100%; - padding-top: 6px; - border-top: 1px solid #3a3a3a; - margin-top: 2px; +#btn-save-merged { + margin-left: auto; + flex-shrink: 0; +} + +.btn-save-merged { + background-color: #388e3c; + color: white; +} + +.btn-save-merged:hover:not(:disabled) { + background-color: #4caf50; +} + +.btn-save-merged:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; +} + +#modify-page-switcher { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; } .toggle-label { @@ -287,6 +404,7 @@ button { /* Preview Area */ #preview-container { flex: 1; + min-height: 0; overflow: hidden; background-image: conic-gradient( #222 90deg, @@ -329,26 +447,21 @@ img#preview-img { z-index: 5; } -#status-text, -#modify-status-text { - flex: 1; - min-width: 0; - color: #aaa; - font-size: 0.9em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} /* Toast Notification */ #toast-container { - position: fixed; + position: absolute; bottom: 20px; left: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 1000; + pointer-events: none; +} + +#toast-container .toast { + pointer-events: auto; } .toast { background-color: #333; @@ -401,12 +514,6 @@ img#preview-img { } /* Multi-page switcher */ -#modify-page-switcher { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} #page-indicator { font-size: 12px; color: #ccc; From b9fcb81a86a79ea25b51f66d1a6654370bed54bc Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:53:43 +0700 Subject: [PATCH 14/18] refactor: reorganize Python code into atlas_toolkit package Move domain, atlas ops, app bridge, and update logic into a layered package while keeping main.py as the Nuitka entry point. Co-authored-by: Cursor --- .github/workflows/build_release.yml | 1 + .gitignore | 3 +- atlas_extracter.py | 293 ---- atlas_modifier.py | 1097 -------------- atlas_toolkit/__init__.py | 5 + atlas_toolkit/__main__.py | 4 + atlas_toolkit/app/__init__.py | 11 + atlas_toolkit/app/bridge.py | 430 ++++++ atlas_toolkit/app/config.py | 49 + atlas_toolkit/app/launch.py | 122 ++ atlas_toolkit/app/session.py | 431 ++++++ atlas_toolkit/atlas/__init__.py | 19 + .../atlas/converter.py | 0 atlas_toolkit/atlas/extracter.py | 131 ++ atlas_toolkit/atlas/modifier.py | 464 ++++++ atlas_toolkit/atlas/repacker.py | 343 +++++ atlas_toolkit/core/__init__.py | 12 + atlas_toolkit/core/document.py | 379 +++++ atlas_toolkit/core/overlay.py | 40 + atlas_toolkit/core/region_ops.py | 74 + atlas_toolkit/paths.py | 17 + atlas_toolkit/update/__init__.py | 6 + atlas_toolkit/update/controller.py | 406 +++++ updater.py => atlas_toolkit/update/updater.py | 29 +- main.py | 1349 +---------------- pyproject.toml | 7 + ui/script.js | 31 +- uv.lock | 2 +- 28 files changed, 2989 insertions(+), 2766 deletions(-) delete mode 100644 atlas_extracter.py delete mode 100644 atlas_modifier.py create mode 100644 atlas_toolkit/__init__.py create mode 100644 atlas_toolkit/__main__.py create mode 100644 atlas_toolkit/app/__init__.py create mode 100644 atlas_toolkit/app/bridge.py create mode 100644 atlas_toolkit/app/config.py create mode 100644 atlas_toolkit/app/launch.py create mode 100644 atlas_toolkit/app/session.py create mode 100644 atlas_toolkit/atlas/__init__.py rename atlas_converter.py => atlas_toolkit/atlas/converter.py (100%) create mode 100644 atlas_toolkit/atlas/extracter.py create mode 100644 atlas_toolkit/atlas/modifier.py create mode 100644 atlas_toolkit/atlas/repacker.py create mode 100644 atlas_toolkit/core/__init__.py create mode 100644 atlas_toolkit/core/document.py create mode 100644 atlas_toolkit/core/overlay.py create mode 100644 atlas_toolkit/core/region_ops.py create mode 100644 atlas_toolkit/paths.py create mode 100644 atlas_toolkit/update/__init__.py create mode 100644 atlas_toolkit/update/controller.py rename updater.py => atlas_toolkit/update/updater.py (91%) diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index a9188ee..8e34998 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -84,6 +84,7 @@ jobs: include-data-files: | VERSION=VERSION include-package-data: webview + include-package: atlas_toolkit nofollow-import-to: | PyQt5 PyQt6 diff --git a/.gitignore b/.gitignore index 0a7d999..18f74fc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,4 @@ wheels/ # Virtual environments .venv .vscode -.claude -test* \ No newline at end of file +.claude \ No newline at end of file diff --git a/atlas_extracter.py b/atlas_extracter.py deleted file mode 100644 index d1b7b13..0000000 --- a/atlas_extracter.py +++ /dev/null @@ -1,293 +0,0 @@ -from __future__ import annotations -import logging -from dataclasses import dataclass, field -from io import BytesIO -from pathlib import Path -from typing import Dict, List, Mapping, Optional, Tuple, Union - -from PIL import Image - -logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s') - -@dataclass -class AtlasRegion: - name: str # unique key (e.g. "arm#2" for a duplicate region name) - atlas_name: str # real name written to the atlas (e.g. "arm") - page_filename: str - index: int = -1 - x: int = 0 - y: int = 0 - w: int = 0 - h: int = 0 - offsets: Optional[Tuple[int, int, int, int]] = None - rotate: int = 0 - split: Optional[List[int]] = None - pad: Optional[List[int]] = None - extra_pairs: List[Tuple[str, List[str]]] = field(default_factory=list) - -@dataclass -class AtlasPage: - filename: str - size: Tuple[int, int] = (0, 0) - format: str = "RGBA8888" - filter: Tuple[str, str] = ("Nearest", "Nearest") - repeat: str = "none" - pma: bool = False - scale_x: float = 1.0 - scale_y: float = 1.0 - -class AtlasProcessor: - def __init__(self, atlas_content: str, image_loader: Mapping[str, Union[str, bytes, Path, Image.Image]]): - self.atlas_content = atlas_content - self.pages: List[AtlasPage] = [] - self.regions: Dict[str, AtlasRegion] = {} - self._loaded_images: Dict[str, Image.Image] = {} - self._page_map: Dict[str, AtlasPage] = {} - self._cache: Dict[str, Image.Image] = {} # Cache for extracted regions - - self._parse_atlas() - if image_loader: - self._load_images(image_loader) - - def _parse_atlas(self) -> None: - lines = [line.strip() for line in self.atlas_content.splitlines()] - iterator = iter(lines) - - current_page: Optional[AtlasPage] = None - current_region: Optional[AtlasRegion] = None - region_name_counts: Dict[str, int] = {} - - def get_unique_region_key(atlas_name: str) -> str: - nxt = region_name_counts.get(atlas_name, 0) + 1 - region_name_counts[atlas_name] = nxt - return atlas_name if nxt == 1 else f"{atlas_name}#{nxt}" - - while True: - try: - line = next(iterator) - except StopIteration: - break - - if not line: - # --- จุดที่แก้ไข --- - # เจอบรรทัดว่าง "ห้าม" Reset current_page หรือ current_region ทิ้ง - # เพราะบางไฟล์มีบรรทัดว่างคั่นระหว่าง Name กับ Properties - continue - - # 1. Check Page (ends with .png) - if line.endswith('.png'): - current_page = AtlasPage(filename=line) - self.pages.append(current_page) - self._page_map[line] = current_page - current_region = None # New page starts, reset region context - continue - - # 2. Check Key-Value Pair - if ':' in line: - key, value_str = line.split(':', 1) - key = key.strip().lower() - values = [v.strip() for v in value_str.split(',')] - - if current_region: - # Region Properties - if key == 'bounds': - if len(values) >= 4: - current_region.x = int(values[0]) - current_region.y = int(values[1]) - current_region.w = int(values[2]) - current_region.h = int(values[3]) - elif key == 'xy': # Support LibGDX old format - current_region.x = int(values[0]) - current_region.y = int(values[1]) - elif key == 'size' and current_region.w == 0: - # Only apply size to region if we haven't got bounds yet - current_region.w = int(values[0]) - current_region.h = int(values[1]) - elif key == 'rotate': - val = values[0].lower() - if val == 'true': - current_region.rotate = 90 - elif val == 'false': - current_region.rotate = 0 - else: - try: - current_region.rotate = int(val) - except ValueError: - current_region.rotate = 0 - elif key == 'offsets': - if len(values) >= 4: - current_region.offsets = tuple(map(int, values[:4])) # type: ignore - elif key == 'index': - current_region.index = int(values[0]) - elif key == 'split' and len(values) >= 4: - current_region.split = [int(v) for v in values] - elif key == 'pad' and len(values) >= 4: - current_region.pad = [int(v) for v in values] - else: - current_region.extra_pairs.append((key, list(values))) - - elif current_page: - if key == 'size': - current_page.size = (int(values[0]), int(values[1])) - elif key == 'format': - current_page.format = values[0] - elif key == 'filter': - current_page.filter = (values[0], values[1]) - elif key == 'repeat': - current_page.repeat = values[0] - elif key == 'pma': - current_page.pma = str(values[0]).strip().lower() == 'true' - - else: - # 3. If no colon and not .png -> It's a Region Name - if current_page is None: - continue - - # Found a new region name -> Create object. - # Duplicate names (common with Spine index: multi-region sprites) - # get a unique dict key; the real name is kept in atlas_name. - region_key = get_unique_region_key(line) - current_region = AtlasRegion( - name=region_key, - atlas_name=line, - page_filename=current_page.filename, - ) - self.regions[region_key] = current_region - - def _load_images(self, loader: Mapping[str, Union[str, bytes, Path, Image.Image]]) -> None: - for page in self.pages: - source = loader.get(page.filename) - if source is None: - for key, val in loader.items(): - if page.filename in str(key): - source = val - break - - if source is None: - logging.debug(f"❌ Image NOT FOUND for page: {page.filename}, skipping...") - continue - - try: - if isinstance(source, (str, Path)): - img = Image.open(source).convert('RGBA') - elif isinstance(source, bytes): - img = Image.open(BytesIO(source)).convert('RGBA') - elif isinstance(source, Image.Image): - img = source.convert('RGBA') - else: - logging.error(f"Unsupported image source type for {page.filename}: {type(source)}") - continue - - # Auto Scale Check (scale atlas coords to match real image) - if page.size != (0, 0): - atlas_w, atlas_h = page.size - real_w, real_h = img.size - - if real_w != atlas_w or real_h != atlas_h: - page.scale_x = real_w / atlas_w - page.scale_y = real_h / atlas_h - logging.warning( - f"⚠️ Scale Mismatch: Atlas={atlas_w}x{atlas_h}, " - f"Real={real_w}x{real_h}. " - f"Scaling atlas coords (x{page.scale_x:.3f}, x{page.scale_y:.3f})" - ) - - self._loaded_images[page.filename] = img - logging.info(f"✅ Loaded {page.filename} ({img.size})") - - except Exception as e: - logging.error(f"Failed to load image {page.filename}: {e}") - - @staticmethod - def crop_and_rotate( - image: Image.Image, x: int, y: int, w: int, h: int, rotate: int, - ) -> Image.Image: - """Crop a region from *image* and undo atlas rotation. - - Args: - image: Source atlas image. - x, y: Top-left position of the region in the atlas. - w, h: Region dimensions (before atlas rotation is applied). - rotate: Atlas rotation value (0, 90, 180, 270). - - Returns: - The cropped sprite in its original (unrotated) orientation. - """ - # When rotated 90/270, the atlas stores w/h swapped - crop_w = h if rotate in (90, 270) else w - crop_h = w if rotate in (90, 270) else h - - sprite = image.crop((x, y, x + crop_w, y + crop_h)) - - if rotate == 90: - sprite = sprite.transpose(Image.Transpose.ROTATE_270) - elif rotate == 270: - sprite = sprite.transpose(Image.Transpose.ROTATE_90) - elif rotate == 180: - sprite = sprite.transpose(Image.Transpose.ROTATE_180) - - return sprite - - def get_page_image(self, page_filename: Optional[str] = None) -> Optional[Image.Image]: - """Get a loaded page image by filename, or the first one if not specified.""" - if page_filename: - return self._loaded_images.get(page_filename) - if self._loaded_images: - return next(iter(self._loaded_images.values())) - return None - - def extract_region(self, name: str) -> Optional[Image.Image]: - region = self.regions.get(name) - if not region: return None - - base_img = self._loaded_images.get(region.page_filename) - if not base_img: return None - - x, y, raw_w, raw_h = region.x, region.y, region.w, region.h - rot = region.rotate - - # Apply page scale factors (atlas coords → real image coords) - page = self._page_map.get(region.page_filename) - if page and (page.scale_x != 1.0 or page.scale_y != 1.0): - sx, sy = page.scale_x, page.scale_y - x = round(x * sx) - y = round(y * sy) - raw_w = round(raw_w * sx) - raw_h = round(raw_h * sy) - - sprite = self.crop_and_rotate(base_img, x, y, raw_w, raw_h, rot) - current_w, current_h = sprite.size - - # Offsets - if region.offsets: - off_x, off_y, orig_w, orig_h = region.offsets - - # Scale offsets to match real image - if page and (page.scale_x != 1.0 or page.scale_y != 1.0): - sx, sy = page.scale_x, page.scale_y - off_x = round(off_x * sx) - off_y = round(off_y * sy) - orig_w = round(orig_w * sx) - orig_h = round(orig_h * sy) - - canvas = Image.new('RGBA', (orig_w, orig_h), (0, 0, 0, 0)) - paste_x = off_x - paste_y = orig_h - off_y - current_h - - canvas.paste(sprite, (paste_x, paste_y)) - self._cache[name] = canvas - return canvas - else: - self._cache[name] = sprite - return sprite - - def extract_all(self) -> List[Tuple[str, Image.Image]]: - results = [] - for name in self.regions: - try: - img = self.extract_region(name) - if img: - results.append((name, img)) - except Exception as e: - logging.error(f"Failed to extract {name}: {e}") - return results \ No newline at end of file diff --git a/atlas_modifier.py b/atlas_modifier.py deleted file mode 100644 index c36a0f3..0000000 --- a/atlas_modifier.py +++ /dev/null @@ -1,1097 +0,0 @@ -""" -Atlas Mod Merger Module - -Merges modified mod images back into the original atlas PNG, -expanding the canvas (right or below, with optional 90° rotation) -and updating region bounds in the atlas file. -The placement strategy that yields the smallest total pixel area is chosen. -""" - -from __future__ import annotations - -import hashlib -import logging -import shutil -from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Tuple - -from PIL import Image - -if TYPE_CHECKING: - pass - -logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") - - -class RegionInfo(NamedTuple): - """Information about a region parsed from atlas file.""" - name: str # unique key (e.g. "arm#2") - bounds: Tuple[int, int, int, int] # x, y, w, h - offsets: Optional[Tuple[int, int, int, int]] # off_x, off_y, orig_w, orig_h - rotate: int # 0, 90, 180, 270 - atlas_name: str = "" # real name emitted to the atlas - page: str = "" - index: int = -1 - split: Optional[List[int]] = None - pad: Optional[List[int]] = None - extra_pairs: List[Tuple[str, List[str]]] = [] - - -def parse_atlas(atlas_text: str) -> Tuple[Dict[str, object], List[str], Dict[str, RegionInfo]]: - from atlas_extracter import AtlasProcessor - processor = AtlasProcessor(atlas_text, {}) - - page_info: Dict[str, object] = {} - if processor.pages: - p = processor.pages[0] - page_info["page"] = p.filename - page_info["size"] = f"{p.size[0]},{p.size[1]}" - page_info["format"] = p.format - page_info["filter"] = f"{p.filter[0]}, {p.filter[1]}" - page_info["repeat"] = p.repeat - page_info["pma"] = bool(p.pma) - - region_names = list(processor.regions.keys()) - regions = {} - for name, r in processor.regions.items(): - regions[name] = RegionInfo( - name=name, - bounds=(r.x, r.y, r.w, r.h), - offsets=r.offsets, - rotate=r.rotate, - atlas_name=r.atlas_name or name, - page=r.page_filename, - index=r.index, - split=r.split, - pad=r.pad, - extra_pairs=list(r.extra_pairs), - ) - - return page_info, region_names, regions - -# Type alias for updated region data: (bounds, offsets, rotate) -UpdatedRegionData = Dict[ - str, - Tuple[Tuple[int, int, int, int], Optional[Tuple[int, int, int, int]], int], -] - - -def _format_rotate(rotate_val: int) -> Optional[str]: - """Convert a rotation degree value to its atlas text representation. - - Returns None if rotation is 0 (meaning the line should be omitted). - """ - if rotate_val == 90: - return "true" - if rotate_val == 180: - return "180" - if rotate_val == 270: - return "270" - return None - - -def _is_default_offsets( - offsets: Optional[Tuple[int, int, int, int]], - bounds: Tuple[int, int, int, int], -) -> bool: - """True when offsets carry no information (0,0,w,h) and can be omitted.""" - if not offsets or not bounds: - return True - return ( - offsets[0] == 0 - and offsets[1] == 0 - and offsets[2] == bounds[2] - and offsets[3] == bounds[3] - ) - - -def _is_default_page_format(fmt: object) -> bool: - return str(fmt or "").upper() == "RGBA8888" - - -def _is_default_page_filter(flt: object) -> bool: - return "".join(str(flt or "").split()).lower() == "nearest,nearest" - - -def _is_default_page_repeat(repeat: object) -> bool: - return str(repeat or "").lower() == "none" - - -def _flush_pending_rotate( - result: List[str], - region: Optional[str], - updated_regions: UpdatedRegionData, - rotate_written: bool, -) -> None: - """Insert a ``rotate:`` line if the region needs one but hasn't got it yet.""" - if region is None or region not in updated_regions or rotate_written: - return - _, _, rotate_val = updated_regions[region] - rotate_str = _format_rotate(rotate_val) - if rotate_str is not None: - result.append(f" rotate: {rotate_str}") - logging.debug(f"Inserted rotate for {region}: {rotate_val}") - - -def _flush_pending_offsets( - result: List[str], - region: Optional[str], - updated_regions: UpdatedRegionData, - offsets_written: bool, -) -> None: - """Insert an ``offsets:`` line if one is required but missing.""" - if region is None or region not in updated_regions or offsets_written: - return - _, new_offsets, _ = updated_regions[region] - if new_offsets is None: - return - result.append( - f" offsets: {new_offsets[0]}, {new_offsets[1]}, " - f"{new_offsets[2]}, {new_offsets[3]}" - ) - logging.debug(f"Inserted offsets for {region}: {new_offsets}") - - -def update_atlas_text( - atlas_text: str, - new_size: Tuple[int, int], - updated_regions: UpdatedRegionData, -) -> str: - """ - Reconstructs the atlas text with updated bounds/offsets for specific regions. - """ - lines = atlas_text.splitlines() - result: List[str] = [] - current_region: Optional[str] = None - in_page_header = False - rotate_written = False - offsets_written = False - - for line in lines: - stripped = line.strip() - - if stripped.endswith(".png"): - _flush_pending_offsets( - result, current_region, updated_regions, offsets_written - ) - _flush_pending_rotate( - result, current_region, updated_regions, rotate_written - ) - result.append(line) - in_page_header = True - current_region = None - rotate_written = False - offsets_written = False - continue - - if in_page_header: - if stripped.startswith("size:"): - result.append(f"size: {new_size[0]},{new_size[1]}") - continue - if ":" not in stripped and stripped: - in_page_header = False - - if ":" not in stripped and stripped and not stripped.endswith(".png"): - _flush_pending_offsets( - result, current_region, updated_regions, offsets_written - ) - _flush_pending_rotate( - result, current_region, updated_regions, rotate_written - ) - current_region = stripped - rotate_written = False - offsets_written = False - result.append(line) - continue - - if current_region in updated_regions: - new_bounds, new_offsets, rotate_val = updated_regions[current_region] - - if stripped.startswith("bounds:"): - result.append( - f" bounds: {new_bounds[0]}, {new_bounds[1]}, " - f"{new_bounds[2]}, {new_bounds[3]}" - ) - logging.debug(f"Updated bounds for {current_region}: {new_bounds}") - continue - - if stripped.startswith("offsets:"): - if new_offsets: - result.append( - f" offsets: {new_offsets[0]}, {new_offsets[1]}, " - f"{new_offsets[2]}, {new_offsets[3]}" - ) - else: - result.append(line) - offsets_written = True - logging.debug(f"Updated offsets for {current_region}: {new_offsets}") - continue - - if stripped.startswith("rotate:"): - rotate_str = _format_rotate(rotate_val) - if rotate_str is not None: - result.append(f" rotate: {rotate_str}") - else: - result.append(" rotate: false") - rotate_written = True - logging.debug(f"Updated rotate for {current_region}: {rotate_val}") - continue - - result.append(line) - - _flush_pending_offsets(result, current_region, updated_regions, offsets_written) - _flush_pending_rotate(result, current_region, updated_regions, rotate_written) - - return "\n".join(result) - -def rebuild_atlas_text( - page_info: Dict[str, object], - new_size: Tuple[int, int], - region_names: List[str], - region_data: Dict[str, tuple], -) -> str: - """ - Build a complete atlas text from scratch. - - Args: - page_info: Original page metadata (must contain 'page'). - new_size: (width, height) of the new canvas. - region_names: Ordered list of region names. - region_data: Mapping of name → (bounds, offsets, rotate[, meta]). - ``meta`` is an optional dict carrying atlas_name, index, split, - pad, extra_pairs so duplicate names and unknown keys survive. - - Default page keys (format RGBA8888, filter nearest,nearest, repeat none), - default offsets (0,0,w,h) and pma:false are omitted for clean output. - """ - lines: List[str] = [ - str(page_info.get("page", "atlas.png")), - f"size: {new_size[0]},{new_size[1]}", - ] - if not _is_default_page_format(page_info.get("format")): - lines.append(f"format: {page_info.get('format')}") - if not _is_default_page_filter(page_info.get("filter")): - lines.append(f"filter: {page_info.get('filter')}") - if not _is_default_page_repeat(page_info.get("repeat")): - lines.append(f"repeat: {page_info.get('repeat')}") - if page_info.get("pma") is True: - lines.append("pma: true") - - for name in region_names: - if name not in region_data: - continue - entry = region_data[name] - bounds, offsets, rotate_val = entry[0], entry[1], entry[2] - meta: Dict[str, object] = entry[3] if len(entry) > 3 and entry[3] else {} - - atlas_name = meta.get("atlas_name") or meta.get("name") or name - lines.append(str(atlas_name)) - - index = meta.get("index") - if isinstance(index, int) and index != -1: - lines.append(f" index: {index}") - - rotate_str = _format_rotate(rotate_val) - if rotate_str: - lines.append(f" rotate: {rotate_str}") - - lines.append( - f" bounds: {bounds[0]}, {bounds[1]}, {bounds[2]}, {bounds[3]}" - ) - if offsets and not _is_default_offsets(offsets, bounds): - lines.append( - f" offsets: {offsets[0]}, {offsets[1]}, " - f"{offsets[2]}, {offsets[3]}" - ) - - split = meta.get("split") - if isinstance(split, (list, tuple)) and len(split) >= 4: - lines.append(" split: " + ", ".join(str(v) for v in split)) - pad = meta.get("pad") - if isinstance(pad, (list, tuple)) and len(pad) >= 4: - lines.append(" pad: " + ", ".join(str(v) for v in pad)) - extra_pairs = meta.get("extra_pairs") - if isinstance(extra_pairs, (list, tuple)): - for pair in extra_pairs: - if not pair or not pair[0]: - continue - key, vals = pair[0], pair[1] if len(pair) > 1 else [] - lines.append(f" {key}: " + ", ".join(str(v) for v in vals)) - - return "\n".join(lines) - - -class _PlacementOption(NamedTuple): - """A candidate placement for the mod image.""" - - label: str - canvas_w: int - canvas_h: int - paste_x: int - paste_y: int - rotated: bool # True = mod image rotated 90° CW before pasting - - -class AtlasModifier: - """Handles merging mod images into an atlas and saving the result.""" - - def __init__(self, atlas_text: str, atlas_path: Path, base_image: Image.Image) -> None: - self.atlas_path = atlas_path - self.base_image = base_image.convert("RGBA") - - # Scale atlas coordinates to match real image size (if mismatched) - self.atlas_text = self._scale_atlas_text(atlas_text) - _, self.region_names, self.regions = parse_atlas(self.atlas_text) - - def adopt_merge_result(self, image: Image.Image, atlas_text: str) -> None: - """Use a merge/repack output as the base for subsequent modifications.""" - self.base_image = image.convert("RGBA") - self.atlas_text = atlas_text - _, self.region_names, self.regions = parse_atlas(self.atlas_text) - - def _scale_atlas_text(self, atlas_text: str) -> str: - """If image size differs from atlas page size, return atlas text - with all coordinates scaled to match the real image.""" - page_info, _, regions = parse_atlas(atlas_text) - size_str = page_info.get("size") - if not isinstance(size_str, str): - return atlas_text - - atlas_w, atlas_h = (int(v.strip()) for v in size_str.split(",")) - real_w, real_h = self.base_image.size - - if real_w == atlas_w and real_h == atlas_h: - return atlas_text - - sx = real_w / atlas_w - sy = real_h / atlas_h - logging.info( - f"Modifier: scaling atlas coords " - f"(Atlas={atlas_w}x{atlas_h} → Image={real_w}x{real_h})" - ) - - updated: UpdatedRegionData = {} - for name, info in regions.items(): - x, y, w, h = info.bounds - new_bounds = (round(x * sx), round(y * sy), round(w * sx), round(h * sy)) - new_offsets: Optional[Tuple[int, int, int, int]] = None - if info.offsets: - ox, oy, ow, oh = info.offsets - new_offsets = (round(ox * sx), round(oy * sy), round(ow * sx), round(oh * sy)) - updated[name] = (new_bounds, new_offsets, info.rotate) - - return update_atlas_text(atlas_text, (real_w, real_h), updated) - - # ------------------------------------------------------------------ # - # Placement strategy # - # ------------------------------------------------------------------ # - - @staticmethod - def _find_best_placement( - base_w: int, - base_h: int, - mod_w: int, - mod_h: int, - *, - allow_rotate: bool = True, - ) -> _PlacementOption: - """ - Evaluate 4 placement strategies and return the one with the - smallest total canvas area (width × height). - - Strategies: - 1. right — mod appended to the right - 2. right + rotate — mod rotated 90° CW then appended to the right - 3. below — mod appended below - 4. below + rotate — mod rotated 90° CW then appended below - """ - # After 90° CW rotation, width/height swap. - rot_w, rot_h = mod_h, mod_w - - candidates: List[_PlacementOption] = [ - _PlacementOption( - label="right", - canvas_w=base_w + mod_w, - canvas_h=max(base_h, mod_h), - paste_x=base_w, - paste_y=0, - rotated=False, - ), - _PlacementOption( - label="right+rotated", - canvas_w=base_w + rot_w, - canvas_h=max(base_h, rot_h), - paste_x=base_w, - paste_y=0, - rotated=True, - ), - _PlacementOption( - label="below", - canvas_w=max(base_w, mod_w), - canvas_h=base_h + mod_h, - paste_x=0, - paste_y=base_h, - rotated=False, - ), - _PlacementOption( - label="below+rotated", - canvas_w=max(base_w, rot_w), - canvas_h=base_h + rot_h, - paste_x=0, - paste_y=base_h, - rotated=True, - ), - ] - - if not allow_rotate: - candidates = [c for c in candidates if not c.rotated] - - best = min(candidates, key=lambda c: c.canvas_w * c.canvas_h) - - for c in candidates: - area = c.canvas_w * c.canvas_h - tag = " ← best" if c is best else "" - logging.info( - f" {c.label:20s} {c.canvas_w}x{c.canvas_h} = {area:,} px²{tag}" - ) - - return best - - @staticmethod - def _canvas_size_match( - mod_w: int, - mod_h: int, - canvas_w: int, - canvas_h: int, - tolerance: float = 0.02, - ) -> bool: - """True when *mod* dimensions match *canvas* within rounding tolerance.""" - if canvas_w <= 0 or canvas_h <= 0: - return False - dw = abs(mod_w - canvas_w) - dh = abs(mod_h - canvas_h) - return ( - dw <= max(2, round(canvas_w * tolerance)) - and dh <= max(2, round(canvas_h * tolerance)) - ) - - def _resolve_mod_canvas( - self, - selected_regions: List[str], - mod_w: int, - mod_h: int, - ) -> Tuple[int, int, int, int, int, int, bool]: - """ - Derive target canvas size and padding anchor for a mod image. - - Returns: - (orig_canvas_w, orig_canvas_h, base_orig_w, base_orig_h, - off_x, off_y, is_full_canvas) - """ - canvas_sizes: set[Tuple[int, int]] = set() - regions_with_offsets: List[RegionInfo] = [] - for name in selected_regions: - region = self.regions.get(name) - if region and region.offsets: - canvas_sizes.add((region.offsets[2], region.offsets[3])) - regions_with_offsets.append(region) - - if regions_with_offsets: - - def _anchor_key(r: RegionInfo) -> tuple[int, int, int]: - o = r.offsets - assert o is not None - return (o[0] + o[1], o[0], o[1]) - - base_orig_w, base_orig_h = next(iter(canvas_sizes)) - anchor = min(regions_with_offsets, key=_anchor_key) - anchor_off = anchor.offsets - assert anchor_off is not None - off_x, off_y = anchor_off[0], anchor_off[1] - orig_canvas_w, orig_canvas_h = base_orig_w, base_orig_h - else: - base_orig_w, base_orig_h = mod_w, mod_h - off_x, off_y = 0, 0 - orig_canvas_w, orig_canvas_h = mod_w, mod_h - - shared_canvas = len(canvas_sizes) == 1 and len(selected_regions) > 1 - - # Detect proportional scale (e.g. mod is 2x the expected canvas) - if ( - orig_canvas_w > 0 - and orig_canvas_h > 0 - and (mod_w != orig_canvas_w or mod_h != orig_canvas_h) - ): - ratio_w = mod_w / orig_canvas_w - ratio_h = mod_h / orig_canvas_h - if abs(ratio_w - ratio_h) < 0.05 and not (0.95 < ratio_w < 1.05): - mod_scale = (ratio_w + ratio_h) / 2 - orig_canvas_w = round(orig_canvas_w * mod_scale) - orig_canvas_h = round(orig_canvas_h * mod_scale) - logging.info( - f"Mod image scale: {mod_scale:.3f}x " - f"(canvas → {orig_canvas_w}x{orig_canvas_h})" - ) - else: - orig_canvas_w = mod_w - orig_canvas_h = mod_h - - is_full_canvas = shared_canvas or self._canvas_size_match( - mod_w, mod_h, orig_canvas_w, orig_canvas_h - ) - if is_full_canvas: - orig_canvas_w, orig_canvas_h = mod_w, mod_h - off_x, off_y = 0, 0 - - return ( - orig_canvas_w, - orig_canvas_h, - base_orig_w, - base_orig_h, - off_x, - off_y, - is_full_canvas, - ) - - def _selected_share_canvas(self, selected_regions: List[str]) -> bool: - """True when every selected region shares one logical canvas size.""" - sizes: set[Tuple[int, int]] = set() - for name in selected_regions: - region = self.regions.get(name) - if not region or not region.offsets: - return False - sizes.add((region.offsets[2], region.offsets[3])) - return len(sizes) == 1 and len(selected_regions) > 1 - - # ------------------------------------------------------------------ # - # Merge # - # ------------------------------------------------------------------ # - - def merge_mod_image( - self, mod_image_path: Path, selected_regions: List[str] - ) -> Tuple[Image.Image, str]: - """ - Merges a mod image onto the base atlas canvas for the selected regions. - - The placement strategy (right / below, with optional 90° rotation) - that yields the smallest total canvas area is chosen automatically. - - Returns: - Tuple of (merged PIL Image, new atlas text). - """ - if not selected_regions: - raise ValueError("No regions selected for modification") - - mod_img = Image.open(mod_image_path).convert("RGBA") - - base_w, base_h = self.base_image.size - mod_w, mod_h = mod_img.size - - logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") - - # offsets format: [left, bottom, originalWidth, originalHeight] (Spine spec) - ( - orig_canvas_w, - orig_canvas_h, - base_orig_w, - base_orig_h, - off_x_orig, - off_y_orig, - is_full_canvas, - ) = self._resolve_mod_canvas(selected_regions, mod_w, mod_h) - - if is_full_canvas: - logging.info("Mod image treated as full canvas replacement") - - # Pad trimmed sprite mods onto the logical canvas. - # Full-canvas mods (combined multi-region sheets, or mod ≈ canvas size) - # are pasted at (0, 0) — trim offsets only apply to partial sprites. - if not is_full_canvas and ( - mod_w != orig_canvas_w or mod_h != orig_canvas_h - ): - scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 - scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 - paste_x = round(off_x_orig * scale_x) - paste_y = orig_canvas_h - mod_h - round(off_y_orig * scale_y) - logging.info( - f"Padding mod image to canvas: " - f"{orig_canvas_w}x{orig_canvas_h} at ({paste_x}, {paste_y})" - ) - padded_mod = Image.new( - "RGBA", (orig_canvas_w, orig_canvas_h), (0, 0, 0, 0) - ) - padded_mod.paste(mod_img, (paste_x, paste_y)) - mod_img = padded_mod - mod_w, mod_h = orig_canvas_w, orig_canvas_h - - shared_canvas_mod = ( - is_full_canvas and self._selected_share_canvas(selected_regions) - ) - if shared_canvas_mod: - logging.info( - "Shared logical canvas: all selected regions use one atlas area" - ) - - # --- Find the best placement --- - best = self._find_best_placement( - base_w, - base_h, - mod_w, - mod_h, - allow_rotate=not shared_canvas_mod, - ) - logging.info(f"Chosen placement: {best.label}") - - # Rotate the mod image if the best strategy requires it - if best.rotated: - # ROTATE_90 in Pillow == 90° counter-clockwise - mod_img = mod_img.transpose(Image.Transpose.ROTATE_90) - - # Create new combined Atlas Image - merged = Image.new( - "RGBA", (best.canvas_w, best.canvas_h), (0, 0, 0, 0) - ) - merged.paste(self.base_image, (0, 0)) - merged.paste(mod_img, (best.paste_x, best.paste_y)) - - # --- Prepare data for atlas text update --- - # - # PIL ROTATE_90 = 90° counter-clockwise - # In Spine Atlas format: - # bounds always store ORIGINAL dimensions (before rotation) - # Extractor will swap w/h when cropping if rotated - rotate_val = 90 if best.rotated else 0 - - # Bounds use ORIGINAL dimensions - no swap! - atlas_bounds_w = mod_w - atlas_bounds_h = mod_h - - updated_regions_data: UpdatedRegionData = {} - - for name in selected_regions: - new_bounds = ( - best.paste_x, - best.paste_y, - atlas_bounds_w, - atlas_bounds_h, - ) - # Full-canvas mod (typical extract output): packed size equals - # original size — no whitespace stripping, omit offsets in atlas. - new_offsets = (0, 0, atlas_bounds_w, atlas_bounds_h) - updated_regions_data[name] = ( - new_bounds, - new_offsets, - rotate_val, - ) - - new_atlas_text = update_atlas_text( - self.atlas_text, - (best.canvas_w, best.canvas_h), - updated_regions_data, - ) - - return merged, new_atlas_text - - def save(self, output_dir: Path, merged_image: Image.Image, atlas_text: str) -> Path: - """ - Save the merged PNG, updated atlas text, and copy .skel if it exists. - - Returns: - The output directory path. - """ - output_dir.mkdir(parents=True, exist_ok=True) - - # Save merged PNG (using the original base PNG's filename) - base_png_name = self.atlas_path.with_suffix(".png").name - merged_png_path = output_dir / base_png_name - merged_image.save(merged_png_path) - - # Save updated atlas text - merged_atlas_path = output_dir / self.atlas_path.name - merged_atlas_path.write_text(atlas_text, encoding="utf-8") - - # Copy .skel if it exists - skel_path = self.atlas_path.with_suffix(".skel") - if skel_path.exists(): - shutil.copy(skel_path, output_dir / skel_path.name) - logging.info("Copied .skel file.") - - logging.info(f"Saved merged files to: {output_dir}") - return output_dir - - # ------------------------------------------------------------------ # - # Repack # - # ------------------------------------------------------------------ # - - @staticmethod - def _extract_raw_sprite( - image: Image.Image, region: RegionInfo - ) -> Image.Image: - """ - Crop the raw sprite from *image* according to *region* bounds/rotate. - - Returns the sprite in its **unrotated** orientation (the visual pixel - data the artist intended), without any offset padding. - """ - from atlas_extracter import AtlasProcessor - - x, y, w, h = region.bounds - return AtlasProcessor.crop_and_rotate(image, x, y, w, h, region.rotate) - - @staticmethod - def _shelf_pack( - items: List[Tuple[str, int, int]], - ) -> Tuple[int, int, List[Tuple[str, int, int, int, int, bool]]]: - """ - Pack rectangles using Shelf Next-Fit Decreasing Height with - automatic strip-width optimisation. - - Tries multiple candidate strip widths and picks the layout that - yields the smallest bounding-box area. - - Args: - items: list of (name, width, height). - - Returns: - (canvas_w, canvas_h, placements) - where each placement is (name, x, y, placed_w, placed_h, rotated). - """ - import math - - if not items: - return 0, 0, [] - - def _pack_with_width( - rects: List[Tuple[str, int, int]], - strip_w: int, - allow_rotate: bool, - ) -> Tuple[int, int, List[Tuple[str, int, int, int, int, bool]]]: - """Pack *rects* into shelves limited to *strip_w* pixels wide.""" - # Sort by height descending — tall items first for better shelves - sorted_rects = sorted( - rects, key=lambda r: max(r[1], r[2]), reverse=True - ) - - placements: List[Tuple[str, int, int, int, int, bool]] = [] - shelf_y = 0 - shelf_h = 0 - cursor_x = 0 - used_w = 0 - - for name, w, h in sorted_rects: - pw, ph, rotated = w, h, False - - if allow_rotate: - # Pick the orientation that better fits the shelf height. - # If the shelf already has a height, prefer the orientation - # whose height is closer to shelf_h (to waste less). - if shelf_h > 0: - # Option A: as-is - waste_a = max(0, h - shelf_h) if h <= shelf_h else h - shelf_h - # Option B: rotated - waste_b = max(0, w - shelf_h) if w <= shelf_h else w - shelf_h - if waste_b < waste_a: - pw, ph, rotated = h, w, True - else: - # No shelf yet — prefer landscape (shorter height) - if h > w: - pw, ph, rotated = h, w, True - - # Does it fit in current shelf? - if cursor_x + pw > strip_w and cursor_x > 0: - # Start a new shelf - shelf_y += shelf_h - cursor_x = 0 - shelf_h = 0 - - placements.append( - (name, cursor_x, shelf_y, pw, ph, rotated) - ) - cursor_x += pw - used_w = max(used_w, cursor_x) - shelf_h = max(shelf_h, ph) - - canvas_h = shelf_y + shelf_h - return used_w, canvas_h, placements - - # --- Determine candidate strip widths to try --- - max_single = max(max(w, h) for _, w, h in items) - total_w = sum(max(w, h) for _, w, h in items) - total_area = sum(w * h for _, w, h in items) - sqrt_area = int(math.isqrt(total_area)) - - # Build a set of candidate widths between the widest item and total - candidates: set[int] = set() - candidates.add(max_single) - candidates.add(total_w) - candidates.add(max(max_single, sqrt_area)) - candidates.add(max(max_single, int(sqrt_area * 0.8))) - candidates.add(max(max_single, int(sqrt_area * 1.2))) - candidates.add(max(max_single, int(sqrt_area * 1.5))) - candidates.add(max(max_single, int(sqrt_area * 2.0))) - - # Also add widths based on multiples of the widest item - for mult in range(1, 6): - candidates.add(max_single * mult) - - best_result: Optional[ - Tuple[int, int, List[Tuple[str, int, int, int, int, bool]]] - ] = None - best_area = float("inf") - - for strip_w in sorted(candidates): - for allow_rot in (False, True): - cw, ch, placements = _pack_with_width( - items, strip_w, allow_rot - ) - area = cw * ch - if area < best_area: - best_area = area - best_result = (cw, ch, placements) - - assert best_result is not None - cw, ch, _ = best_result - logging.info( - f" Shelf pack: best {cw}x{ch} = {cw * ch:,} px² " - f"(tried {len(candidates)} widths × 2 rotate modes)" - ) - return best_result - - def repack( - self, - merged_image: Image.Image, - atlas_text: str, - ) -> Tuple[Image.Image, str]: - """ - Repack all regions from *merged_image* into a new optimally-sized - canvas, deduplicating regions that share identical pixel data. - - Args: - merged_image: The image output from ``merge_mod_image``. - atlas_text: The atlas text output from ``merge_mod_image``. - - Returns: - Tuple of (repacked PIL Image, new atlas text). - """ - page_info, region_names, regions = parse_atlas(atlas_text) - - # ---- 1. Extract raw sprites ---- - sprites: Dict[str, Image.Image] = {} - for name, info in regions.items(): - sprites[name] = self._extract_raw_sprite(merged_image, info) - - logging.info(f"Repack: extracted {len(sprites)} sprites") - - # ---- 2. Deduplicate by pixel hash ---- - hash_to_canonical: Dict[str, str] = {} # hash → first name - canonical_map: Dict[str, str] = {} # name → canonical name - - for name in region_names: - if name not in sprites: - continue - pixel_hash = hashlib.md5( - sprites[name].tobytes(), usedforsecurity=False - ).hexdigest() - - if pixel_hash in hash_to_canonical: - canonical = hash_to_canonical[pixel_hash] - canonical_map[name] = canonical - logging.info(f" dedup: '{name}' == '{canonical}'") - else: - hash_to_canonical[pixel_hash] = name - canonical_map[name] = name - - unique_names = list(hash_to_canonical.values()) - logging.info( - f"Repack: {len(sprites)} regions → {len(unique_names)} unique" - ) - - # ---- 3. Bin-pack unique sprites ---- - pack_items: List[Tuple[str, int, int]] = [ - (n, sprites[n].width, sprites[n].height) for n in unique_names - ] - canvas_w, canvas_h, placements = self._shelf_pack(pack_items) - - logging.info(f"Repack: canvas {canvas_w}x{canvas_h}") - - # ---- 4. Build placement lookup ---- - # name → (x, y, placed_w, placed_h, rotated) - placement_map: Dict[str, Tuple[int, int, int, int, bool]] = {} - for name, px, py, pw, ph, rotated in placements: - placement_map[name] = (px, py, pw, ph, rotated) - - # ---- 5. Paste sprites onto new canvas ---- - canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) - - for name in unique_names: - px, py, pw, ph, rotated = placement_map[name] - sprite = sprites[name] - if rotated: - sprite = sprite.transpose(Image.Transpose.ROTATE_90) - canvas.paste(sprite, (px, py)) - - # ---- 6. Build region data for atlas text ---- - region_data: Dict[str, tuple] = {} - - for name in region_names: - if name not in canonical_map: - continue - canonical = canonical_map[name] - px, py, pw, ph, rotated = placement_map[canonical] - # PIL ROTATE_90 = 90° CCW - rotate_val = 90 if rotated else 0 - - orig_sprite = sprites[name] - orig_w, orig_h = orig_sprite.width, orig_sprite.height - - # Bounds always use ORIGINAL dimensions - no swap! - bounds = (px, py, orig_w, orig_h) - - info = regions[name] - region_data[name] = ( - bounds, - info.offsets, - rotate_val, - { - "atlas_name": info.atlas_name or info.name, - "index": info.index, - "split": info.split, - "pad": info.pad, - "extra_pairs": info.extra_pairs, - }, - ) - - new_atlas_text = rebuild_atlas_text( - page_info, (canvas_w, canvas_h), region_names, region_data - ) - - return canvas, new_atlas_text - - -# ---------------------------------------------------------------------------- # -# Multi-page repack # -# ---------------------------------------------------------------------------- # - - -def repack_multi_page( - all_sprites: Dict[str, Image.Image], - num_pages: int, - page_infos: List[Dict[str, object]], - region_metas: Dict[str, Dict[str, object]], -) -> Tuple[List[Image.Image], str]: - """Repack all sprites across *num_pages* pages. - - Greedy first-fit-decreasing assignment (largest sprite → least-filled page), - then shelf-pack each page. Mirrors the JS ``repackMultiPage``. - - Unlike the single-page :meth:`AtlasModifier.repack`, this does **not** - deduplicate identical sprites — every sprite is placed. - - Args: - all_sprites: name → sprite image (already whitespace-restored). - num_pages: number of output pages. - page_infos: per-page metadata dicts (page, format, filter, repeat, pma). - region_metas: name → dict(atlas_name, index, split, pad, extra_pairs). - - Returns: - (list of page images, atlas text). - """ - sprite_names = list(all_sprites.keys()) - if not sprite_names or num_pages == 0: - return [], "" - - # Greedy first-fit-decreasing: largest area first → least-filled group. - ordered = sorted( - sprite_names, - key=lambda n: all_sprites[n].width * all_sprites[n].height, - reverse=True, - ) - groups: List[Dict[str, object]] = [ - {"names": [], "area": 0} for _ in range(num_pages) - ] - for name in ordered: - s = all_sprites[name] - g = min(groups, key=lambda gr: gr["area"]) # type: ignore[index] - g["names"].append(name) # type: ignore[attr-defined] - g["area"] += s.width * s.height # type: ignore[operator] - - result_pages: List[Image.Image] = [] - atlas_lines: List[str] = [] - - def _emit_page_keys(pi: Dict[str, object]) -> None: - if not _is_default_page_format(pi.get("format")): - atlas_lines.append(f"format: {pi.get('format')}") - if not _is_default_page_filter(pi.get("filter")): - atlas_lines.append(f"filter: {pi.get('filter')}") - if not _is_default_page_repeat(pi.get("repeat")): - atlas_lines.append(f"repeat: {pi.get('repeat')}") - if pi.get("pma") is True: - atlas_lines.append("pma: true") - - for i in range(num_pages): - names: List[str] = groups[i]["names"] # type: ignore[assignment] - pi = page_infos[i] if i < len(page_infos) else page_infos[0] - - if i > 0: - atlas_lines.append("") # blank line between page sections - atlas_lines.append(str(pi.get("page"))) - - if not names: - atlas_lines.append("size: 1,1") - _emit_page_keys(pi) - result_pages.append(Image.new("RGBA", (1, 1), (0, 0, 0, 0))) - continue - - items = [(n, all_sprites[n].width, all_sprites[n].height) for n in names] - canvas_w, canvas_h, placements = AtlasModifier._shelf_pack(items) - placement_map = {p[0]: p for p in placements} - - canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) - for name in names: - placement = placement_map.get(name) - if not placement: - continue - _, px, py, _pw, _ph, rotated = placement - sprite = all_sprites[name] - if rotated: - sprite = sprite.transpose(Image.Transpose.ROTATE_90) - canvas.paste(sprite, (px, py)) - result_pages.append(canvas) - - atlas_lines.append(f"size: {canvas_w},{canvas_h}") - _emit_page_keys(pi) - - for name in names: - placement = placement_map.get(name) - if not placement: - continue - _, px, py, _pw, _ph, rotated = placement - sprite = all_sprites[name] # original (unrotated) dims for bounds - meta = region_metas.get(name, {}) - - atlas_lines.append(str(meta.get("atlas_name") or name)) - index = meta.get("index") - if isinstance(index, int) and index != -1: - atlas_lines.append(f" index: {index}") - rotate_str = _format_rotate(90 if rotated else 0) - if rotate_str: - atlas_lines.append(f" rotate: {rotate_str}") - atlas_lines.append( - f" bounds: {px}, {py}, {sprite.width}, {sprite.height}" - ) - split = meta.get("split") - if isinstance(split, (list, tuple)) and len(split) >= 4: - atlas_lines.append(" split: " + ", ".join(str(v) for v in split)) - pad = meta.get("pad") - if isinstance(pad, (list, tuple)) and len(pad) >= 4: - atlas_lines.append(" pad: " + ", ".join(str(v) for v in pad)) - extra_pairs = meta.get("extra_pairs") - if isinstance(extra_pairs, (list, tuple)): - for pair in extra_pairs: - if not pair or not pair[0]: - continue - key = pair[0] - vals = pair[1] if len(pair) > 1 else [] - atlas_lines.append(f" {key}: " + ", ".join(str(v) for v in vals)) - - return result_pages, "\n".join(atlas_lines) \ No newline at end of file diff --git a/atlas_toolkit/__init__.py b/atlas_toolkit/__init__.py new file mode 100644 index 0000000..69da59b --- /dev/null +++ b/atlas_toolkit/__init__.py @@ -0,0 +1,5 @@ +"""AtlasToolkit — Spine atlas extract, modify, and repack.""" + +from __future__ import annotations + +__version__ = "0.2.2" diff --git a/atlas_toolkit/__main__.py b/atlas_toolkit/__main__.py new file mode 100644 index 0000000..3540f3c --- /dev/null +++ b/atlas_toolkit/__main__.py @@ -0,0 +1,4 @@ +from atlas_toolkit.app.launch import run + +if __name__ == "__main__": + run() diff --git a/atlas_toolkit/app/__init__.py b/atlas_toolkit/app/__init__.py new file mode 100644 index 0000000..88a5de7 --- /dev/null +++ b/atlas_toolkit/app/__init__.py @@ -0,0 +1,11 @@ +"""Application layer — session orchestration and pywebview bridge.""" + +from atlas_toolkit.app.config import AppConfig +from atlas_toolkit.app.session import AtlasSession, ModifyResult, ModifyViewData + +__all__ = [ + "AppConfig", + "AtlasSession", + "ModifyResult", + "ModifyViewData", +] diff --git a/atlas_toolkit/app/bridge.py b/atlas_toolkit/app/bridge.py new file mode 100644 index 0000000..566d760 --- /dev/null +++ b/atlas_toolkit/app/bridge.py @@ -0,0 +1,430 @@ +"""pywebview bridge — dialogs, JS callbacks, and OS integration.""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import subprocess +import sys +import threading +import time +import webbrowser +import webview +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Any, List, Optional +from urllib.parse import urlparse + +from atlas_toolkit.app.config import AppConfig +from atlas_toolkit.app.session import AtlasSession, ModifyResult, ModifyViewData +from atlas_toolkit.update.controller import UpdateController +from atlas_toolkit.update.updater import get_current_version + +if TYPE_CHECKING: + from PIL.Image import Image + +log = logging.getLogger(__name__) + +IMAGE_EXTENSIONS = {".png"} + + +def _image_to_base64(img: Image) -> str: + buffered = BytesIO() + img.save(buffered, format="PNG") + return f"data:image/png;base64,{base64.b64encode(buffered.getvalue()).decode('utf-8')}" + + +def _modify_view_to_payload(view: ModifyViewData) -> dict[str, object]: + payload: dict[str, object] = { + "image": _image_to_base64(view.image), + "regions": view.regions, + "overlayRects": view.overlay_rects, + "pages": view.pages, + "regionPages": view.region_pages, + "activePage": view.active_page, + "modifiedRegions": view.modified_regions, + } + if view.extra: + payload.update(view.extra) + return payload + + +def _modify_result_to_payload(result: ModifyResult) -> dict[str, object]: + payload: dict[str, object] = { + "image": _image_to_base64(result.image), + "regions": result.regions, + "overlayRects": result.overlay_rects, + "modifiedRegions": result.modified_regions, + } + if result.extra: + payload.update(result.extra) + return payload + + +class Api: + """pywebview bridge — dialogs, JS callbacks, and OS integration.""" + + def __init__(self, pending_update_failure: Optional[dict[str, str]] = None) -> None: + self._window: Optional[webview.Window] = None + self._session = AtlasSession() + self._config = AppConfig() + self._updates = UpdateController(pending_failure=pending_update_failure) + + def set_window(self, window: webview.Window) -> None: + self._window = window + + def get_pref(self, key: str, default: Any = None) -> Any: + return self._config.get(key, default) + + def set_pref(self, key: str, value: Any) -> None: + self._config.set(key, value) + + def startup_check(self) -> bool: + time.sleep(0.5) + threading.Thread(target=self._run_update_check, daemon=True).start() + + if self._updates.pending_failure and self._window: + payload_json = json.dumps(self._updates.pending_failure) + self._window.evaluate_js( + f"window.showUpdateInstallFailed({payload_json});" + ) + + if len(sys.argv) > 1 and sys.argv[1].endswith(".atlas"): + return self.load_atlas(sys.argv[1]) + return False + + def open_update_log(self, log_path: str) -> dict[str, Any]: + p = Path(log_path) + if not p.exists(): + return {"ok": False, "error": "Log file not found."} + try: + if sys.platform == "win32": + os.startfile(str(p)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", str(p)], close_fds=True) + else: + subprocess.Popen(["xdg-open", str(p)], close_fds=True) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": f"Failed to open log file: {e}"} + + def open_url(self, url: str) -> dict[str, Any]: + try: + parsed = urlparse(str(url)) + if parsed.scheme not in ("http", "https"): + return {"ok": False, "error": "Invalid URL scheme."} + webbrowser.open(parsed.geturl()) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": f"Failed to open URL: {e}"} + + def choose_file(self) -> bool: + if not self._window: + return False + file_types = ("Atlas Files (*.atlas)", "All files (*.*)") + result = self._window.create_file_dialog( + webview.FileDialog.OPEN, allow_multiple=False, file_types=file_types + ) + if result: + return self.load_atlas(result[0]) + return False + + def load_atlas(self, path_str: str) -> bool: + log.debug("load_atlas received path: %r", path_str) + if not self._window: + return False + + try: + atlas_path = Path(path_str) + content = atlas_path.read_text(encoding="utf-8") + page_images: dict[str, Path] = {} + + for page_name, resolved in self._session.resolve_page_images( + atlas_path, content + ).items(): + if resolved is not None: + page_images[page_name] = resolved + continue + + self._window.evaluate_js( + f"alert('Image \\\"{page_name}\\\" not found. Please locate it.')" + ) + file_types = ( + f"{Path(page_name).stem} (*{Path(page_name).suffix})", + "All files (*.*)", + ) + result = self._window.create_file_dialog( + webview.FileDialog.OPEN, + allow_multiple=False, + file_types=file_types, + directory=str(atlas_path.parent), + ) + if result: + page_images[page_name] = Path(result[0]) + else: + self._window.evaluate_js("alert('Load cancelled.')") + return False + + self._session.load(atlas_path, page_images) + self._window.set_title( + f"Atlas Toolkit v{get_current_version()} - {atlas_path.name}" + ) + return True + except Exception as e: + self._window.evaluate_js(f"alert('Error: {str(e)}')") + return False + + def get_region_names(self) -> List[str]: + return self._session.get_region_names() + + def get_preview(self, names: List[str]) -> Optional[str]: + img = self._session.get_preview_image(names) + return _image_to_base64(img) if img else None + + def save_preview_image( + self, png_data_url: str, default_filename: str = "merged.png" + ) -> str: + if not self._window: + return "Error: Window not ready." + try: + b64 = png_data_url.split(",", 1)[1] if "," in png_data_url else png_data_url + data = base64.b64decode(b64) + except Exception as e: + return f"Error: Invalid image data ({e})." + + default_dir = ( + str(self._session.atlas_path.parent) if self._session.atlas_path else "" + ) + result = self._window.create_file_dialog( + webview.FileDialog.SAVE, + directory=default_dir, + save_filename=default_filename, + ) + if not result: + return "Cancelled" + try: + Path(result[0]).write_bytes(data) + return f"Saved to {result[0]}" + except Exception as e: + log.error("Save preview image error: %s", e) + return f"Error: {e}" + + def extract_files(self, region_names: Optional[List[str]]) -> str: + if not self._session.is_loaded or not self._window: + return "No atlas loaded or window not ready." + + extracted = self._session.extract_regions(region_names) + if not extracted: + return "No regions to extract." + + is_single = len(extracted) == 1 + default_dir = str(self._session.atlas_path.parent) if self._session.atlas_path else "" + save_path: Any = None + + if is_single: + result = self._window.create_file_dialog( + webview.FileDialog.SAVE, + directory=default_dir, + save_filename=f"{extracted[0][0]}.png", + ) + if result: + save_path = result[0] + else: + result = self._window.create_file_dialog( + webview.FileDialog.FOLDER, directory=default_dir + ) + if result: + save_path = result[0] + + if not save_path: + return "Cancelled" + + try: + for name, img in extracted: + if is_single: + dest = Path(save_path) + else: + safe_name = "".join( + x for x in name if x.isalnum() or x in "._- " + ) + dest = Path(save_path) / f"{safe_name}.png" + img.save(dest) + return f"Successfully extracted {len(extracted)} images." + except Exception as e: + return f"Error: {str(e)}" + + def enter_modify_mode(self) -> Optional[dict[str, object]]: + view = self._session.enter_modify_mode() + if view is not None: + log.debug("Entered modify mode") + return _modify_view_to_payload(view) + return None + + def reset_modify_mode(self) -> Optional[dict[str, object]]: + view = self._session.reset_modify_mode() + if view is not None: + log.debug("Reset modify mode to original atlas") + return _modify_view_to_payload(view) + return None + + def exit_modify_mode(self) -> None: + self._session.exit_modify_mode() + log.debug("Exited modify mode") + + def select_mod_image( + self, selected_names: List[str], repack: bool = False + ) -> Optional[dict[str, object]]: + if not self._window or not self._session.modifier: + return None + + file_types = ("PNG Files (*.png)", "All files (*.*)") + default_dir = ( + str(self._session.atlas_path.parent) if self._session.atlas_path else "" + ) + result = self._window.create_file_dialog( + webview.FileDialog.OPEN, + allow_multiple=False, + file_types=file_types, + directory=default_dir, + ) + if not result: + return None + return self.process_mod_image(result[0], selected_names, repack) + + def process_mod_image( + self, path_str: str, selected_names: List[str], repack: bool = False + ) -> Optional[dict[str, object]]: + result = self._session.process_mod_image(path_str, selected_names, repack) + if result is None: + if self._window: + self._window.evaluate_js("showToast('Error processing mod image.', 'error')") + return None + return _modify_result_to_payload(result) + + def get_modify_page_preview(self, index: int) -> Optional[str]: + img = self._session.get_modify_page_image(index) + return _image_to_base64(img) if img else None + + def save_modified(self) -> str: + if not self._session.has_merged_output() or not self._window: + return "Error: No merged data to save." + + default_dir = ( + str(self._session.atlas_path.parent) if self._session.atlas_path else "" + ) + result = self._window.create_file_dialog( + webview.FileDialog.FOLDER, + directory=default_dir, + ) + if not result: + return "Cancelled" + + try: + self._session.save_merged_to(Path(result[0])) + return f"Saved to: {result[0]}" + except Exception as e: + return f"Error: {e}" + + def toggle_repack(self, repack: bool) -> Optional[dict[str, object]]: + result = self._session.toggle_repack(repack) + return _modify_result_to_payload(result) if result else None + + def debug_log(self, msg: str) -> None: + log.debug("JS: %s", msg) + + def on_drop(self, e: Any) -> None: + try: + files = e["dataTransfer"]["files"] + if len(files) == 0: + return + + path = files[0].get("pywebviewFullPath") + log.debug("Dropped file path: %s", path) + if not path: + return + + path_lower = path.lower() + if path_lower.endswith(".atlas"): + if self.load_atlas(path) and self._window: + self._window.evaluate_js("onAtlasLoadedFromPython()") + elif any(path_lower.endswith(ext) for ext in IMAGE_EXTENSIONS): + if self._session.modifier: + self._handle_image_drop(path) + elif self._window: + self._window.evaluate_js( + "showToast('Enter Modify Mode first to drop images.', 'error')" + ) + elif self._window: + self._window.evaluate_js("showToast('Unsupported file type.', 'error')") + except Exception as ex: + log.error("Drop error: %s", ex) + + def _handle_image_drop(self, path: str) -> None: + if not self._window: + return + + selected_json = self._window.evaluate_js("JSON.stringify(getSelectedNames())") + if not selected_json: + self._window.evaluate_js( + "showToast('Select at least one region first.', 'error')" + ) + return + + names: list[str] = json.loads(selected_json) + if not names: + self._window.evaluate_js( + "showToast('Select at least one region first.', 'error')" + ) + return + + repack_val = self._window.evaluate_js( + "document.getElementById('chk-repack').checked" + ) + result = self.process_mod_image(path, names, bool(repack_val)) + if result: + result_json = json.dumps(result) + self._window.evaluate_js(f"window.onModImageProcessed({result_json})") + else: + self._window.evaluate_js("showToast('Failed to process mod image.', 'error')") + + def get_update_download_progress(self) -> dict[str, Any]: + return self._updates.get_progress() + + def download_update(self) -> dict[str, Any]: + return self._updates.download() + + def restart_and_install_update(self) -> dict[str, Any]: + def _close_window() -> None: + if self._window: + self._window.destroy() + + return self._updates.restart_and_install(on_success=_close_window) + + def _run_update_check(self) -> None: + try: + payload = self._updates.check_for_notification() + if payload and self._window: + args_json = json.dumps(payload) + self._window.evaluate_js( + f"window.showUpdateNotification({args_json});" + ) + except Exception as e: + log.warning("Update check failed: %s", e) + + +def setup_drop(window: webview.Window, api: Api) -> None: + try: + from webview.dom import DOMEventHandler + + def _no_op(e: Any) -> None: + pass + + log.debug("Binding drop events...") + doc = window.dom.document + doc.events.dragover += DOMEventHandler(_no_op, True, True, debounce=500) # type: ignore[operator] + doc.events.drop += DOMEventHandler(api.on_drop, True, True) # type: ignore[operator] + log.debug("Drop events bound.") + except Exception as e: + log.error("Failed to setup drop events: %s", e) diff --git a/atlas_toolkit/app/config.py b/atlas_toolkit/app/config.py new file mode 100644 index 0000000..3b6fb82 --- /dev/null +++ b/atlas_toolkit/app/config.py @@ -0,0 +1,49 @@ +"""Persistent user preferences for AtlasToolkit.""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + + +def get_config_dir() -> Path: + if sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + d = base / "AtlasToolkit" + d.mkdir(parents=True, exist_ok=True) + return d + + +CONFIG_PATH = get_config_dir() / "config.json" + + +class AppConfig: + def __init__(self) -> None: + self._data: dict[str, Any] = self._load() + + @staticmethod + def _load() -> dict[str, Any]: + try: + if CONFIG_PATH.exists(): + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + def get(self, key: str, default: Any = None) -> Any: + return self._data.get(key, default) + + def set(self, key: str, value: Any) -> None: + self._data[key] = value + try: + CONFIG_PATH.write_text(json.dumps(self._data), encoding="utf-8") + except Exception as e: + log.warning("Failed to save config: %s", e) diff --git a/atlas_toolkit/app/launch.py b/atlas_toolkit/app/launch.py new file mode 100644 index 0000000..1b7eb97 --- /dev/null +++ b/atlas_toolkit/app/launch.py @@ -0,0 +1,122 @@ +"""Desktop application entry — window creation and pywebview startup.""" + +from __future__ import annotations + +import logging +import sys +from typing import Optional + +import webview + +from atlas_toolkit.app.bridge import Api, setup_drop +from atlas_toolkit.paths import resource_path +from atlas_toolkit.update.updater import get_current_version + +log = logging.getLogger(__name__) + + +def _consume_launch_flags(argv: list[str]) -> tuple[list[str], Optional[dict[str, str]]]: + clean_args: list[str] = [] + failed = False + failed_log = "" + failed_release_url = "" + failed_message = "" + + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--update-install-failed": + failed = True + i += 1 + continue + if arg == "--update-failed-log" and i + 1 < len(argv): + failed_log = argv[i + 1] + i += 2 + continue + if arg == "--update-release-url" and i + 1 < len(argv): + failed_release_url = argv[i + 1] + i += 2 + continue + if arg == "--update-failed-message" and i + 1 < len(argv): + failed_message = argv[i + 1] + i += 2 + continue + clean_args.append(arg) + i += 1 + + payload: Optional[dict[str, str]] = None + if failed: + payload = { + "message": failed_message or "Update installation failed. The app was relaunched.", + "logPath": failed_log, + "releaseUrl": failed_release_url, + } + return clean_args, payload + + +def configure_stdio() -> None: + if sys.stdout: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] + if sys.stderr: + sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr] + + +def configure_logging() -> None: + logging.basicConfig( + level=logging.DEBUG, + format="%(levelname)s: %(message)s", + stream=sys.stdout, + ) + + +def run() -> None: + configure_stdio() + configure_logging() + + clean_argv, pending_failure = _consume_launch_flags(sys.argv[1:]) + sys.argv = [sys.argv[0], *clean_argv] + + if sys.platform == "win32": + try: + import ctypes + + ctypes.windll.kernel32.CreateMutexW( # type: ignore[attr-defined] + None, False, "AtlasToolkitSingleInstanceMutex" + ) + except Exception: + pass + + api = Api(pending_update_failure=pending_failure) + + window_width, window_height = 1200, 800 + if sys.platform == "win32": + import ctypes + + screen_width = ctypes.windll.user32.GetSystemMetrics(0) + screen_height = ctypes.windll.user32.GetSystemMetrics(1) + else: + _scr = webview.screens[0] + screen_width, screen_height = _scr.width, _scr.height + center_x = (screen_width - window_width) // 2 + center_y = (screen_height - window_height) // 2 + + gui_path = resource_path("ui/index.html") + window = webview.create_window( + f"Atlas Toolkit v{get_current_version()}", + url=str(gui_path.absolute().as_uri()), + width=window_width, + height=window_height, + min_size=(800, 500), + x=center_x, + y=center_y, + resizable=True, + js_api=api, + background_color="#2b2b2b", + ) + + if window: + api.set_window(window) + else: + sys.exit(1) + + webview.start(func=setup_drop, args=(window, api)) diff --git a/atlas_toolkit/app/session.py b/atlas_toolkit/app/session.py new file mode 100644 index 0000000..83d67a4 --- /dev/null +++ b/atlas_toolkit/app/session.py @@ -0,0 +1,431 @@ +"""Domain orchestration for atlas load, extract, and modify — no UI dependencies.""" + +from __future__ import annotations + +import logging +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +from atlas_toolkit.atlas.converter import auto_convert_atlas +from atlas_toolkit.core.document import AtlasDocument +from atlas_toolkit.atlas.extracter import AtlasProcessor +from atlas_toolkit.atlas.modifier import AtlasModifier, parse_atlas +from atlas_toolkit.atlas.repacker import repack_multi_page +from atlas_toolkit.core.overlay import overlay_rects_for_regions +from atlas_toolkit.core.region_ops import extract_raw_sprite + +if TYPE_CHECKING: + from PIL.Image import Image + +log = logging.getLogger(__name__) + + +@dataclass +class ModifyViewData: + """Modify-mode view state before bridge encoding for JS.""" + + image: Image + regions: dict[str, list[int]] + overlay_rects: dict[str, list[int]] + pages: list[str] + region_pages: dict[str, str] + active_page: Optional[str] + modified_regions: list[str] + extra: dict[str, object] = field(default_factory=dict) + + +@dataclass +class ModifyResult: + """Result of merge/repack before bridge encodes image to base64.""" + + image: Image + atlas_text: str + regions: dict[str, list[int]] + overlay_rects: dict[str, list[int]] + modified_regions: list[str] + extra: dict[str, object] = field(default_factory=dict) + + +class AtlasSession: + """In-memory atlas workflow: load → extract / modify → save.""" + + def __init__(self) -> None: + self.atlas_path: Optional[Path] = None + self.processor: Optional[AtlasProcessor] = None + self.modifier: Optional[AtlasModifier] = None + self.merged_image: Optional[Image] = None + self.merged_atlas_text: Optional[str] = None + self.merged_pages: Optional[List[Image]] = None + self.pre_repack_image: Optional[Image] = None + self.pre_repack_text: Optional[str] = None + self.modified_regions: set[str] = set() + + @property + def is_loaded(self) -> bool: + return self.processor is not None and self.atlas_path is not None + + @staticmethod + def required_page_names(atlas_text: str) -> list[str]: + return AtlasDocument.parse(atlas_text).page_filenames() + + def resolve_page_images( + self, atlas_path: Path, atlas_text: str + ) -> dict[str, Optional[Path]]: + """Map each required page to an on-disk path next to the atlas, if present.""" + atlas_dir = atlas_path.parent + return { + name: (atlas_dir / name if (atlas_dir / name).exists() else None) + for name in self.required_page_names(atlas_text) + } + + def load(self, atlas_path: Path, page_images: Dict[str, Path]) -> None: + content = atlas_path.read_text(encoding="utf-8") + loader = {name: path for name, path in page_images.items()} + self.atlas_path = atlas_path + self.processor = AtlasProcessor(auto_convert_atlas(content), loader) + self.clear_modify_state() + + def clear_modify_state(self) -> None: + self.modifier = None + self.merged_image = None + self.merged_atlas_text = None + self.merged_pages = None + self.pre_repack_image = None + self.pre_repack_text = None + self.modified_regions = set() + + def get_region_names(self) -> List[str]: + if not self.processor: + return [] + return list(self.processor.regions.keys()) + + def get_preview_image(self, names: List[str]) -> Optional[Image]: + from PIL import Image + + if not self.processor or not names: + return None + + try: + images: List[Image] = [] + max_w, max_h = 0, 0 + valid_names = [n for n in names if n in self.processor.regions] + + for name in valid_names: + img = self.processor.extract_region(name) + if img: + images.append(img) + max_w = max(max_w, img.width) + max_h = max(max_h, img.height) + + if not images: + return None + if len(images) == 1: + return images[0] + + monitor = Image.new("RGBA", (max_w, max_h), (0, 0, 0, 0)) + for img in reversed(images): + layer = Image.new("RGBA", monitor.size, (0, 0, 0, 0)) + layer.paste(img, (0, 0)) + monitor = Image.alpha_composite(monitor, layer) + return monitor + except Exception as e: + log.error("Preview error: %s", e) + return None + + def extract_regions( + self, region_names: Optional[List[str]] + ) -> List[tuple[str, Image]]: + from PIL import Image # noqa: F401 — ensures Image in scope for type checkers + + if not self.processor: + return [] + target = region_names if region_names else list(self.processor.regions.keys()) + out: List[tuple[str, Image]] = [] + for name in target: + img = self.processor.extract_region(name) + if img: + out.append((name, img)) + return out + + def build_modify_view(self, *, clear_modified: bool = False) -> Optional[ModifyViewData]: + if not self.processor or not self.atlas_path: + return None + + try: + atlas_text = self.atlas_path.read_text(encoding="utf-8") + base_image = self.processor.get_page_image() + if not base_image: + log.error("No loaded images in processor") + return None + + if clear_modified: + self.modified_regions = set() + + self.merged_image = None + self.merged_atlas_text = None + self.merged_pages = None + self.pre_repack_image = None + self.pre_repack_text = None + + self.modifier = AtlasModifier( + auto_convert_atlas(atlas_text), self.atlas_path, base_image + ) + + region_bounds: dict[str, list[int]] = {} + for name, info in self.modifier.regions.items(): + region_bounds[name] = info.bounds_with_rotate() + + pages = [p.filename for p in self.processor.pages] + region_pages = { + name: r.page_filename for name, r in self.processor.regions.items() + } + + return ModifyViewData( + image=base_image, + regions=region_bounds, + overlay_rects=overlay_rects_for_regions(self.modifier.regions), + pages=pages, + region_pages=region_pages, + active_page=pages[0] if pages else None, + modified_regions=sorted(self.modified_regions), + ) + except Exception as e: + log.error("Building modify view: %s", e) + return None + + def enter_modify_mode(self) -> Optional[ModifyViewData]: + return self.build_modify_view(clear_modified=False) + + def reset_modify_mode(self) -> Optional[ModifyViewData]: + return self.build_modify_view(clear_modified=True) + + def exit_modify_mode(self) -> None: + self.clear_modify_state() + + def process_mod_image( + self, path_str: str, selected_names: List[str], repack: bool = False + ) -> Optional[ModifyResult]: + if not self.modifier: + return None + + if self.processor and len(self.processor.pages) > 1: + return self._process_mod_multi_page(path_str, selected_names) + + try: + mod_path = Path(path_str) + log.debug("Processing mod image: %s", mod_path) + + merged_image, merged_atlas_text = self.modifier.merge_mod_image( + mod_path, selected_names + ) + + self.pre_repack_image = merged_image + self.pre_repack_text = merged_atlas_text + + if repack: + log.debug("Running repack...") + merged_image, merged_atlas_text = self.modifier.repack( + merged_image, merged_atlas_text + ) + + self.merged_image = merged_image + self.merged_atlas_text = merged_atlas_text + self.modified_regions.update(selected_names) + self.modifier.adopt_merge_result( + self.pre_repack_image, self.pre_repack_text + ) + + return self._build_modify_result(merged_image, merged_atlas_text) + except Exception as e: + log.error("Processing mod image: %s", e) + return None + + def _extract_sprites_from_merged_pages(self) -> dict[str, Image]: + if not self.merged_pages or not self.merged_atlas_text: + return {} + + page_names = AtlasDocument.parse(self.merged_atlas_text).page_filenames() + page_images = { + name: self.merged_pages[i] + for i, name in enumerate(page_names) + if i < len(self.merged_pages) + } + + _, _, regions = parse_atlas(self.merged_atlas_text) + sprites: dict[str, Image] = {} + for name, region in regions.items(): + page_img = page_images.get(region.page_filename) + if page_img is not None: + sprites[name] = extract_raw_sprite(page_img, region) + return sprites + + def _process_mod_multi_page( + self, path_str: str, selected_names: List[str] + ) -> Optional[ModifyResult]: + if not self.processor: + return None + + try: + from PIL import Image + + if self.merged_pages and self.merged_atlas_text: + all_sprites = self._extract_sprites_from_merged_pages() + else: + all_sprites = {} + for name in self.processor.regions: + sprite = self.processor.extract_region(name) + if sprite is not None: + all_sprites[name] = sprite + + mod_img = Image.open(Path(path_str)).convert("RGBA") + for name in selected_names: + if name in all_sprites: + all_sprites[name] = mod_img + + page_infos: list[dict[str, object]] = [ + { + "page": p.filename, + "format": p.format, + "filter": f"{p.filter[0]}, {p.filter[1]}", + "repeat": p.repeat, + "pma": p.pma, + } + for p in self.processor.pages + ] + region_metas: dict[str, dict[str, object]] = { + name: r.to_meta_dict() for name, r in self.processor.regions.items() + } + + pages, atlas_text = repack_multi_page( + all_sprites, len(self.processor.pages), page_infos, region_metas + ) + + self.merged_pages = pages + self.merged_atlas_text = atlas_text + self.merged_image = None + self.pre_repack_image = None + self.pre_repack_text = None + self.modified_regions.update(selected_names) + + _, _, merged_regions = parse_atlas(atlas_text) + region_bounds: dict[str, list[int]] = {} + region_pages: dict[str, str] = {} + for name, region in merged_regions.items(): + region_bounds[name] = region.bounds_with_rotate() + region_pages[name] = region.page_filename + + preview = pages[0] if pages else Image.new("RGBA", (1, 1)) + return ModifyResult( + image=preview, + atlas_text=atlas_text, + regions=region_bounds, + overlay_rects=overlay_rects_for_regions(merged_regions), + modified_regions=sorted(self.modified_regions), + extra={ + "regionPages": region_pages, + "pages": [str(pi["page"]) for pi in page_infos], + "pageCount": len(pages), + "previewPage": str(page_infos[0]["page"]) if page_infos else None, + }, + ) + except Exception as e: + log.error("Processing multi-page mod image: %s", e) + return None + + def _build_modify_result( + self, image: Image, atlas_text: str, extra: Optional[dict[str, object]] = None + ) -> ModifyResult: + _, _, merged_regions = parse_atlas(atlas_text) + region_bounds: dict[str, list[int]] = {} + for name, region in merged_regions.items(): + region_bounds[name] = region.bounds_with_rotate() + + return ModifyResult( + image=image, + atlas_text=atlas_text, + regions=region_bounds, + overlay_rects=overlay_rects_for_regions(merged_regions), + modified_regions=sorted(self.modified_regions), + extra=extra or {}, + ) + + def get_modify_page_image(self, index: int) -> Optional[Image]: + try: + if self.merged_pages is not None: + if 0 <= index < len(self.merged_pages): + return self.merged_pages[index] + return None + if self.processor and 0 <= index < len(self.processor.pages): + return self.processor.get_page_image( + self.processor.pages[index].filename + ) + return None + except Exception as e: + log.error("get_modify_page_preview: %s", e) + return None + + def has_merged_output(self) -> bool: + if self.merged_atlas_text is None: + return False + return self.merged_pages is not None or ( + self.modifier is not None and self.merged_image is not None + ) + + def save_merged_to(self, output_dir: Path) -> None: + if not self.has_merged_output() or not self.atlas_path: + raise RuntimeError("No merged data to save") + + if self.merged_pages is not None: + self._save_multi_page(output_dir) + elif self.modifier and self.merged_image and self.merged_atlas_text: + self.modifier.save(output_dir, self.merged_image, self.merged_atlas_text) + else: + raise RuntimeError("No merged data to save") + + def _save_multi_page(self, output_dir: Path) -> None: + if not self.processor or not self.atlas_path or self.merged_pages is None: + return + output_dir.mkdir(parents=True, exist_ok=True) + + for i, page_img in enumerate(self.merged_pages): + if i < len(self.processor.pages): + page_name = self.processor.pages[i].filename + else: + page_name = f"page{i}.png" + page_img.save(output_dir / Path(page_name).name) + + if self.merged_atlas_text is not None: + (output_dir / self.atlas_path.name).write_text( + self.merged_atlas_text, encoding="utf-8" + ) + + skel_path = self.atlas_path.with_suffix(".skel") + if skel_path.exists(): + shutil.copy(skel_path, output_dir / skel_path.name) + + def toggle_repack(self, repack: bool) -> Optional[ModifyResult]: + if not self.modifier or not self.pre_repack_image or not self.pre_repack_text: + return None + + try: + if repack: + log.debug("Applying repack...") + image, text = self.modifier.repack( + self.pre_repack_image, self.pre_repack_text + ) + else: + log.debug("Reverting to pre-repack merge result") + image = self.pre_repack_image + text = self.pre_repack_text + + self.merged_image = image + self.merged_atlas_text = text + self.modifier.adopt_merge_result( + self.pre_repack_image, self.pre_repack_text + ) + return self._build_modify_result(image, text) + except Exception as e: + log.error("toggle_repack: %s", e) + return None diff --git a/atlas_toolkit/atlas/__init__.py b/atlas_toolkit/atlas/__init__.py new file mode 100644 index 0000000..75d1b2b --- /dev/null +++ b/atlas_toolkit/atlas/__init__.py @@ -0,0 +1,19 @@ +"""Atlas parsing, extraction, modification, and repack.""" + +from atlas_toolkit.atlas.converter import auto_convert_atlas, convert_atlas_to_new_format, is_old_format +from atlas_toolkit.atlas.extracter import AtlasProcessor +from atlas_toolkit.atlas.modifier import AtlasModifier, parse_atlas, rebuild_atlas_text, update_atlas_text +from atlas_toolkit.atlas.repacker import repack_multi_page, repack_single_page + +__all__ = [ + "AtlasModifier", + "AtlasProcessor", + "auto_convert_atlas", + "convert_atlas_to_new_format", + "is_old_format", + "parse_atlas", + "rebuild_atlas_text", + "repack_multi_page", + "repack_single_page", + "update_atlas_text", +] diff --git a/atlas_converter.py b/atlas_toolkit/atlas/converter.py similarity index 100% rename from atlas_converter.py rename to atlas_toolkit/atlas/converter.py diff --git a/atlas_toolkit/atlas/extracter.py b/atlas_toolkit/atlas/extracter.py new file mode 100644 index 0000000..6cdf0ef --- /dev/null +++ b/atlas_toolkit/atlas/extracter.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import logging +from io import BytesIO +from pathlib import Path +from typing import Dict, List, Mapping, Optional, Tuple, Union + +from PIL import Image + +from atlas_toolkit.core.document import AtlasDocument, Page, Region +from atlas_toolkit.core.region_ops import extract_region_from_page + +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") + + +class AtlasProcessor: + def __init__( + self, + atlas_content: str, + image_loader: Mapping[str, Union[str, bytes, Path, Image.Image]], + ): + self.atlas_content = atlas_content + self.pages: List[Page] = [] + self.regions: Dict[str, Region] = {} + self._loaded_images: Dict[str, Image.Image] = {} + self._page_map: Dict[str, Page] = {} + self._cache: Dict[str, Image.Image] = {} + + doc = AtlasDocument.parse(atlas_content) + self.pages = doc.pages + self._page_map = {p.filename: p for p in self.pages} + self.regions = doc.regions_by_key() + + if image_loader: + self._load_images(image_loader) + + def _load_images( + self, loader: Mapping[str, Union[str, bytes, Path, Image.Image]] + ) -> None: + for page in self.pages: + source = loader.get(page.filename) + if source is None: + for key, val in loader.items(): + if page.filename in str(key): + source = val + break + + if source is None: + logging.debug( + "Image NOT FOUND for page: %s, skipping...", page.filename + ) + continue + + try: + if isinstance(source, (str, Path)): + img = Image.open(source).convert("RGBA") + elif isinstance(source, bytes): + img = Image.open(BytesIO(source)).convert("RGBA") + elif isinstance(source, Image.Image): + img = source.convert("RGBA") + else: + logging.error( + "Unsupported image source type for %s: %s", + page.filename, + type(source), + ) + continue + + if page.size != (0, 0): + atlas_w, atlas_h = page.size + real_w, real_h = img.size + + if real_w != atlas_w or real_h != atlas_h: + page.scale_x = real_w / atlas_w + page.scale_y = real_h / atlas_h + logging.warning( + "Scale Mismatch: Atlas=%sx%s, Real=%sx%s. " + "Scaling atlas coords (x%.3f, x%.3f)", + atlas_w, + atlas_h, + real_w, + real_h, + page.scale_x, + page.scale_y, + ) + + self._loaded_images[page.filename] = img + logging.info("Loaded %s (%s)", page.filename, img.size) + + except Exception as e: + logging.error("Failed to load image %s: %s", page.filename, e) + + @staticmethod + def crop_and_rotate( + image: Image.Image, x: int, y: int, w: int, h: int, rotate: int + ) -> Image.Image: + from region_ops import crop_and_rotate + + return crop_and_rotate(image, x, y, w, h, rotate) + + def get_page_image(self, page_filename: Optional[str] = None) -> Optional[Image.Image]: + if page_filename: + return self._loaded_images.get(page_filename) + if self._loaded_images: + return next(iter(self._loaded_images.values())) + return None + + def extract_region(self, name: str) -> Optional[Image.Image]: + region = self.regions.get(name) + if not region: + return None + + base_img = self._loaded_images.get(region.page_filename) + if not base_img: + return None + + page = self._page_map.get(region.page_filename) + sprite = extract_region_from_page(base_img, region, page) + self._cache[name] = sprite + return sprite + + def extract_all(self) -> List[Tuple[str, Image.Image]]: + results = [] + for name in self.regions: + try: + img = self.extract_region(name) + if img: + results.append((name, img)) + except Exception as e: + logging.error("Failed to extract %s: %s", name, e) + return results diff --git a/atlas_toolkit/atlas/modifier.py b/atlas_toolkit/atlas/modifier.py new file mode 100644 index 0000000..c9a3a11 --- /dev/null +++ b/atlas_toolkit/atlas/modifier.py @@ -0,0 +1,464 @@ +""" +Atlas Mod Merger Module + +Merges modified mod images back into the original atlas PNG, +expanding the canvas (right or below, with optional 90° rotation) +and updating region bounds in the atlas file. +The placement strategy that yields the smallest total pixel area is chosen. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Tuple + +from PIL import Image + +from atlas_toolkit.core.document import ( + AtlasDocument, + Region, + UpdatedRegionData, +) +from atlas_toolkit.atlas.repacker import repack_single_page + + +def parse_atlas( + atlas_text: str, +) -> Tuple[Dict[str, object], List[str], Dict[str, Region]]: + doc = AtlasDocument.parse(atlas_text) + return doc.first_page_info(), doc.region_keys(), doc.regions_by_key() + + +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") + + +def update_atlas_text( + atlas_text: str, + new_size: Tuple[int, int], + updated_regions: UpdatedRegionData, +) -> str: + """Apply region and page-size updates, then emit canonical atlas text.""" + doc = AtlasDocument.parse(atlas_text) + return doc.with_updates(updated_regions, page_size=new_size).serialize() + + +def rebuild_atlas_text( + page_info: Dict[str, object], + new_size: Tuple[int, int], + region_names: List[str], + region_data: Dict[str, tuple], +) -> str: + """Build canonical atlas text from page metadata and region data.""" + return AtlasDocument.from_rebuild_args( + page_info, new_size, region_names, region_data + ).serialize() + + +class _PlacementOption(NamedTuple): + """A candidate placement for the mod image.""" + + label: str + canvas_w: int + canvas_h: int + paste_x: int + paste_y: int + rotated: bool # True = mod image rotated 90° CW before pasting + + +class AtlasModifier: + """Handles merging mod images into an atlas and saving the result.""" + + def __init__(self, atlas_text: str, atlas_path: Path, base_image: Image.Image) -> None: + self.atlas_path = atlas_path + self.base_image = base_image.convert("RGBA") + + # Scale atlas coordinates to match real image size (if mismatched) + self.atlas_text = self._scale_atlas_text(atlas_text) + _, self.region_names, self.regions = parse_atlas(self.atlas_text) + + def adopt_merge_result(self, image: Image.Image, atlas_text: str) -> None: + """Use a merge/repack output as the base for subsequent modifications.""" + self.base_image = image.convert("RGBA") + self.atlas_text = atlas_text + _, self.region_names, self.regions = parse_atlas(self.atlas_text) + + def _scale_atlas_text(self, atlas_text: str) -> str: + """If image size differs from atlas page size, return atlas text + with all coordinates scaled to match the real image.""" + page_info, _, regions = parse_atlas(atlas_text) + size_str = page_info.get("size") + if not isinstance(size_str, str): + return atlas_text + + atlas_w, atlas_h = (int(v.strip()) for v in size_str.split(",")) + real_w, real_h = self.base_image.size + + if real_w == atlas_w and real_h == atlas_h: + return atlas_text + + sx = real_w / atlas_w + sy = real_h / atlas_h + logging.info( + f"Modifier: scaling atlas coords " + f"(Atlas={atlas_w}x{atlas_h} → Image={real_w}x{real_h})" + ) + + updated: UpdatedRegionData = {} + for name, info in regions.items(): + x, y, w, h = info.bounds + new_bounds = (round(x * sx), round(y * sy), round(w * sx), round(h * sy)) + new_offsets: Optional[Tuple[int, int, int, int]] = None + if info.offsets: + ox, oy, ow, oh = info.offsets + new_offsets = (round(ox * sx), round(oy * sy), round(ow * sx), round(oh * sy)) + updated[name] = (new_bounds, new_offsets, info.rotate) + + return update_atlas_text(atlas_text, (real_w, real_h), updated) + + # ------------------------------------------------------------------ # + # Placement strategy # + # ------------------------------------------------------------------ # + + @staticmethod + def _find_best_placement( + base_w: int, + base_h: int, + mod_w: int, + mod_h: int, + *, + allow_rotate: bool = True, + ) -> _PlacementOption: + """ + Evaluate 4 placement strategies and return the one with the + smallest total canvas area (width × height). + + Strategies: + 1. right — mod appended to the right + 2. right + rotate — mod rotated 90° CW then appended to the right + 3. below — mod appended below + 4. below + rotate — mod rotated 90° CW then appended below + """ + # After 90° CW rotation, width/height swap. + rot_w, rot_h = mod_h, mod_w + + candidates: List[_PlacementOption] = [ + _PlacementOption( + label="right", + canvas_w=base_w + mod_w, + canvas_h=max(base_h, mod_h), + paste_x=base_w, + paste_y=0, + rotated=False, + ), + _PlacementOption( + label="right+rotated", + canvas_w=base_w + rot_w, + canvas_h=max(base_h, rot_h), + paste_x=base_w, + paste_y=0, + rotated=True, + ), + _PlacementOption( + label="below", + canvas_w=max(base_w, mod_w), + canvas_h=base_h + mod_h, + paste_x=0, + paste_y=base_h, + rotated=False, + ), + _PlacementOption( + label="below+rotated", + canvas_w=max(base_w, rot_w), + canvas_h=base_h + rot_h, + paste_x=0, + paste_y=base_h, + rotated=True, + ), + ] + + if not allow_rotate: + candidates = [c for c in candidates if not c.rotated] + + best = min(candidates, key=lambda c: c.canvas_w * c.canvas_h) + + for c in candidates: + area = c.canvas_w * c.canvas_h + tag = " ← best" if c is best else "" + logging.info( + f" {c.label:20s} {c.canvas_w}x{c.canvas_h} = {area:,} px²{tag}" + ) + + return best + + @staticmethod + def _canvas_size_match( + mod_w: int, + mod_h: int, + canvas_w: int, + canvas_h: int, + tolerance: float = 0.02, + ) -> bool: + """True when *mod* dimensions match *canvas* within rounding tolerance.""" + if canvas_w <= 0 or canvas_h <= 0: + return False + dw = abs(mod_w - canvas_w) + dh = abs(mod_h - canvas_h) + return ( + dw <= max(2, round(canvas_w * tolerance)) + and dh <= max(2, round(canvas_h * tolerance)) + ) + + def _resolve_mod_canvas( + self, + selected_regions: List[str], + mod_w: int, + mod_h: int, + ) -> Tuple[int, int, int, int, int, int, bool]: + """ + Derive target canvas size and padding anchor for a mod image. + + Returns: + (orig_canvas_w, orig_canvas_h, base_orig_w, base_orig_h, + off_x, off_y, is_full_canvas) + """ + canvas_sizes: set[Tuple[int, int]] = set() + regions_with_offsets: List[Region] = [] + for name in selected_regions: + region = self.regions.get(name) + if region and region.offsets: + canvas_sizes.add((region.offsets[2], region.offsets[3])) + regions_with_offsets.append(region) + + if regions_with_offsets: + + def _anchor_key(r: Region) -> tuple[int, int, int]: + o = r.offsets + assert o is not None + return (o[0] + o[1], o[0], o[1]) + + base_orig_w, base_orig_h = next(iter(canvas_sizes)) + anchor = min(regions_with_offsets, key=_anchor_key) + anchor_off = anchor.offsets + assert anchor_off is not None + off_x, off_y = anchor_off[0], anchor_off[1] + orig_canvas_w, orig_canvas_h = base_orig_w, base_orig_h + else: + base_orig_w, base_orig_h = mod_w, mod_h + off_x, off_y = 0, 0 + orig_canvas_w, orig_canvas_h = mod_w, mod_h + + shared_canvas = len(canvas_sizes) == 1 and len(selected_regions) > 1 + + # Detect proportional scale (e.g. mod is 2x the expected canvas) + if ( + orig_canvas_w > 0 + and orig_canvas_h > 0 + and (mod_w != orig_canvas_w or mod_h != orig_canvas_h) + ): + ratio_w = mod_w / orig_canvas_w + ratio_h = mod_h / orig_canvas_h + if abs(ratio_w - ratio_h) < 0.05 and not (0.95 < ratio_w < 1.05): + mod_scale = (ratio_w + ratio_h) / 2 + orig_canvas_w = round(orig_canvas_w * mod_scale) + orig_canvas_h = round(orig_canvas_h * mod_scale) + logging.info( + f"Mod image scale: {mod_scale:.3f}x " + f"(canvas → {orig_canvas_w}x{orig_canvas_h})" + ) + else: + orig_canvas_w = mod_w + orig_canvas_h = mod_h + + is_full_canvas = shared_canvas or self._canvas_size_match( + mod_w, mod_h, orig_canvas_w, orig_canvas_h + ) + if is_full_canvas: + orig_canvas_w, orig_canvas_h = mod_w, mod_h + off_x, off_y = 0, 0 + + return ( + orig_canvas_w, + orig_canvas_h, + base_orig_w, + base_orig_h, + off_x, + off_y, + is_full_canvas, + ) + + def _selected_share_canvas(self, selected_regions: List[str]) -> bool: + """True when every selected region shares one logical canvas size.""" + sizes: set[Tuple[int, int]] = set() + for name in selected_regions: + region = self.regions.get(name) + if not region or not region.offsets: + return False + sizes.add((region.offsets[2], region.offsets[3])) + return len(sizes) == 1 and len(selected_regions) > 1 + + # ------------------------------------------------------------------ # + # Merge # + # ------------------------------------------------------------------ # + + def merge_mod_image( + self, mod_image_path: Path, selected_regions: List[str] + ) -> Tuple[Image.Image, str]: + """ + Merges a mod image onto the base atlas canvas for the selected regions. + + The placement strategy (right / below, with optional 90° rotation) + that yields the smallest total canvas area is chosen automatically. + + Returns: + Tuple of (merged PIL Image, new atlas text). + """ + if not selected_regions: + raise ValueError("No regions selected for modification") + + mod_img = Image.open(mod_image_path).convert("RGBA") + + base_w, base_h = self.base_image.size + mod_w, mod_h = mod_img.size + + logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") + + # offsets format: [left, bottom, originalWidth, originalHeight] (Spine spec) + ( + orig_canvas_w, + orig_canvas_h, + base_orig_w, + base_orig_h, + off_x_orig, + off_y_orig, + is_full_canvas, + ) = self._resolve_mod_canvas(selected_regions, mod_w, mod_h) + + if is_full_canvas: + logging.info("Mod image treated as full canvas replacement") + + # Pad trimmed sprite mods onto the logical canvas. + # Full-canvas mods (combined multi-region sheets, or mod ≈ canvas size) + # are pasted at (0, 0) — trim offsets only apply to partial sprites. + if not is_full_canvas and ( + mod_w != orig_canvas_w or mod_h != orig_canvas_h + ): + scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 + scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 + paste_x = round(off_x_orig * scale_x) + paste_y = orig_canvas_h - mod_h - round(off_y_orig * scale_y) + logging.info( + f"Padding mod image to canvas: " + f"{orig_canvas_w}x{orig_canvas_h} at ({paste_x}, {paste_y})" + ) + padded_mod = Image.new( + "RGBA", (orig_canvas_w, orig_canvas_h), (0, 0, 0, 0) + ) + padded_mod.paste(mod_img, (paste_x, paste_y)) + mod_img = padded_mod + mod_w, mod_h = orig_canvas_w, orig_canvas_h + + shared_canvas_mod = ( + is_full_canvas and self._selected_share_canvas(selected_regions) + ) + if shared_canvas_mod: + logging.info( + "Shared logical canvas: all selected regions use one atlas area" + ) + + # --- Find the best placement --- + best = self._find_best_placement( + base_w, + base_h, + mod_w, + mod_h, + allow_rotate=not shared_canvas_mod, + ) + logging.info(f"Chosen placement: {best.label}") + + # Rotate the mod image if the best strategy requires it + if best.rotated: + # ROTATE_90 in Pillow == 90° counter-clockwise + mod_img = mod_img.transpose(Image.Transpose.ROTATE_90) + + # Create new combined Atlas Image + merged = Image.new( + "RGBA", (best.canvas_w, best.canvas_h), (0, 0, 0, 0) + ) + merged.paste(self.base_image, (0, 0)) + merged.paste(mod_img, (best.paste_x, best.paste_y)) + + # --- Prepare data for atlas text update --- + # + # PIL ROTATE_90 = 90° counter-clockwise + # In Spine Atlas format: + # bounds always store ORIGINAL dimensions (before rotation) + # Extractor will swap w/h when cropping if rotated + rotate_val = 90 if best.rotated else 0 + + # Bounds use ORIGINAL dimensions - no swap! + atlas_bounds_w = mod_w + atlas_bounds_h = mod_h + + updated_regions_data: UpdatedRegionData = {} + + for name in selected_regions: + new_bounds = ( + best.paste_x, + best.paste_y, + atlas_bounds_w, + atlas_bounds_h, + ) + # Full-canvas mod (typical extract output): packed size equals + # original size — no whitespace stripping, omit offsets in atlas. + new_offsets = (0, 0, atlas_bounds_w, atlas_bounds_h) + updated_regions_data[name] = ( + new_bounds, + new_offsets, + rotate_val, + ) + + new_atlas_text = update_atlas_text( + self.atlas_text, + (best.canvas_w, best.canvas_h), + updated_regions_data, + ) + + return merged, new_atlas_text + + def save(self, output_dir: Path, merged_image: Image.Image, atlas_text: str) -> Path: + """ + Save the merged PNG, updated atlas text, and copy .skel if it exists. + + Returns: + The output directory path. + """ + output_dir.mkdir(parents=True, exist_ok=True) + + # Save merged PNG (using the original base PNG's filename) + base_png_name = self.atlas_path.with_suffix(".png").name + merged_png_path = output_dir / base_png_name + merged_image.save(merged_png_path) + + # Save updated atlas text + merged_atlas_path = output_dir / self.atlas_path.name + merged_atlas_path.write_text(atlas_text, encoding="utf-8") + + # Copy .skel if it exists + skel_path = self.atlas_path.with_suffix(".skel") + if skel_path.exists(): + shutil.copy(skel_path, output_dir / skel_path.name) + logging.info("Copied .skel file.") + + logging.info(f"Saved merged files to: {output_dir}") + return output_dir + + def repack( + self, + merged_image: Image.Image, + atlas_text: str, + ) -> Tuple[Image.Image, str]: + """Repack all regions into an optimally-sized canvas.""" + result = repack_single_page(merged_image, atlas_text) + return result.image, result.atlas_text \ No newline at end of file diff --git a/atlas_toolkit/atlas/repacker.py b/atlas_toolkit/atlas/repacker.py new file mode 100644 index 0000000..fb3b45d --- /dev/null +++ b/atlas_toolkit/atlas/repacker.py @@ -0,0 +1,343 @@ +"""Shelf-pack repack and atlas text emission for single- and multi-page atlases.""" + +from __future__ import annotations + +import hashlib +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from PIL import Image + +from atlas_toolkit.core.document import AtlasDocument, Page, Region +from atlas_toolkit.core.region_ops import extract_raw_sprite + +log = logging.getLogger(__name__) + +Placement = Tuple[str, int, int, int, int, bool] + + +@dataclass +class RepackSingleResult: + image: Image.Image + atlas_text: str + + +def shelf_pack( + items: List[Tuple[str, int, int]], +) -> Tuple[int, int, List[Placement]]: + """Pack rectangles using shelf next-fit with strip-width optimisation.""" + import math + + if not items: + return 0, 0, [] + + def _pack_with_width( + rects: List[Tuple[str, int, int]], + strip_w: int, + allow_rotate: bool, + ) -> Tuple[int, int, List[Placement]]: + sorted_rects = sorted(rects, key=lambda r: max(r[1], r[2]), reverse=True) + + placements: List[Placement] = [] + shelf_y = 0 + shelf_h = 0 + cursor_x = 0 + used_w = 0 + + for name, w, h in sorted_rects: + pw, ph, rotated = w, h, False + + if allow_rotate: + if shelf_h > 0: + waste_a = max(0, h - shelf_h) if h <= shelf_h else h - shelf_h + waste_b = max(0, w - shelf_h) if w <= shelf_h else w - shelf_h + if waste_b < waste_a: + pw, ph, rotated = h, w, True + elif h > w: + pw, ph, rotated = h, w, True + + if cursor_x + pw > strip_w and cursor_x > 0: + shelf_y += shelf_h + cursor_x = 0 + shelf_h = 0 + + placements.append((name, cursor_x, shelf_y, pw, ph, rotated)) + cursor_x += pw + used_w = max(used_w, cursor_x) + shelf_h = max(shelf_h, ph) + + return used_w, shelf_y + shelf_h, placements + + max_single = max(max(w, h) for _, w, h in items) + total_w = sum(max(w, h) for _, w, h in items) + total_area = sum(w * h for _, w, h in items) + sqrt_area = int(math.isqrt(total_area)) + + candidates: set[int] = { + max_single, + total_w, + max(max_single, sqrt_area), + max(max_single, int(sqrt_area * 0.8)), + max(max_single, int(sqrt_area * 1.2)), + max(max_single, int(sqrt_area * 1.5)), + max(max_single, int(sqrt_area * 2.0)), + } + for mult in range(1, 6): + candidates.add(max_single * mult) + + best_result: Optional[Tuple[int, int, List[Placement]]] = None + best_area = float("inf") + + for strip_w in sorted(candidates): + for allow_rot in (False, True): + cw, ch, placements = _pack_with_width(items, strip_w, allow_rot) + area = cw * ch + if area < best_area: + best_area = area + best_result = (cw, ch, placements) + + assert best_result is not None + cw, ch, _ = best_result + log.info( + "Shelf pack: best %sx%s = %s px² (tried %s widths × 2 rotate modes)", + cw, + ch, + f"{cw * ch:,}", + len(candidates), + ) + return best_result + + +def _deduplicate_sprites( + sprites: Dict[str, Image.Image], + region_names: List[str], +) -> Dict[str, str]: + """Map each region name to a canonical name with identical pixels.""" + hash_to_canonical: Dict[str, str] = {} + canonical_map: Dict[str, str] = {} + + for name in region_names: + if name not in sprites: + continue + pixel_hash = hashlib.md5( + sprites[name].tobytes(), usedforsecurity=False + ).hexdigest() + + if pixel_hash in hash_to_canonical: + canonical = hash_to_canonical[pixel_hash] + canonical_map[name] = canonical + log.info(" dedup: '%s' == '%s'", name, canonical) + else: + hash_to_canonical[pixel_hash] = name + canonical_map[name] = name + + return canonical_map + + +def _paste_packed_sprites( + unique_names: List[str], + sprites: Dict[str, Image.Image], + placement_map: Dict[str, Tuple[int, int, int, int, bool]], + canvas_w: int, + canvas_h: int, +) -> Image.Image: + canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) + for name in unique_names: + px, py, _pw, _ph, rotated = placement_map[name] + sprite = sprites[name] + if rotated: + sprite = sprite.transpose(Image.Transpose.ROTATE_90) + canvas.paste(sprite, (px, py)) + return canvas + + +def repack_single_page( + merged_image: Image.Image, + atlas_text: str, + *, + deduplicate: bool = True, +) -> RepackSingleResult: + """Repack all regions on one page; optionally deduplicate identical sprites.""" + page_info, region_names, regions = _parse_atlas(atlas_text) + + sprites: Dict[str, Image.Image] = { + name: extract_raw_sprite(merged_image, region) + for name, region in regions.items() + } + log.info("Repack: extracted %s sprites", len(sprites)) + + if deduplicate: + canonical_map = _deduplicate_sprites(sprites, region_names) + unique_names = list({canonical_map[n] for n in canonical_map}) + log.info( + "Repack: %s regions → %s unique", + len(sprites), + len(unique_names), + ) + else: + canonical_map = {n: n for n in region_names if n in sprites} + unique_names = [n for n in region_names if n in sprites] + + pack_items = [(n, sprites[n].width, sprites[n].height) for n in unique_names] + canvas_w, canvas_h, placements = shelf_pack(pack_items) + placement_map = {p[0]: (p[1], p[2], p[3], p[4], p[5]) for p in placements} + + canvas = _paste_packed_sprites( + unique_names, sprites, placement_map, canvas_w, canvas_h + ) + + region_data: Dict[str, tuple] = {} + for name in region_names: + if name not in canonical_map: + continue + canonical = canonical_map[name] + px, py, _pw, _ph, rotated = placement_map[canonical] + rotate_val = 90 if rotated else 0 + orig_sprite = sprites[name] + bounds = (px, py, orig_sprite.width, orig_sprite.height) + info = regions[name] + region_data[name] = ( + bounds, + info.offsets, + rotate_val, + info.to_meta_dict(), + ) + + new_atlas_text = AtlasDocument.from_rebuild_args( + page_info, (canvas_w, canvas_h), region_names, region_data + ).serialize() + return RepackSingleResult(image=canvas, atlas_text=new_atlas_text) + + +def _parse_atlas(atlas_text: str): + doc = AtlasDocument.parse(atlas_text) + return doc.first_page_info(), doc.region_keys(), doc.regions_by_key() + + +def repack_multi_page( + all_sprites: Dict[str, Image.Image], + num_pages: int, + page_infos: List[Dict[str, object]], + region_metas: Dict[str, Dict[str, object]], +) -> Tuple[List[Image.Image], str]: + """Repack sprites across multiple pages; emit canonical atlas via AtlasDocument.""" + sprite_names = list(all_sprites.keys()) + if not sprite_names or num_pages == 0: + return [], "" + + ordered = sorted( + sprite_names, + key=lambda n: all_sprites[n].width * all_sprites[n].height, + reverse=True, + ) + groups: List[Dict[str, object]] = [ + {"names": [], "area": 0} for _ in range(num_pages) + ] + for name in ordered: + s = all_sprites[name] + g = min(groups, key=lambda gr: gr["area"]) # type: ignore[index] + g["names"].append(name) # type: ignore[attr-defined] + g["area"] += s.width * s.height # type: ignore[operator] + + result_pages: List[Image.Image] = [] + doc_pages: List[Page] = [] + + for i in range(num_pages): + names: List[str] = groups[i]["names"] # type: ignore[assignment] + pi = page_infos[i] if i < len(page_infos) else page_infos[0] + page_filename = str(pi.get("page", f"page{i}.png")) + + if not names: + result_pages.append(Image.new("RGBA", (1, 1), (0, 0, 0, 0))) + doc_pages.append( + Page( + filename=page_filename, + size=(1, 1), + format=str(pi.get("format", "RGBA8888")), + filter=_page_filter(pi.get("filter")), + repeat=str(pi.get("repeat", "none")), + pma=bool(pi.get("pma")), + ) + ) + continue + + items = [(n, all_sprites[n].width, all_sprites[n].height) for n in names] + canvas_w, canvas_h, placements = shelf_pack(items) + placement_map = {p[0]: p for p in placements} + + sprites_on_page = {n: all_sprites[n] for n in names} + pm_for_paste = { + n: (placement_map[n][1], placement_map[n][2], placement_map[n][3], placement_map[n][4], placement_map[n][5]) + for n in names + if n in placement_map + } + canvas = _paste_packed_sprites( + names, sprites_on_page, pm_for_paste, canvas_w, canvas_h + ) + result_pages.append(canvas) + + page_regions: List[Region] = [] + for name in names: + placement = placement_map.get(name) + if not placement: + continue + _, px, py, _pw, _ph, rotated = placement + sprite = all_sprites[name] + meta = region_metas.get(name, {}) + rotate_val = 90 if rotated else 0 + page_regions.append( + Region( + name=name, + atlas_name=str(meta.get("atlas_name") or name), + page_filename=page_filename, + x=px, + y=py, + w=sprite.width, + h=sprite.height, + rotate=rotate_val, + index=int(meta["index"]) if isinstance(meta.get("index"), int) else -1, + split=list(meta["split"]) if isinstance(meta.get("split"), (list, tuple)) else None, + pad=list(meta["pad"]) if isinstance(meta.get("pad"), (list, tuple)) else None, + extra_pairs=_extra_pairs_from_meta(meta), + ) + ) + + doc_pages.append( + Page( + filename=page_filename, + size=(canvas_w, canvas_h), + format=str(pi.get("format", "RGBA8888")), + filter=_page_filter(pi.get("filter")), + repeat=str(pi.get("repeat", "none")), + pma=bool(pi.get("pma")), + regions=page_regions, + ) + ) + + atlas_text = AtlasDocument(pages=doc_pages).serialize() + return result_pages, atlas_text + + +def _page_filter(value: object) -> Tuple[str, str]: + if isinstance(value, str): + parts = [p.strip() for p in value.split(",")] + if len(parts) >= 2: + return parts[0], parts[1] + if isinstance(value, tuple) and len(value) >= 2: + return str(value[0]), str(value[1]) + return "Nearest", "Nearest" + + +def _extra_pairs_from_meta(meta: Dict[str, object]) -> List[Tuple[str, List[str]]]: + pairs = meta.get("extra_pairs") + if not isinstance(pairs, (list, tuple)): + return [] + out: List[Tuple[str, List[str]]] = [] + for pair in pairs: + if not pair or not pair[0]: + continue + key = str(pair[0]) + vals = [str(v) for v in (pair[1] if len(pair) > 1 else [])] + out.append((key, vals)) + return out diff --git a/atlas_toolkit/core/__init__.py b/atlas_toolkit/core/__init__.py new file mode 100644 index 0000000..d74b688 --- /dev/null +++ b/atlas_toolkit/core/__init__.py @@ -0,0 +1,12 @@ +"""Domain models and geometry.""" + +from atlas_toolkit.core.document import AtlasDocument, Page, Region +from atlas_toolkit.core.overlay import overlay_rect, overlay_rects_for_regions + +__all__ = [ + "AtlasDocument", + "Page", + "Region", + "overlay_rect", + "overlay_rects_for_regions", +] diff --git a/atlas_toolkit/core/document.py b/atlas_toolkit/core/document.py new file mode 100644 index 0000000..1121683 --- /dev/null +++ b/atlas_toolkit/core/document.py @@ -0,0 +1,379 @@ +""" +AtlasDocument — single seam for Spine atlas parse → model → canonical serialize. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Dict, List, Optional, Tuple + +# Region updates: name → (bounds, offsets, rotate) +UpdatedRegionData = Dict[ + str, + Tuple[Tuple[int, int, int, int], Optional[Tuple[int, int, int, int]], int], +] + +_PAGE_KNOWN_KEYS = frozenset({"size", "format", "filter", "repeat", "pma"}) + + +@dataclass +class Region: + """One sprite entry within a page.""" + + name: str # region key (e.g. "arm#2") + atlas_name: str + page_filename: str + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + index: int = -1 + offsets: Optional[Tuple[int, int, int, int]] = None + rotate: int = 0 + split: Optional[List[int]] = None + pad: Optional[List[int]] = None + extra_pairs: List[Tuple[str, List[str]]] = field(default_factory=list) + + @property + def bounds(self) -> Tuple[int, int, int, int]: + return (self.x, self.y, self.w, self.h) + + @property + def page(self) -> str: + """Page filename alias (same as page_filename).""" + return self.page_filename + + def bounds_with_rotate(self) -> List[int]: + return [self.x, self.y, self.w, self.h, self.rotate] + + def to_meta_dict(self) -> Dict[str, object]: + """Metadata dict for repack / rebuild helpers.""" + return { + "atlas_name": self.atlas_name or self.name, + "index": self.index, + "split": self.split, + "pad": self.pad, + "extra_pairs": self.extra_pairs, + } + + +@dataclass +class Page: + """One texture page referenced by the atlas.""" + + filename: str + size: Tuple[int, int] = (0, 0) + format: str = "RGBA8888" + filter: Tuple[str, str] = ("Nearest", "Nearest") + repeat: str = "none" + pma: bool = False + scale_x: float = 1.0 + scale_y: float = 1.0 + extra_pairs: List[Tuple[str, List[str]]] = field(default_factory=list) + regions: List[Region] = field(default_factory=list) + + +@dataclass +class AtlasDocument: + pages: List[Page] = field(default_factory=list) + + @classmethod + def parse(cls, text: str) -> AtlasDocument: + lines = [line.strip() for line in text.splitlines()] + iterator = iter(lines) + + pages: List[Page] = [] + page_map: Dict[str, Page] = {} + region_name_counts: Dict[str, int] = {} + + current_page: Optional[Page] = None + current_region: Optional[Region] = None + + def unique_region_key(atlas_name: str) -> str: + nxt = region_name_counts.get(atlas_name, 0) + 1 + region_name_counts[atlas_name] = nxt + return atlas_name if nxt == 1 else f"{atlas_name}#{nxt}" + + while True: + try: + line = next(iterator) + except StopIteration: + break + + if not line: + continue + + if line.endswith(".png"): + current_page = Page(filename=line) + pages.append(current_page) + page_map[line] = current_page + current_region = None + continue + + if ":" in line: + key, value_str = line.split(":", 1) + key = key.strip().lower() + values = [v.strip() for v in value_str.split(",")] + + if current_region is not None: + if key == "bounds" and len(values) >= 4: + current_region.x = int(values[0]) + current_region.y = int(values[1]) + current_region.w = int(values[2]) + current_region.h = int(values[3]) + elif key == "xy": + current_region.x = int(values[0]) + current_region.y = int(values[1]) + elif key == "size" and current_region.w == 0: + current_region.w = int(values[0]) + current_region.h = int(values[1]) + elif key == "rotate": + val = values[0].lower() + if val == "true": + current_region.rotate = 90 + elif val == "false": + current_region.rotate = 0 + else: + try: + current_region.rotate = int(val) + except ValueError: + current_region.rotate = 0 + elif key == "offsets" and len(values) >= 4: + current_region.offsets = tuple(map(int, values[:4])) # type: ignore[assignment] + elif key == "index": + current_region.index = int(values[0]) + elif key == "split" and len(values) >= 4: + current_region.split = [int(v) for v in values] + elif key == "pad" and len(values) >= 4: + current_region.pad = [int(v) for v in values] + else: + current_region.extra_pairs.append((key, list(values))) + elif current_page is not None: + if key == "size": + current_page.size = (int(values[0]), int(values[1])) + elif key == "format": + current_page.format = values[0] + elif key == "filter" and len(values) >= 2: + current_page.filter = (values[0], values[1]) + elif key == "repeat": + current_page.repeat = values[0] + elif key == "pma": + current_page.pma = str(values[0]).strip().lower() == "true" + elif key not in _PAGE_KNOWN_KEYS: + current_page.extra_pairs.append((key, list(values))) + continue + + if current_page is None: + continue + + region_key = unique_region_key(line) + current_region = Region( + name=region_key, + atlas_name=line, + page_filename=current_page.filename, + ) + current_page.regions.append(current_region) + + return cls(pages=pages) + + def page_filenames(self) -> List[str]: + return [p.filename for p in self.pages] + + def region_keys(self) -> List[str]: + keys: List[str] = [] + for page in self.pages: + for region in page.regions: + keys.append(region.name) + return keys + + def regions_by_key(self) -> Dict[str, Region]: + out: Dict[str, Region] = {} + for page in self.pages: + for region in page.regions: + out[region.name] = region + return out + + def first_page_info(self) -> Dict[str, object]: + if not self.pages: + return {} + p = self.pages[0] + return { + "page": p.filename, + "size": f"{p.size[0]},{p.size[1]}", + "format": p.format, + "filter": f"{p.filter[0]}, {p.filter[1]}", + "repeat": p.repeat, + "pma": bool(p.pma), + } + + def with_updates( + self, + updated_regions: UpdatedRegionData, + page_size: Optional[Tuple[int, int]] = None, + ) -> AtlasDocument: + """Return a copy with region bounds/offsets/rotate and optional page size applied.""" + new_pages: List[Page] = [] + for page in self.pages: + new_size = page_size if page_size is not None else page.size + new_regions: List[Region] = [] + for region in page.regions: + r = region + if region.name in updated_regions: + bounds, offsets, rotate_val = updated_regions[region.name] + r = replace( + region, + x=bounds[0], + y=bounds[1], + w=bounds[2], + h=bounds[3], + offsets=offsets, + rotate=rotate_val, + ) + new_regions.append(r) + new_pages.append( + replace(page, size=new_size, regions=new_regions) + ) + return AtlasDocument(pages=new_pages) + + @classmethod + def from_rebuild_args( + cls, + page_info: Dict[str, object], + new_size: Tuple[int, int], + region_names: List[str], + region_data: Dict[str, tuple], + ) -> AtlasDocument: + page = Page( + filename=str(page_info.get("page", "atlas.png")), + size=new_size, + format=str(page_info.get("format", "RGBA8888")), + filter=_parse_filter(page_info.get("filter")), + repeat=str(page_info.get("repeat", "none")), + pma=bool(page_info.get("pma")), + ) + regions: List[Region] = [] + for name in region_names: + if name not in region_data: + continue + entry = region_data[name] + bounds, offsets, rotate_val = entry[0], entry[1], entry[2] + meta: Dict[str, object] = entry[3] if len(entry) > 3 and entry[3] else {} + regions.append( + Region( + name=name, + atlas_name=str(meta.get("atlas_name") or meta.get("name") or name), + page_filename=page.filename, + x=bounds[0], + y=bounds[1], + w=bounds[2], + h=bounds[3], + offsets=offsets, + rotate=rotate_val, + index=int(meta["index"]) if isinstance(meta.get("index"), int) else -1, + split=list(meta["split"]) if isinstance(meta.get("split"), (list, tuple)) else None, + pad=list(meta["pad"]) if isinstance(meta.get("pad"), (list, tuple)) else None, + extra_pairs=[ + (str(p[0]), [str(v) for v in p[1]]) + for p in meta.get("extra_pairs", []) + if p and p[0] + ] + if isinstance(meta.get("extra_pairs"), (list, tuple)) + else [], + ) + ) + page.regions = regions + return cls(pages=[page]) + + def serialize(self) -> str: + lines: List[str] = [] + for page_idx, page in enumerate(self.pages): + if page_idx > 0: + lines.append("") + lines.append(page.filename) + lines.append(f"size: {page.size[0]},{page.size[1]}") + if not _is_default_page_format(page.format): + lines.append(f"format: {page.format}") + if not _is_default_page_filter(page.filter): + lines.append(f"filter: {page.filter[0]}, {page.filter[1]}") + if not _is_default_page_repeat(page.repeat): + lines.append(f"repeat: {page.repeat}") + if page.pma: + lines.append("pma: true") + for key, values in page.extra_pairs: + lines.append(f"{key}: " + ", ".join(str(v) for v in values)) + + for region in page.regions: + lines.extend(_serialize_region(region)) + + return "\n".join(lines) + + +def _parse_filter(value: object) -> Tuple[str, str]: + if isinstance(value, tuple) and len(value) >= 2: + return str(value[0]), str(value[1]) + if isinstance(value, str): + parts = [p.strip() for p in value.split(",")] + if len(parts) >= 2: + return parts[0], parts[1] + return "Nearest", "Nearest" + + +def _format_rotate(rotate_val: int) -> Optional[str]: + if rotate_val == 90: + return "true" + if rotate_val == 180: + return "180" + if rotate_val == 270: + return "270" + return None + + +def _is_default_offsets( + offsets: Optional[Tuple[int, int, int, int]], + bounds: Tuple[int, int, int, int], +) -> bool: + if not offsets: + return True + return ( + offsets[0] == 0 + and offsets[1] == 0 + and offsets[2] == bounds[2] + and offsets[3] == bounds[3] + ) + + +def _is_default_page_format(fmt: object) -> bool: + return str(fmt or "").upper() == "RGBA8888" + + +def _is_default_page_filter(flt: object) -> bool: + if isinstance(flt, tuple): + return f"{flt[0]},{flt[1]}".replace(" ", "").lower() == "nearest,nearest" + return "".join(str(flt or "").split()).lower() == "nearest,nearest" + + +def _is_default_page_repeat(repeat: object) -> bool: + return str(repeat or "").lower() == "none" + + +def _serialize_region(region: Region) -> List[str]: + lines: List[str] = [region.atlas_name] + if region.index != -1: + lines.append(f" index: {region.index}") + rotate_str = _format_rotate(region.rotate) + if rotate_str: + lines.append(f" rotate: {rotate_str}") + lines.append( + f" bounds: {region.x}, {region.y}, {region.w}, {region.h}" + ) + bounds = (region.x, region.y, region.w, region.h) + if region.offsets and not _is_default_offsets(region.offsets, bounds): + o = region.offsets + lines.append(f" offsets: {o[0]}, {o[1]}, {o[2]}, {o[3]}") + if region.split and len(region.split) >= 4: + lines.append(" split: " + ", ".join(str(v) for v in region.split)) + if region.pad and len(region.pad) >= 4: + lines.append(" pad: " + ", ".join(str(v) for v in region.pad)) + for key, values in region.extra_pairs: + lines.append(f" {key}: " + ", ".join(str(v) for v in values)) + return lines diff --git a/atlas_toolkit/core/overlay.py b/atlas_toolkit/core/overlay.py new file mode 100644 index 0000000..f21a5da --- /dev/null +++ b/atlas_toolkit/core/overlay.py @@ -0,0 +1,40 @@ +"""Pre-computed overlay rectangles for modify-mode canvas rendering.""" + +from __future__ import annotations + +from typing import Dict, List, Tuple + +from atlas_toolkit.core.document import Region + + +def overlay_rect(region: Region) -> Tuple[int, int, int, int]: + """Return (x, y, w, h) for canvas overlay using stored packed dimensions. + + Spine stores original (unrotated) bounds in the atlas file; the page image + stores pixels with rotation applied. The overlay must match the stored + packed footprint on the canvas (swap w/h when rotate is 90° or 270°). + """ + x, y, w, h = region.bounds + if region.rotate in (90, 270): + return (x, y, h, w) + return (x, y, w, h) + + +def overlay_rects_for_regions(regions: Dict[str, Region]) -> Dict[str, List[int]]: + return {name: list(overlay_rect(region)) for name, region in regions.items()} + + +def overlay_rects_from_bounds( + bounds_by_name: Dict[str, List[int]], +) -> Dict[str, List[int]]: + """Build overlay rects from ``[x, y, w, h, rotate]`` payloads.""" + out: Dict[str, List[int]] = {} + for name, values in bounds_by_name.items(): + if len(values) < 5: + continue + x, y, w, h, rotate = values[0], values[1], values[2], values[3], values[4] + if rotate in (90, 270): + out[name] = [x, y, h, w] + else: + out[name] = [x, y, w, h] + return out diff --git a/atlas_toolkit/core/region_ops.py b/atlas_toolkit/core/region_ops.py new file mode 100644 index 0000000..295b05f --- /dev/null +++ b/atlas_toolkit/core/region_ops.py @@ -0,0 +1,74 @@ +"""Crop and extract operations on atlas Region instances.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from PIL import Image + +from atlas_toolkit.core.document import Page, Region + +if TYPE_CHECKING: + pass + + +def crop_and_rotate( + image: Image.Image, x: int, y: int, w: int, h: int, rotate: int +) -> Image.Image: + """Crop a region from *image* and undo atlas stored rotation.""" + crop_w = h if rotate in (90, 270) else w + crop_h = w if rotate in (90, 270) else h + + sprite = image.crop((x, y, x + crop_w, y + crop_h)) + + if rotate == 90: + sprite = sprite.transpose(Image.Transpose.ROTATE_270) + elif rotate == 270: + sprite = sprite.transpose(Image.Transpose.ROTATE_90) + elif rotate == 180: + sprite = sprite.transpose(Image.Transpose.ROTATE_180) + + return sprite + + +def extract_raw_sprite(image: Image.Image, region: Region) -> Image.Image: + """Crop unrotated sprite pixels — no offset padding.""" + x, y, w, h = region.bounds + return crop_and_rotate(image, x, y, w, h, region.rotate) + + +def extract_region_from_page( + page_image: Image.Image, + region: Region, + page: Optional[Page] = None, +) -> Image.Image: + """Extract a region with page scale factors and offset restoration applied.""" + x, y, raw_w, raw_h = region.x, region.y, region.w, region.h + rot = region.rotate + + if page and (page.scale_x != 1.0 or page.scale_y != 1.0): + sx, sy = page.scale_x, page.scale_y + x = round(x * sx) + y = round(y * sy) + raw_w = round(raw_w * sx) + raw_h = round(raw_h * sy) + + sprite = crop_and_rotate(page_image, x, y, raw_w, raw_h, rot) + current_w, current_h = sprite.size + + if not region.offsets: + return sprite + + off_x, off_y, orig_w, orig_h = region.offsets + if page and (page.scale_x != 1.0 or page.scale_y != 1.0): + sx, sy = page.scale_x, page.scale_y + off_x = round(off_x * sx) + off_y = round(off_y * sy) + orig_w = round(orig_w * sx) + orig_h = round(orig_h * sy) + + canvas = Image.new("RGBA", (orig_w, orig_h), (0, 0, 0, 0)) + paste_x = off_x + paste_y = orig_h - off_y - current_h + canvas.paste(sprite, (paste_x, paste_y)) + return canvas diff --git a/atlas_toolkit/paths.py b/atlas_toolkit/paths.py new file mode 100644 index 0000000..28a83b8 --- /dev/null +++ b/atlas_toolkit/paths.py @@ -0,0 +1,17 @@ +"""Filesystem paths for bundled resources (ui/, etc.).""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def app_root() -> Path: + """Project / install directory containing ``ui/``.""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def resource_path(relative: str) -> Path: + return app_root() / relative diff --git a/atlas_toolkit/update/__init__.py b/atlas_toolkit/update/__init__.py new file mode 100644 index 0000000..67b31b4 --- /dev/null +++ b/atlas_toolkit/update/__init__.py @@ -0,0 +1,6 @@ +"""GitHub release check and silent self-update.""" + +from atlas_toolkit.update.controller import UpdateController +from atlas_toolkit.update.updater import get_current_version + +__all__ = ["UpdateController", "get_current_version"] diff --git a/atlas_toolkit/update/controller.py b/atlas_toolkit/update/controller.py new file mode 100644 index 0000000..59f79f8 --- /dev/null +++ b/atlas_toolkit/update/controller.py @@ -0,0 +1,406 @@ +"""Self-update download and silent install orchestration.""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Optional + +from atlas_toolkit.app.config import get_config_dir +from atlas_toolkit.update.updater import ( + check_for_updates, + download_update_asset, + find_windows_installer_asset, + get_latest_release_info, + is_running_as_exe, +) + +log = logging.getLogger(__name__) + +_INNO_SILENT_FLAGS = ( + "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS" +) + + +def get_update_dir() -> Path: + d = get_config_dir() / "update" + d.mkdir(parents=True, exist_ok=True) + return d + + +def get_nuitka_onefile_parent_exe_path() -> Optional[Path]: + if os.name != "nt": + return None + + raw_pid = os.environ.get("NUITKA_ONEFILE_PARENT", "").strip() + if not raw_pid: + return None + + try: + pid = int(raw_pid) + except Exception: + return None + + if pid <= 0: + return None + + try: + import ctypes + from ctypes import wintypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + kernel32 = ctypes.windll.kernel32 + + process_handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not process_handle: + return None + + try: + size = wintypes.DWORD(32768) + buf = ctypes.create_unicode_buffer(size.value) + ok = kernel32.QueryFullProcessImageNameW( + process_handle, 0, buf, ctypes.byref(size), + ) + if not ok: + return None + p = Path(buf.value) + if p.exists() and p.is_file(): + return p + return None + finally: + kernel32.CloseHandle(process_handle) + except Exception: + return None + + +def get_running_executable_path() -> Path: + if sys.argv and sys.argv[0]: + try: + argv0_path = Path(os.path.abspath(sys.argv[0])) + if argv0_path.exists() and argv0_path.is_file(): + return argv0_path.resolve() + except Exception: + pass + + candidates: list[Path] = [] + onefile_parent = get_nuitka_onefile_parent_exe_path() + if onefile_parent is not None: + candidates.append(onefile_parent) + + for raw in ([sys.argv[0]] if sys.argv else []) + [sys.executable]: + if not raw: + continue + try: + p = Path(raw).expanduser() + except Exception: + continue + if not p.is_absolute(): + p = Path.cwd() / p + candidates.append(p) + + for candidate in candidates: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate + if resolved.exists() and resolved.is_file(): + return resolved + + if candidates: + try: + return candidates[0].resolve() + except Exception: + return candidates[0] + + return Path(sys.executable).resolve() + + +def install_dir() -> Path: + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return (base / "AtlasToolkit").resolve() + + +def is_installed_build() -> bool: + if not is_running_as_exe(): + return False + try: + exe = get_running_executable_path().resolve() + target = install_dir() + return exe.parent == target or target in exe.parents + except Exception: + return False + + +def build_update_script( + installer_path: Path, + target_exe: Path, + pid: int, + relaunch_args: list[str], + release_url: str, + inno_log_path: Path, +) -> str: + inst = str(installer_path) + exe = str(target_exe) + log_path = str(inno_log_path) + relaunch_suffix = " ".join(f'"{a}"' for a in relaunch_args) + success_launch = f'start "" "{exe}" {relaunch_suffix}'.rstrip() + + fail_message = "Update failed: the installer reported an error." + fail_args = [ + "--update-install-failed", + "--update-failed-message", f'"{fail_message}"', + "--update-failed-log", f'"{log_path}"', + ] + if release_url: + fail_args += ["--update-release-url", f'"{release_url}"'] + fail_launch = f'start "" "{exe}" ' + " ".join(fail_args) + + lines = [ + "@echo off", + "setlocal", + ":waitloop", + f'tasklist /FI "PID eq {pid}" 2>nul | find "{pid}" >nul', + "if not errorlevel 1 (", + " ping -n 2 127.0.0.1 >nul", + " goto waitloop", + ")", + f'"{inst}" {_INNO_SILENT_FLAGS} /LOG="{log_path}"', + "if errorlevel 1 goto failed", + success_launch, + "goto cleanup", + ":failed", + fail_launch, + ":cleanup", + f'del /f /q "{inst}" >nul 2>nul', + 'del /f /q "%~f0" >nul 2>nul', + "endlocal", + ] + return "\r\n".join(lines) + "\r\n" + + +class UpdateController: + """Manages update check, download, and install relaunch.""" + + def __init__(self, pending_failure: Optional[dict[str, str]] = None) -> None: + self.pending_failure = pending_failure + self._installer_path: Optional[Path] = None + self._version: Optional[str] = None + self._release_url: Optional[str] = None + self._ready = False + self._lock = threading.Lock() + self._progress: dict[str, Any] = { + "status": "idle", + "downloaded_bytes": 0, + "total_bytes": None, + "percent": 0, + "error": None, + } + + def get_progress(self) -> dict[str, Any]: + with self._lock: + return dict(self._progress) + + def _set_progress( + self, + *, + status: str, + downloaded_bytes: int, + total_bytes: Optional[int], + percent: int, + error: Optional[str] = None, + ) -> None: + with self._lock: + self._progress = { + "status": status, + "downloaded_bytes": downloaded_bytes, + "total_bytes": total_bytes, + "percent": percent, + "error": error, + } + + def check_for_notification(self) -> Optional[dict[str, Any]]: + """Return update notification payload for JS, or None if up to date.""" + info = check_for_updates() + if not info: + return None + + if is_installed_build(): + action = "download" + source_tree_url = info.source_tree_url + elif is_running_as_exe(): + action = "open_source_tag" + source_tree_url = info.release_url + else: + action = "open_source_tag" + source_tree_url = info.source_tree_url + + return { + "latestVersion": info.latest_version, + "releaseName": info.release_name, + "releaseUrl": info.release_url, + "tagName": info.tag_name, + "sourceTreeUrl": source_tree_url, + "action": action, + } + + def download(self) -> dict[str, Any]: + if not is_running_as_exe(): + return { + "ok": False, + "error": "Dev mode does not support self-update install flow.", + } + if not is_installed_build(): + return { + "ok": False, + "error": "Portable build does not support silent self-update. Use the releases page.", + } + + self._installer_path = None + self._version = None + self._release_url = None + self._ready = False + self._set_progress( + status="downloading", + downloaded_bytes=0, + total_bytes=None, + percent=0, + ) + + try: + latest = get_latest_release_info() + asset = find_windows_installer_asset(latest.assets) + + update_dir = get_update_dir() + safe_tag = "".join( + c if c.isalnum() or c in "._-" else "_" + for c in (latest.tag_name or latest.latest_version or "latest") + ) + target_installer_path = update_dir / f"{safe_tag}-{asset.name}" + + def _progress(downloaded: int, total: Optional[int]) -> None: + percent = int((downloaded * 100) / total) if total and total > 0 else 0 + self._set_progress( + status="downloading", + downloaded_bytes=downloaded, + total_bytes=total, + percent=max(0, min(100, percent)), + ) + + download_update_asset( + download_url=asset.browser_download_url, + target_path=target_installer_path, + progress_cb=_progress, + ) + + target_exe = get_running_executable_path() + metadata = { + "installer_path": str(target_installer_path), + "target_exe_path": str(target_exe), + "relaunch_args": sys.argv[1:], + "version": latest.latest_version, + "release_url": latest.release_url, + } + (update_dir / "pending_update.json").write_text( + json.dumps(metadata, ensure_ascii=True, indent=2), + encoding="utf-8", + ) + + self._installer_path = target_installer_path + self._version = latest.latest_version + self._release_url = latest.release_url + self._ready = True + size = target_installer_path.stat().st_size + self._set_progress( + status="ready", + downloaded_bytes=size, + total_bytes=size, + percent=100, + ) + + return { + "ok": True, + "version": latest.latest_version, + "downloaded_path": str(target_installer_path), + } + except Exception as e: + msg = str(e) or "Unknown update download error" + self._set_progress( + status="error", + downloaded_bytes=0, + total_bytes=None, + percent=0, + error=msg, + ) + return {"ok": False, "error": msg} + + def restart_and_install( + self, + on_success: Callable[[], None], + ) -> dict[str, Any]: + if not is_running_as_exe(): + return { + "ok": False, + "error": "Dev mode does not support restart-and-install self-update.", + } + + if not self._ready or not self._installer_path: + return { + "ok": False, + "error": "No downloaded update found. Please download update first.", + } + + installer_path = self._installer_path + if not installer_path.exists() or installer_path.stat().st_size <= 0: + return { + "ok": False, + "error": "Downloaded installer is missing or invalid.", + } + + target_exe = get_running_executable_path() + if not target_exe.exists() or not target_exe.is_file(): + return { + "ok": False, + "error": f"Cannot locate executable for relaunch: {target_exe}", + } + + update_dir = get_update_dir() + timestamp = time.strftime("%Y%m%d_%H%M%S") + inno_log_path = update_dir / f"inno_install_{timestamp}.log" + script_text = build_update_script( + installer_path=installer_path, + target_exe=target_exe, + pid=os.getpid(), + relaunch_args=list(sys.argv[1:]), + release_url=self._release_url or "", + inno_log_path=inno_log_path, + ) + script_path = update_dir / f"install_update_{timestamp}_{os.getpid()}.cmd" + + try: + script_path.write_text(script_text, encoding="utf-8") + cmd_exe = os.environ.get("COMSPEC") or "cmd" + popen_kwargs: dict[str, Any] = { + "cwd": str(update_dir), + "close_fds": True, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = ( + subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + ) + else: + popen_kwargs["start_new_session"] = True + + subprocess.Popen([cmd_exe, "/d", "/c", str(script_path)], **popen_kwargs) + on_success() + return {"ok": True} + except Exception as e: + return { + "ok": False, + "error": f"Failed to launch update installer: {e}", + } diff --git a/updater.py b/atlas_toolkit/update/updater.py similarity index 91% rename from updater.py rename to atlas_toolkit/update/updater.py index 6b63787..a2a80fc 100644 --- a/updater.py +++ b/atlas_toolkit/update/updater.py @@ -49,29 +49,28 @@ def is_running_as_exe() -> bool: def get_current_version() -> str: """Read version from VERSION file when running as exe, pyproject.toml when in dev.""" - from pathlib import Path + from atlas_toolkit.paths import app_root + root = app_root() if is_running_as_exe(): - # Nuitka embeds VERSION next to the exe via include-data-files - version_file = Path(__file__).parent / "VERSION" + version_file = root / "VERSION" try: if version_file.exists(): return version_file.read_text(encoding="utf-8-sig").strip() except Exception: pass return "0.0.0" - else: - # Dev mode — read from pyproject.toml - try: - toml_path = Path(__file__).parent / "pyproject.toml" - if toml_path.exists(): - for line in toml_path.read_text(encoding="utf-8-sig").splitlines(): - line = line.strip() - if line.startswith("version"): - return line.split("=", 1)[1].strip().strip('"\'') - except Exception: - pass - return "0.0.0" + + try: + toml_path = root / "pyproject.toml" + if toml_path.exists(): + for line in toml_path.read_text(encoding="utf-8-sig").splitlines(): + line = line.strip() + if line.startswith("version"): + return line.split("=", 1)[1].strip().strip("\"'") + except Exception: + pass + return "0.0.0" def _version_tuple(v: str) -> tuple[int, ...]: diff --git a/main.py b/main.py index cc55af4..37a0d94 100644 --- a/main.py +++ b/main.py @@ -1,1347 +1,6 @@ -from __future__ import annotations -import sys -import os -import base64 -import json -import shutil -import subprocess -import webbrowser -import webview -import time -import threading -from io import BytesIO -from pathlib import Path -from typing import TYPE_CHECKING, Any, List, Optional -from urllib.parse import urlparse -from atlas_converter import auto_convert_atlas -from atlas_extracter import AtlasProcessor -from atlas_modifier import AtlasModifier -from updater import ( - check_for_updates, - download_update_asset, - find_windows_installer_asset, - get_current_version, - get_latest_release_info, - is_running_as_exe, -) +"""Run Atlas Toolkit desktop app.""" +from atlas_toolkit.app.launch import run -def get_resource_path(path: str) -> Path: - """Get path to a resource file embedded in the executable. - - In Nuitka standalone mode, ``__file__`` resolves to the install directory - where embedded resources sit next to the executable. - """ - return Path(__file__).parent / path - - -def _consume_launch_flags(argv: list[str]) -> tuple[list[str], Optional[dict[str, str]]]: - """Consume internal launch flags and return user args + optional failure payload.""" - clean_args: list[str] = [] - failed = False - failed_log = "" - failed_release_url = "" - failed_message = "" - - i = 0 - while i < len(argv): - arg = argv[i] - if arg == "--update-install-failed": - failed = True - i += 1 - continue - if arg == "--update-failed-log" and i + 1 < len(argv): - failed_log = argv[i + 1] - i += 2 - continue - if arg == "--update-release-url" and i + 1 < len(argv): - failed_release_url = argv[i + 1] - i += 2 - continue - if arg == "--update-failed-message" and i + 1 < len(argv): - failed_message = argv[i + 1] - i += 2 - continue - - clean_args.append(arg) - i += 1 - - payload: Optional[dict[str, str]] = None - if failed: - payload = { - "message": failed_message or "Update installation failed. The app was relaunched.", - "logPath": failed_log, - "releaseUrl": failed_release_url, - } - - return clean_args, payload - - -_clean_argv, _pending_update_failure = _consume_launch_flags(sys.argv[1:]) -sys.argv = [sys.argv[0], *_clean_argv] - -if TYPE_CHECKING: - from PIL.Image import Image - - - -# Reconfigure stdout/stderr for immediate output (Nuitka console mode compatibility) -if sys.stdout: - sys.stdout.reconfigure(encoding='utf-8') # type: ignore -if sys.stderr: - sys.stderr.reconfigure(encoding='utf-8') # type: ignore - -# Logging setup -import logging -logging.basicConfig( - level=logging.DEBUG, - format='%(levelname)s: %(message)s', - stream=sys.stdout, # Ensure logs go to stdout where we can see them -) -log = logging.getLogger(__name__) - - -IMAGE_EXTENSIONS = {'.png'} -PAGE_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} -PAGE_HEADER_KEYS = {'size', 'format', 'filter', 'repeat', 'pma'} - - -def _extract_required_pages(atlas_text: str) -> list[str]: - """Extract atlas page image names from page headers only.""" - lines = atlas_text.splitlines() - pages: list[str] = [] - - for i, raw in enumerate(lines): - candidate = raw.strip() - if not candidate or ':' in candidate: - continue - - suffix = Path(candidate).suffix.lower() - if suffix not in PAGE_IMAGE_EXTENSIONS: - continue - - next_key: Optional[str] = None - for j in range(i + 1, len(lines)): - nxt = lines[j].strip() - if not nxt: - continue - if ':' in nxt: - next_key = nxt.split(':', 1)[0].strip().lower() - break - - if next_key in PAGE_HEADER_KEYS and candidate not in pages: - pages.append(candidate) - - return pages - -def _get_config_dir() -> Path: - """Return a persistent config directory for the app.""" - if sys.platform == "win32": - base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - else: - base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - d = base / "AtlasToolkit" - d.mkdir(parents=True, exist_ok=True) - return d - -CONFIG_PATH = _get_config_dir() / "config.json" - - -def _get_update_dir() -> Path: - """Return persistent directory for downloaded update artifacts.""" - d = _get_config_dir() / "update" - d.mkdir(parents=True, exist_ok=True) - return d - - -def _get_nuitka_onefile_parent_exe_path() -> Optional[Path]: - """Return outer onefile launcher path when running in Nuitka child process.""" - if os.name != "nt": - return None - - raw_pid = os.environ.get("NUITKA_ONEFILE_PARENT", "").strip() - if not raw_pid: - return None - - try: - pid = int(raw_pid) - except Exception: - return None - - if pid <= 0: - return None - - try: - import ctypes - from ctypes import wintypes - - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - kernel32 = ctypes.windll.kernel32 - - process_handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) - if not process_handle: - return None - - try: - size = wintypes.DWORD(32768) - buf = ctypes.create_unicode_buffer(size.value) - ok = kernel32.QueryFullProcessImageNameW( - process_handle, - 0, - buf, - ctypes.byref(size), - ) - if not ok: - return None - - p = Path(buf.value) - if p.exists() and p.is_file(): - return p - return None - finally: - kernel32.CloseHandle(process_handle) - except Exception: - return None - - -def _get_running_executable_path() -> Path: - """Return the best on-disk executable path for relaunch/update flow.""" - # Nuitka onefile should expose the outer launcher as sys.argv[0]. - if sys.argv and sys.argv[0]: - try: - argv0_path = Path(os.path.abspath(sys.argv[0])) - if argv0_path.exists() and argv0_path.is_file(): - return argv0_path.resolve() - except Exception: - pass - - candidates: list[Path] = [] - - onefile_parent = _get_nuitka_onefile_parent_exe_path() - if onefile_parent is not None: - candidates.append(onefile_parent) - - for raw in ([sys.argv[0]] if sys.argv else []) + [sys.executable]: - if not raw: - continue - try: - p = Path(raw).expanduser() - except Exception: - continue - if not p.is_absolute(): - p = Path.cwd() / p - candidates.append(p) - - for candidate in candidates: - try: - resolved = candidate.resolve() - except Exception: - resolved = candidate - if resolved.exists() and resolved.is_file(): - return resolved - - if candidates: - try: - return candidates[0].resolve() - except Exception: - return candidates[0] - - return Path(sys.executable).resolve() - - -def _install_dir() -> Path: - """The per-user install directory the Inno Setup installer targets - (%LOCALAPPDATA%\\AtlasToolkit). The installer upgrades cleanly by running the - previous version's uninstaller first, which removes only installer-tracked - files and leaves the app's config / update cache (in the same dir) intact.""" - base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - return (base / "AtlasToolkit").resolve() - - -def _is_installed_build() -> bool: - """True when running from the installed location (vs a portable copy). - - Only installed builds get the silent installer self-update; portable copies - are pointed at the releases page instead (a silent install would replace a - different folder and orphan the portable exe). - """ - if not is_running_as_exe(): - return False - try: - exe = _get_running_executable_path().resolve() - install_dir = _install_dir() - return exe.parent == install_dir or install_dir in exe.parents - except Exception: - return False - - -# Silent installer flags: no UI, no msgboxes, don't reboot, close the running -# app via Restart Manager, and don't let Inno restart it (the script relaunches). -_INNO_SILENT_FLAGS = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /NORESTARTAPPLICATIONS" - - -def _build_update_script( - installer_path: Path, - target_exe: Path, - pid: int, - relaunch_args: list[str], - release_url: str, - inno_log_path: Path, -) -> str: - """Build the detached .cmd that waits for the app to exit, runs the Inno - installer silently, then relaunches the freshly-installed app (or relaunches - with a failure notice). Returned as text so it can be unit-tested off-Windows. - """ - inst = str(installer_path) - exe = str(target_exe) - log = str(inno_log_path) - relaunch_suffix = " ".join(f'"{a}"' for a in relaunch_args) - success_launch = f'start "" "{exe}" {relaunch_suffix}'.rstrip() - - fail_message = "Update failed: the installer reported an error." - fail_args = [ - "--update-install-failed", - "--update-failed-message", f'"{fail_message}"', - "--update-failed-log", f'"{log}"', - ] - if release_url: - fail_args += ["--update-release-url", f'"{release_url}"'] - fail_launch = f'start "" "{exe}" ' + " ".join(fail_args) - - lines = [ - "@echo off", - "setlocal", - ":waitloop", - f'tasklist /FI "PID eq {pid}" 2>nul | find "{pid}" >nul', - "if not errorlevel 1 (", - " ping -n 2 127.0.0.1 >nul", - " goto waitloop", - ")", - f'"{inst}" {_INNO_SILENT_FLAGS} /LOG="{log}"', - "if errorlevel 1 goto failed", - success_launch, - "goto cleanup", - ":failed", - fail_launch, - ":cleanup", - f'del /f /q "{inst}" >nul 2>nul', - 'del /f /q "%~f0" >nul 2>nul', - "endlocal", - ] - return "\r\n".join(lines) + "\r\n" - - -class Api: - def __init__(self) -> None: - self._atlas_path: Optional[Path] = None - self._processor: Optional[AtlasProcessor] = None - self._window: Optional[webview.Window] = None - # Modify mode state - self._modifier: Optional[AtlasModifier] = None - self._merged_image: Optional[Image] = None - self._merged_atlas_text: Optional[str] = None - # Multi-page merge output (set instead of _merged_image for >1 page atlases) - self._merged_pages: Optional[List[Image]] = None - # Pre-repack state (merge output before repack was applied) - self._pre_repack_image: Optional[Image] = None - self._pre_repack_text: Optional[str] = None - self._modified_regions: set[str] = set() - # Persistent config - self._config: dict[str, Any] = self._load_config() - # Update state - self._update_installer_path: Optional[Path] = None - self._update_version: Optional[str] = None - self._update_release_url: Optional[str] = None - self._update_ready: bool = False - self._update_progress_lock = threading.Lock() - self._update_progress: dict[str, Any] = { - "status": "idle", - "downloaded_bytes": 0, - "total_bytes": None, - "percent": 0, - "error": None, - } - self._pending_update_failure: Optional[dict[str, str]] = _pending_update_failure - - def set_window(self, window: webview.Window) -> None: - self._window = window - - # --- Config persistence --- - @staticmethod - def _load_config() -> dict[str, Any]: - try: - if CONFIG_PATH.exists(): - return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) # type: ignore[no-any-return] - except Exception: - pass - return {} - - def _save_config(self) -> None: - try: - CONFIG_PATH.write_text(json.dumps(self._config), encoding="utf-8") - except Exception as e: - log.warning("Failed to save config: %s", e) - - def get_pref(self, key: str, default: Any = None) -> Any: - """Get a persistent preference value.""" - return self._config.get(key, default) - - def set_pref(self, key: str, value: Any) -> None: - """Set and persist a preference value.""" - self._config[key] = value - self._save_config() - - def startup_check(self) -> bool: - """Called by JS when pywebview is ready""" - time.sleep(0.5) - - threading.Thread(target=self._run_update_check, daemon=True).start() - - if self._pending_update_failure and self._window: - payload_json = json.dumps(self._pending_update_failure) - self._window.evaluate_js( - f"window.showUpdateInstallFailed({payload_json});" - ) - - if len(sys.argv) > 1 and sys.argv[1].endswith('.atlas'): - return self.load_atlas(sys.argv[1]) - else: - return False - - def open_update_log(self, log_path: str) -> dict[str, Any]: - """Open update failure log file in OS default app.""" - p = Path(log_path) - if not p.exists(): - return {"ok": False, "error": "Log file not found."} - - try: - if sys.platform == "win32": - os.startfile(str(p)) # type: ignore[attr-defined] - elif sys.platform == "darwin": - subprocess.Popen(["open", str(p)], close_fds=True) - else: - subprocess.Popen(["xdg-open", str(p)], close_fds=True) - return {"ok": True} - except Exception as e: - return {"ok": False, "error": f"Failed to open log file: {e}"} - - def open_url(self, url: str) -> dict[str, Any]: - """Open URL using system default browser.""" - try: - parsed = urlparse(str(url)) - if parsed.scheme not in ("http", "https"): - return {"ok": False, "error": "Invalid URL scheme."} - - webbrowser.open(parsed.geturl()) - return {"ok": True} - except Exception as e: - return {"ok": False, "error": f"Failed to open URL: {e}"} - - def choose_file(self) -> bool: - if not self._window: - return False - file_types = ('Atlas Files (*.atlas)', 'All files (*.*)') - result = self._window.create_file_dialog(webview.FileDialog.OPEN, allow_multiple=False, file_types=file_types) - if result: - return self.load_atlas(result[0]) - return False - - def load_atlas(self, path_str: str) -> bool: - log.debug("load_atlas received path: %r", path_str) - try: - self._atlas_path = Path(path_str) - atlas_dir = self._atlas_path.parent - - with open(self._atlas_path, 'r', encoding='utf-8') as f: - content = f.read() - - required_pages = _extract_required_pages(content) - image_loader = {} - - if not self._window: - return False - - for page_name in required_pages: - expected_path = atlas_dir / page_name - if expected_path.exists(): - image_loader[page_name] = expected_path - else: - self._window.evaluate_js(f"alert('Image \\\"{page_name}\\\" not found. Please locate it.')") - file_types = (f'{Path(page_name).stem} (*{Path(page_name).suffix})', 'All files (*.*)') - result = self._window.create_file_dialog( - webview.FileDialog.OPEN, - allow_multiple=False, - file_types=file_types, - directory=str(atlas_dir) - ) - if result: - image_loader[page_name] = Path(result[0]) - else: - self._window.evaluate_js("alert('Load cancelled.')") - return False - - self._processor = AtlasProcessor(auto_convert_atlas(content), image_loader) - self._window.set_title(f"Atlas Toolkit v{get_current_version()} - {self._atlas_path.name}") - - # Clear modify state when loading a new atlas - self._clear_modify_state() - - return True - - except Exception as e: - if self._window: - self._window.evaluate_js(f"alert('Error: {str(e)}')") - return False - - def _clear_modify_state(self) -> None: - """Reset all modify mode state.""" - self._modifier = None - self._merged_image = None - self._merged_atlas_text = None - self._merged_pages = None - self._pre_repack_image = None - self._pre_repack_text = None - self._modified_regions = set() - - def _mark_regions_modified(self, names: List[str]) -> None: - self._modified_regions.update(names) - - def _build_modify_response( - self, - image: Image, - atlas_text: str, - extra: Optional[dict[str, object]] = None, - ) -> dict[str, object]: - from atlas_modifier import parse_atlas - - _, _, merged_regions = parse_atlas(atlas_text) - region_bounds: dict[str, list[int]] = {} - for name, info in merged_regions.items(): - region_bounds[name] = [*info.bounds, info.rotate] - - payload: dict[str, object] = { - "image": self._image_to_base64(image), - "regions": region_bounds, - "modifiedRegions": sorted(self._modified_regions), - } - if extra: - payload.update(extra) - return payload - - @staticmethod - def _parse_atlas_page_names(atlas_text: str) -> List[str]: - names: List[str] = [] - for line in atlas_text.splitlines(): - stripped = line.strip() - if ( - stripped - and ":" not in stripped - and stripped.lower().endswith(".png") - ): - names.append(stripped) - return names - - def _extract_sprites_from_merged_pages(self) -> dict[str, Image]: - from atlas_modifier import AtlasModifier, parse_atlas - - if not self._merged_pages or not self._merged_atlas_text: - return {} - - page_names = self._parse_atlas_page_names(self._merged_atlas_text) - page_images = { - name: self._merged_pages[i] - for i, name in enumerate(page_names) - if i < len(self._merged_pages) - } - - _, _, regions = parse_atlas(self._merged_atlas_text) - sprites: dict[str, Image] = {} - for name, info in regions.items(): - page_img = page_images.get(info.page) - if page_img is not None: - sprites[name] = AtlasModifier._extract_raw_sprite(page_img, info) - return sprites - - def get_region_names(self) -> List[str]: - if not self._processor: return [] - return list(self._processor.regions.keys()) - - def get_preview(self, names: List[str]) -> Optional[str]: - if not self._processor: return None - if not names: return None - - try: - images: List[Image] = [] - max_w, max_h = 0, 0 - - valid_names = [n for n in names if n in self._processor.regions] - - for name in valid_names: - img = self._processor.extract_region(name) - if img: - images.append(img) - max_w = max(max_w, img.width) - max_h = max(max_h, img.height) - - if not images: - return None - - if len(images) == 1: - return self._image_to_base64(images[0]) - - from PIL import Image - monitor = Image.new('RGBA', (max_w, max_h), (0, 0, 0, 0)) - - for img in reversed(images): - # Pad to monitor size so alpha_composite works (requires same size) - layer = Image.new('RGBA', monitor.size, (0, 0, 0, 0)) - layer.paste(img, (0, 0)) - monitor = Image.alpha_composite(monitor, layer) - - return self._image_to_base64(monitor) - - except Exception as e: - log.error("Preview error: %s", e) - return None - - def save_preview_image(self, png_data_url: str, default_filename: str = "merged.png") -> str: - """Save a PNG data URL (same bytes as preview copy) via save dialog.""" - if not self._window: - return "Error: Window not ready." - try: - b64 = png_data_url.split(",", 1)[1] if "," in png_data_url else png_data_url - data = base64.b64decode(b64) - except Exception as e: - return f"Error: Invalid image data ({e})." - - default_dir = str(self._atlas_path.parent) if self._atlas_path else "" - result = self._window.create_file_dialog( - webview.FileDialog.SAVE, - directory=default_dir, - save_filename=default_filename, - ) - if not result: - return "Cancelled" - try: - Path(result[0]).write_bytes(data) - return f"Saved to {result[0]}" - except Exception as e: - log.error("Save preview image error: %s", e) - return f"Error: {e}" - - def _image_to_base64(self, img: Image) -> str: - """Convert a PIL Image to a base64 data URI string.""" - buffered = BytesIO() - img.save(buffered, format="PNG") - return f"data:image/png;base64,{base64.b64encode(buffered.getvalue()).decode('utf-8')}" - - def extract_files(self, region_names: Optional[List[str]]) -> str: - if not self._processor or not self._atlas_path or not self._window: - return "No atlas loaded or window not ready." - - target_regions = region_names if region_names else list(self._processor.regions.keys()) - is_single = len(target_regions) == 1 - default_dir = str(self._atlas_path.parent) - save_path: Any = None - - if is_single: - result = self._window.create_file_dialog( - webview.FileDialog.SAVE, - directory=default_dir, - save_filename=f"{target_regions[0]}.png" - ) - if result: save_path = result[0] - else: - result = self._window.create_file_dialog( - webview.FileDialog.FOLDER, - directory=default_dir - ) - if result: save_path = result[0] - - if not save_path: return "Cancelled" - - success_count = 0 - try: - for name in target_regions: - img = self._processor.extract_region(name) - if img: - if is_single: - dest = Path(save_path) - else: - safe_name = "".join(x for x in name if x.isalnum() or x in "._- ") - dest = Path(save_path) / f"{safe_name}.png" - img.save(dest) - success_count += 1 - return f"Successfully extracted {success_count} images." - except Exception as e: - return f"Error: {str(e)}" - - # ========================================== - # MODIFY MODE API - # ========================================== - - def _build_modify_view(self, *, clear_modified: bool = False) -> Optional[dict[str, object]]: - """Rebuild modify mode from the originally-loaded atlas.""" - if not self._processor or not self._atlas_path: - return None - - try: - atlas_text = self._atlas_path.read_text(encoding='utf-8') - base_image = self._processor.get_page_image() - if not base_image: - log.error("No loaded images in processor") - return None - - if clear_modified: - self._modified_regions = set() - - self._merged_image = None - self._merged_atlas_text = None - self._merged_pages = None - self._pre_repack_image = None - self._pre_repack_text = None - - self._modifier = AtlasModifier( - auto_convert_atlas(atlas_text), self._atlas_path, base_image - ) - - region_bounds: dict[str, list[int]] = {} - for name, info in self._modifier.regions.items(): - region_bounds[name] = [*info.bounds, info.rotate] - - pages = [p.filename for p in self._processor.pages] - region_pages = { - name: r.page_filename - for name, r in self._processor.regions.items() - } - - return { - "image": self._image_to_base64(base_image), - "regions": region_bounds, - "pages": pages, - "regionPages": region_pages, - "activePage": pages[0] if pages else None, - "modifiedRegions": sorted(self._modified_regions), - } - except Exception as e: - log.error("Building modify view: %s", e) - return None - - def enter_modify_mode(self) -> Optional[dict[str, object]]: - """Prepare the AtlasModifier from the current loaded atlas. - - Returns: - Dict with 'image' (base64) and 'regions' ({name: [x,y,w,h]}), or None. - """ - data = self._build_modify_view(clear_modified=False) - if data is not None: - log.debug("Entered modify mode") - return data - - def reset_modify_mode(self) -> Optional[dict[str, object]]: - """Discard all in-session modifications and restore the original atlas view.""" - data = self._build_modify_view(clear_modified=True) - if data is not None: - log.debug("Reset modify mode to original atlas") - return data - - def exit_modify_mode(self) -> None: - """Clean up modify mode state.""" - self._clear_modify_state() - log.debug("Exited modify mode") - - def select_mod_image(self, selected_names: List[str], repack: bool = False) -> Optional[dict[str, object]]: - """Open a file dialog to select a mod PNG, then process it.""" - if not self._window or not self._modifier: - return None - - file_types = ('PNG Files (*.png)', 'All files (*.*)') - default_dir = str(self._atlas_path.parent) if self._atlas_path else '' - - result = self._window.create_file_dialog( - webview.FileDialog.OPEN, - allow_multiple=False, - file_types=file_types, - directory=default_dir, - ) - - if not result: - return None - - return self.process_mod_image(result[0], selected_names, repack) - - def process_mod_image(self, path_str: str, selected_names: List[str], repack: bool = False) -> Optional[dict[str, object]]: - """Run merge (and optional repack) and return dict with base64 preview + updated region bounds.""" - if not self._modifier: - return None - - # Multi-page atlases take a dedicated path: extract every region from - # every page, swap in the mod for the selected regions, and repack all - # pages. (Mirrors the JS multi-page branch; repack flag is implied.) - if self._processor and len(self._processor.pages) > 1: - return self._process_mod_multi_page(path_str, selected_names) - - try: - mod_path = Path(path_str) - log.debug("Processing mod image: %s", mod_path) - - merged_image, merged_atlas_text = self._modifier.merge_mod_image( - mod_path, selected_names - ) - - # Store pre-repack state so toggle can revert - self._pre_repack_image = merged_image - self._pre_repack_text = merged_atlas_text - - # Repack as the final step of the merge process - if repack: - log.debug("Running repack...") - merged_image, merged_atlas_text = self._modifier.repack( - merged_image, merged_atlas_text - ) - - self._merged_image = merged_image - self._merged_atlas_text = merged_atlas_text - self._mark_regions_modified(selected_names) - # Continue subsequent merges from the pre-repack canvas so toggling - # repack off still reflects every mod, not an earlier repacked layout. - self._modifier.adopt_merge_result( - self._pre_repack_image, self._pre_repack_text - ) - - return self._build_modify_response(merged_image, merged_atlas_text) - - except Exception as e: - log.error("Processing mod image: %s", e) - if self._window: - self._window.evaluate_js(f"showToast('Error: {str(e)}', 'error')") - return None - - def _process_mod_multi_page( - self, path_str: str, selected_names: List[str] - ) -> Optional[dict[str, object]]: - """Multi-page merge: extract every region (whitespace restored), replace - the selected regions with the mod image, then repack across all pages.""" - if not self._processor: - return None - - try: - from PIL import Image - from atlas_modifier import parse_atlas, repack_multi_page - - # 1. Extract every region (from merged output if continuing a session). - if self._merged_pages and self._merged_atlas_text: - all_sprites = self._extract_sprites_from_merged_pages() - else: - all_sprites = {} - for name in self._processor.regions: - sprite = self._processor.extract_region(name) - if sprite is not None: - all_sprites[name] = sprite - - # 2. Replace the selected regions' sprites with the mod image. - mod_img = Image.open(Path(path_str)).convert("RGBA") - for name in selected_names: - if name in all_sprites: - all_sprites[name] = mod_img - - # 3. Collect per-page infos and per-region metadata. - page_infos: list[dict[str, object]] = [ - { - "page": p.filename, - "format": p.format, - "filter": f"{p.filter[0]}, {p.filter[1]}", - "repeat": p.repeat, - "pma": p.pma, - } - for p in self._processor.pages - ] - region_metas: dict[str, dict[str, object]] = { - name: { - "atlas_name": r.atlas_name or name, - "index": r.index, - "split": r.split, - "pad": r.pad, - "extra_pairs": r.extra_pairs, - } - for name, r in self._processor.regions.items() - } - - # 4. Repack across all pages. - pages, atlas_text = repack_multi_page( - all_sprites, len(self._processor.pages), page_infos, region_metas - ) - - self._merged_pages = pages - self._merged_atlas_text = atlas_text - self._merged_image = None - self._pre_repack_image = None - self._pre_repack_text = None - self._mark_regions_modified(selected_names) - - _, _, merged_regions = parse_atlas(atlas_text) - region_bounds: dict[str, list[int]] = {} - region_pages: dict[str, str] = {} - for name, info in merged_regions.items(): - region_bounds[name] = [*info.bounds, info.rotate] - region_pages[name] = info.page - - return self._build_modify_response( - pages[0] if pages else Image.new("RGBA", (1, 1)), - atlas_text, - extra={ - "regionPages": region_pages, - "pages": [str(pi["page"]) for pi in page_infos], - "pageCount": len(pages), - "previewPage": str(page_infos[0]["page"]) if page_infos else None, - }, - ) - - except Exception as e: - log.error("Processing multi-page mod image: %s", e) - if self._window: - self._window.evaluate_js(f"showToast('Error: {str(e)}', 'error')") - return None - - def get_modify_page_preview(self, index: int) -> Optional[str]: - """Return a base64 data-URI for page *index* of the current modify view. - - Serves the repacked merged pages when a multi-page merge is active, - otherwise the originally-loaded atlas page images (so the page switcher - works both before and after merging). - """ - try: - if self._merged_pages is not None: - if 0 <= index < len(self._merged_pages): - return self._image_to_base64(self._merged_pages[index]) - return None - if self._processor and 0 <= index < len(self._processor.pages): - img = self._processor.get_page_image( - self._processor.pages[index].filename - ) - if img is not None: - return self._image_to_base64(img) - return None - except Exception as e: - log.error("get_modify_page_preview: %s", e) - return None - - def save_modified(self) -> str: - """Open a folder dialog and save the merged atlas files.""" - if not self._merged_atlas_text or not self._window: - return "Error: No merged data to save." - if not self._merged_pages and not (self._modifier and self._merged_image): - return "Error: No merged data to save." - - default_dir = str(self._atlas_path.parent) if self._atlas_path else '' - - result = self._window.create_file_dialog( - webview.FileDialog.FOLDER, - directory=default_dir, - ) - - if not result: - return "Cancelled" - - try: - output_dir = Path(result[0]) - if self._merged_pages is not None: - self._save_multi_page(output_dir) - else: - if not self._modifier or self._merged_image is None: - return "Error: No merged data to save." - self._modifier.save(output_dir, self._merged_image, self._merged_atlas_text) - return f"Saved to: {output_dir}" - except Exception as e: - return f"Error: {str(e)}" - - def _save_multi_page(self, output_dir: Path) -> None: - """Write each repacked page PNG (named by its original page filename), - the updated atlas text, and copy the .skel if present.""" - if not self._processor or not self._atlas_path or self._merged_pages is None: - return - output_dir.mkdir(parents=True, exist_ok=True) - - for i, page_img in enumerate(self._merged_pages): - if i < len(self._processor.pages): - page_name = self._processor.pages[i].filename - else: - page_name = f"page{i}.png" - page_img.save(output_dir / Path(page_name).name) - - if self._merged_atlas_text is not None: - (output_dir / self._atlas_path.name).write_text( - self._merged_atlas_text, encoding="utf-8" - ) - - skel_path = self._atlas_path.with_suffix(".skel") - if skel_path.exists(): - shutil.copy(skel_path, output_dir / skel_path.name) - - def toggle_repack(self, repack: bool) -> Optional[dict[str, object]]: - """Re-apply or remove repack on the existing merge result.""" - if not self._modifier or not self._pre_repack_image or not self._pre_repack_text: - return None - - try: - if repack: - log.debug("Applying repack...") - image, text = self._modifier.repack( - self._pre_repack_image, self._pre_repack_text - ) - else: - log.debug("Reverting to pre-repack merge result") - image = self._pre_repack_image - text = self._pre_repack_text - - self._merged_image = image - self._merged_atlas_text = text - self._modifier.adopt_merge_result( - self._pre_repack_image, self._pre_repack_text - ) - - return self._build_modify_response(image, text) - except Exception as e: - log.error("toggle_repack: %s", e) - return None - - def debug_log(self, msg: str) -> None: - log.debug("JS: %s", msg) - - def on_drop(self, e: Any) -> None: - try: - files = e['dataTransfer']['files'] - if len(files) > 0: - path = files[0].get('pywebviewFullPath') - log.debug("Dropped file path: %s", path) - if not path: - return - - path_lower = path.lower() - - if path_lower.endswith('.atlas'): - # Always load atlas (switch to extract mode if in modify) - if self.load_atlas(path): - if self._window: - self._window.evaluate_js("onAtlasLoadedFromPython()") - - elif any(path_lower.endswith(ext) for ext in IMAGE_EXTENSIONS): - # Image dropped — only handle in modify mode - if self._modifier: - self._handle_image_drop(path) - else: - if self._window: - self._window.evaluate_js("showToast('Enter Modify Mode first to drop images.', 'error')") - else: - if self._window: - self._window.evaluate_js("showToast('Unsupported file type.', 'error')") - except Exception as ex: - log.error("Drop error: %s", ex) - - def _handle_image_drop(self, path: str) -> None: - """Process a dropped image in modify mode directly, avoiding JS round-trip.""" - if not self._window: - return - - # Get selected names and repack flag from JS (synchronous eval) - selected_json = self._window.evaluate_js("JSON.stringify(getSelectedNames())") - if not selected_json: - self._window.evaluate_js("showToast('Select at least one region first.', 'error')") - return - - names: list[str] = json.loads(selected_json) - if len(names) == 0: - self._window.evaluate_js("showToast('Select at least one region first.', 'error')") - return - - repack_val = self._window.evaluate_js("document.getElementById('chk-repack').checked") - repack = bool(repack_val) - - # Process directly in Python — no JS→Python→JS→Python round-trip - result = self.process_mod_image(path, names, repack) - if result: - result_json = json.dumps(result) - self._window.evaluate_js(f"window.onModImageProcessed({result_json})") - else: - self._window.evaluate_js("showToast('Failed to process mod image.', 'error')") - - def _set_update_progress( - self, - *, - status: str, - downloaded_bytes: int, - total_bytes: Optional[int], - percent: int, - error: Optional[str] = None, - ) -> None: - with self._update_progress_lock: - self._update_progress = { - "status": status, - "downloaded_bytes": downloaded_bytes, - "total_bytes": total_bytes, - "percent": percent, - "error": error, - } - - def get_update_download_progress(self) -> dict[str, Any]: - with self._update_progress_lock: - return dict(self._update_progress) - - def download_update(self) -> dict[str, Any]: - """Download the Windows installer (Setup.exe) into the update folder.""" - if not is_running_as_exe(): - return { - "ok": False, - "error": "Dev mode does not support self-update install flow.", - } - if not _is_installed_build(): - return { - "ok": False, - "error": "Portable build does not support silent self-update. Use the releases page.", - } - - self._update_installer_path = None - self._update_version = None - self._update_release_url = None - self._update_ready = False - self._set_update_progress( - status="downloading", - downloaded_bytes=0, - total_bytes=None, - percent=0, - ) - - try: - latest = get_latest_release_info() - asset = find_windows_installer_asset(latest.assets) - - update_dir = _get_update_dir() - safe_tag = "".join( - c if c.isalnum() or c in "._-" else "_" - for c in (latest.tag_name or latest.latest_version or "latest") - ) - target_installer_path = update_dir / f"{safe_tag}-{asset.name}" - - def _progress(downloaded: int, total: Optional[int]) -> None: - percent = int((downloaded * 100) / total) if total and total > 0 else 0 - self._set_update_progress( - status="downloading", - downloaded_bytes=downloaded, - total_bytes=total, - percent=max(0, min(100, percent)), - ) - - download_update_asset( - download_url=asset.browser_download_url, - target_path=target_installer_path, - progress_cb=_progress, - ) - - target_exe = _get_running_executable_path() - - metadata = { - "installer_path": str(target_installer_path), - "target_exe_path": str(target_exe), - "relaunch_args": sys.argv[1:], - "version": latest.latest_version, - "release_url": latest.release_url, - } - metadata_path = update_dir / "pending_update.json" - metadata_path.write_text( - json.dumps(metadata, ensure_ascii=True, indent=2), - encoding="utf-8", - ) - - self._update_installer_path = target_installer_path - self._update_version = latest.latest_version - self._update_release_url = latest.release_url - self._update_ready = True - size = target_installer_path.stat().st_size - self._set_update_progress( - status="ready", - downloaded_bytes=size, - total_bytes=size, - percent=100, - ) - - return { - "ok": True, - "version": latest.latest_version, - "downloaded_path": str(target_installer_path), - } - except Exception as e: - msg = str(e) or "Unknown update download error" - self._set_update_progress( - status="error", - downloaded_bytes=0, - total_bytes=None, - percent=0, - error=msg, - ) - return {"ok": False, "error": msg} - - def restart_and_install_update(self) -> dict[str, Any]: - """Spawn a detached cmd that runs the installer silently and relaunches the app.""" - if not is_running_as_exe(): - return { - "ok": False, - "error": "Dev mode does not support restart-and-install self-update.", - } - - if not self._update_ready or not self._update_installer_path: - return { - "ok": False, - "error": "No downloaded update found. Please download update first.", - } - - installer_path = self._update_installer_path - if not installer_path.exists() or installer_path.stat().st_size <= 0: - return { - "ok": False, - "error": "Downloaded installer is missing or invalid.", - } - - target_exe = _get_running_executable_path() - if not target_exe.exists() or not target_exe.is_file(): - return { - "ok": False, - "error": f"Cannot locate executable for relaunch: {target_exe}", - } - - update_dir = _get_update_dir() - timestamp = time.strftime("%Y%m%d_%H%M%S") - inno_log_path = update_dir / f"inno_install_{timestamp}.log" - script_text = _build_update_script( - installer_path=installer_path, - target_exe=target_exe, - pid=os.getpid(), - relaunch_args=list(sys.argv[1:]), - release_url=self._update_release_url or "", - inno_log_path=inno_log_path, - ) - script_path = update_dir / f"install_update_{timestamp}_{os.getpid()}.cmd" - - try: - script_path.write_text(script_text, encoding="utf-8") - - cmd_exe = os.environ.get("COMSPEC") or "cmd" - popen_kwargs: dict[str, Any] = { - "cwd": str(update_dir), - "close_fds": True, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = ( - subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - ) - else: - popen_kwargs["start_new_session"] = True - - subprocess.Popen([cmd_exe, "/d", "/c", str(script_path)], **popen_kwargs) - - if self._window: - self._window.destroy() - - return {"ok": True} - except Exception as e: - return { - "ok": False, - "error": f"Failed to launch update installer: {e}", - } - - def _run_update_check(self) -> None: - """Run update check in background thread, push result to JS when done.""" - try: - info = check_for_updates() - if info and self._window: - # Installed build → silent in-app installer update. - # Portable build → open the releases page (no silent install). - # Dev → open the source tree at the tag. - if _is_installed_build(): - action = "download" - source_tree_url = info.source_tree_url - elif is_running_as_exe(): - action = "open_source_tag" - source_tree_url = info.release_url - else: - action = "open_source_tag" - source_tree_url = info.source_tree_url - payload = { - "latestVersion": info.latest_version, - "releaseName": info.release_name, - "releaseUrl": info.release_url, - "tagName": info.tag_name, - "sourceTreeUrl": source_tree_url, - "action": action, - } - args_json = json.dumps(payload) - self._window.evaluate_js( - f"window.showUpdateNotification({args_json});" - ) - except Exception as e: - log.warning("Update check failed: %s", e) - -def setup_drop(window: webview.Window, api: Api) -> None: - """Bind drag-and-drop events. Runs in a background thread via webview.start().""" - try: - from webview.dom import DOMEventHandler - - def _no_op(e: Any) -> None: - pass - - log.debug("Binding drop events...") - doc = window.dom.document - doc.events.dragover += DOMEventHandler(_no_op, True, True, debounce=500) # type: ignore[operator] - doc.events.drop += DOMEventHandler(api.on_drop, True, True) # type: ignore[operator] - log.debug("Drop events bound.") - except Exception as e: - log.error("Failed to setup drop events: %s", e) - -if __name__ == '__main__': - # Named mutex so the Inno Setup installer's AppMutex can detect a running - # instance (used by CloseApplications during silent self-update). The handle - # is intentionally left open for the process lifetime. - if sys.platform == 'win32': - try: - import ctypes - _single_instance_mutex = ctypes.windll.kernel32.CreateMutexW( - None, False, "AtlasToolkitSingleInstanceMutex" - ) - except Exception: - _single_instance_mutex = None - - api = Api() - - # Calculate center position for primary monitor - window_width, window_height = 1200, 800 - if sys.platform == 'win32': - import ctypes - screen_width = ctypes.windll.user32.GetSystemMetrics(0) # SM_CXSCREEN - screen_height = ctypes.windll.user32.GetSystemMetrics(1) # SM_CYSCREEN - else: - _scr = webview.screens[0] - screen_width, screen_height = _scr.width, _scr.height - center_x = (screen_width - window_width) // 2 - center_y = (screen_height - window_height) // 2 - - GUI_PATH = get_resource_path("ui/index.html") - window = webview.create_window( - f'Atlas Toolkit v{get_current_version()}', - url=str(GUI_PATH.absolute().as_uri()), - width=window_width, height=window_height, - min_size=(800, 500), - x=center_x, y=center_y, - resizable=True, - js_api=api, - background_color='#2b2b2b' - ) - - if window: - api.set_window(window) - else: - sys.exit(1) - - webview.start( - func=setup_drop, - args=(window, api), - # debug=True - ) \ No newline at end of file +if __name__ == "__main__": + run() diff --git a/pyproject.toml b/pyproject.toml index bc2fcd6..b215901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "AtlasToolkit" version = "0.2.2" @@ -9,3 +13,6 @@ dependencies = [ "pywebview==6.1", "requests==2.32.5", ] + +[tool.hatch.build.targets.wheel] +packages = ["atlas_toolkit"] diff --git a/ui/script.js b/ui/script.js index c816033..f459872 100644 --- a/ui/script.js +++ b/ui/script.js @@ -5,7 +5,8 @@ let lastClickIndex = -1; let isDragSelecting = false; let dragStartIndex = -1; let currentMode = "extract"; // 'extract' | 'modify' -let modifyRegionBounds = {}; // {name: [x, y, w, h], ...} +let modifyRegionBounds = {}; // {name: [x, y, w, h, rotate], ...} — atlas bounds +let modifyOverlayRects = {}; // {name: [x, y, w, h], ...} — pre-computed draw rects let hasModImage = false; // Multi-page modify state let modifyPages = []; // ordered page filenames @@ -119,6 +120,7 @@ function updateModifyActionButtons() { function applyModifyView(data, statusText) { modifyRegionBounds = data.regions || {}; + modifyOverlayRects = data.overlayRects || {}; setupModifyPages(data); modifiedRegionNames = new Set(data.modifiedRegions || []); renderRegionList(); @@ -167,6 +169,7 @@ async function exitModifyMode() { } setMode("extract"); modifyRegionBounds = {}; + modifyOverlayRects = {}; modifyPages = []; modifyRegionPages = {}; modifyActivePageIndex = 0; @@ -208,6 +211,9 @@ function onModPreviewReceived(data) { if (data.regions) { modifyRegionBounds = data.regions; } + if (data.overlayRects) { + modifyOverlayRects = data.overlayRects; + } if (data.modifiedRegions) { modifiedRegionNames = new Set(data.modifiedRegions); renderRegionList(); @@ -661,22 +667,20 @@ function drawRegionOverlay() { for (const name of names) { if (activePage && modifyRegionPages[name] && modifyRegionPages[name] !== activePage) continue; - const bounds = modifyRegionBounds[name]; + const bounds = modifyOverlayRects[name] || modifyRegionBounds[name]; if (!bounds) continue; - const [bx, by, bw, bh, rotate] = bounds; - - // Bounds store ORIGINAL dimensions (before rotation) - // Overlay needs to show STORED dimensions (after rotation) - // So swap w/h when rotated - const isRotated = rotate === 90 || rotate === 270; - const drawW = isRotated ? bh : bw; - const drawH = isRotated ? bw : bh; + const [bx, by, bw, bh] = bounds.length >= 5 + ? (() => { + const [x, y, w, h, rotate] = bounds; + const isRotated = rotate === 90 || rotate === 270; + return [x, y, isRotated ? h : w, isRotated ? w : h]; + })() + : bounds; - // Convert to screen coords const rx = topLeftX + bx * scale; const ry = topLeftY + by * scale; - const rw = drawW * scale; - const rh = drawH * scale; + const rw = bw * scale; + const rh = bh * scale; // Draw rect — expand outward by lineWidth ctx.strokeStyle = "rgba(255, 60, 60, 0.85)"; @@ -1057,6 +1061,7 @@ window.onAtlasLoadedFromPython = async () => { if (currentMode === "modify") { setMode("extract"); modifyRegionBounds = {}; // Clear modify state + modifyOverlayRects = {}; hasModImage = false; modifiedRegionNames = new Set(); } diff --git a/uv.lock b/uv.lock index a6fc2e1..6c85653 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "atlastoolkit" version = "0.2.2" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "pillow" }, { name = "pywebview" }, From 379f52557e2d644707c87cfcd9480213ac9f4140 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:00:03 +0700 Subject: [PATCH 15/18] refactor(ui): split monolithic script and stylesheet into modules Organize the pywebview frontend into css/ and js/ layers while preserving global window APIs for onclick handlers and Python evaluate_js callbacks. Co-authored-by: Cursor --- .gitignore | 3 +- ui/css/base.css | 37 ++ ui/css/components.css | 344 ++++++++++ ui/css/layout.css | 161 +++++ ui/css/overlays.css | 277 ++++++++ ui/index.html | 19 +- ui/js/app.js | 17 + ui/js/atlas-load.js | 30 + ui/js/context-menu.js | 39 ++ ui/js/drag-drop.js | 64 ++ ui/js/extract.js | 78 +++ ui/js/keyboard.js | 48 ++ ui/js/mode.js | 88 +++ ui/js/modify.js | 228 +++++++ ui/js/preview.js | 242 +++++++ ui/js/regions.js | 207 ++++++ ui/js/state.js | 24 + ui/js/ui.js | 70 ++ ui/js/updates.js | 289 +++++++++ ui/script.js | 1419 ----------------------------------------- ui/style.css | 830 ------------------------ 21 files changed, 2262 insertions(+), 2252 deletions(-) create mode 100644 ui/css/base.css create mode 100644 ui/css/components.css create mode 100644 ui/css/layout.css create mode 100644 ui/css/overlays.css create mode 100644 ui/js/app.js create mode 100644 ui/js/atlas-load.js create mode 100644 ui/js/context-menu.js create mode 100644 ui/js/drag-drop.js create mode 100644 ui/js/extract.js create mode 100644 ui/js/keyboard.js create mode 100644 ui/js/mode.js create mode 100644 ui/js/modify.js create mode 100644 ui/js/preview.js create mode 100644 ui/js/regions.js create mode 100644 ui/js/state.js create mode 100644 ui/js/ui.js create mode 100644 ui/js/updates.js delete mode 100644 ui/script.js delete mode 100644 ui/style.css diff --git a/.gitignore b/.gitignore index 18f74fc..9150326 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ wheels/ # Virtual environments .venv .vscode -.claude \ No newline at end of file +.claude +CONTEXT.md \ No newline at end of file diff --git a/ui/css/base.css b/ui/css/base.css new file mode 100644 index 0000000..f4d8b39 --- /dev/null +++ b/ui/css/base.css @@ -0,0 +1,37 @@ +:root { + --sidebar-width: 300px; + --panel-header-h: 30px; +} + +body { + margin: 0; + padding: 0; + font-family: "Segoe UI", sans-serif; + height: 100vh; + display: flex; + flex-direction: column; + background-color: #2b2b2b; + color: #eee; + overflow: hidden; + user-select: none; +} +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; + background-color: #1e1e1e; +} +::-webkit-scrollbar-thumb { + background-color: #555; + border-radius: 5px; +} +::-webkit-scrollbar-thumb:hover { + background-color: #666; +} +::-webkit-scrollbar-corner { + background-color: #1e1e1e; +} +/* Modal Styling */ +.hidden { + display: none !important; +} diff --git a/ui/css/components.css b/ui/css/components.css new file mode 100644 index 0000000..b0a40f9 --- /dev/null +++ b/ui/css/components.css @@ -0,0 +1,344 @@ +/* --- BUTTON STYLES (Shared) --- */ +button { + height: 28px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: "Segoe UI", sans-serif; + font-size: 12px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + outline: none; + box-sizing: border-box; + transition: all 0.2s; +} + +/* Open / Back buttons (grey) */ +.btn-open { + background-color: #3c3c3c; + color: #ccc; + border-color: #555; +} +.btn-open:hover { + background-color: #4c4c4c; + color: white; + border-color: #666; +} +.btn-open:disabled { + background-color: #333; + color: #666; + cursor: not-allowed; + border-color: #444; +} + +/* Extract buttons (blue) */ +.action-btn { + background-color: #0e639c; + color: white; + white-space: nowrap; + flex-shrink: 0; +} +.action-btn:hover { + background-color: #1177bb; +} +.action-btn:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; + border-color: #444; +} + +/* Save button (green) */ +.btn-save { + background-color: #388e3c; + color: white; +} +.btn-save:hover { + background-color: #4caf50; +} +.btn-save:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; + border-color: #444; +} + +.btn-reset { + background-color: #5d4037; + color: white; +} +.btn-reset:hover:not(:disabled) { + background-color: #795548; +} +.btn-reset:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; + border-color: #444; +} + +/* Icon Style */ +.btn-icon { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* List Area */ +#region-list-container { + flex: 1; + overflow-y: auto; +} +#region-list { + list-style: none; + padding: 0; + margin: 0; + padding-bottom: 20px; +} +.region-item { + padding: 6px 15px; + font-size: 13px; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; + user-select: none; +} +.region-item:hover { + background-color: #2a2d2e; +} + +.region-item.modified { + font-weight: 700; + color: #89cfff; +} + +/* Highlight Styles */ +.region-item.selected { + background-color: #094771; + color: white; +} + +.region-item.modified.selected { + font-weight: 700; + color: #b8e8ff; +} + +#extract-controls, +#modify-controls { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + flex: 1; +} + +#btn-save-merged { + margin-left: auto; + flex-shrink: 0; +} + +.btn-save-merged { + background-color: #388e3c; + color: white; +} + +.btn-save-merged:hover:not(:disabled) { + background-color: #4caf50; +} + +.btn-save-merged:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; +} + +#modify-page-switcher { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: #bbb; + user-select: none; +} + +.toggle-label input[type="checkbox"] { + display: none; +} + +.toggle-switch { + position: relative; + width: 32px; + height: 18px; + background-color: #555; + border-radius: 9px; + transition: background-color 0.2s; + flex-shrink: 0; +} + +.toggle-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background-color: #ccc; + border-radius: 50%; + transition: + transform 0.2s, + background-color 0.2s; +} + +.toggle-label input:checked + .toggle-switch { + background-color: #0e639c; +} + +.toggle-label input:checked + .toggle-switch::after { + transform: translateX(14px); + background-color: white; +} + +.info-icon { + width: 14px; + height: 14px; + fill: #777; + flex-shrink: 0; + cursor: help; +} + +.toggle-label:hover .info-icon { + fill: #aaa; +} + +/* Preview Area */ +#preview-container { + flex: 1; + min-height: 0; + overflow: hidden; + background-image: conic-gradient( + #222 90deg, + #333 90deg 180deg, + #222 180deg 270deg, + #333 270deg + ); + background-size: 20px 20px; + position: relative; + cursor: grab; + display: flex; + align-items: center; + justify-content: center; +} +#preview-container:active { + cursor: grabbing; +} + +img#preview-img { + position: absolute; + top: 50%; + left: 50%; + max-width: none; + max-height: none; + border: 1px solid #555; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + display: none; + transform-origin: center center; + transition: transform 0.05s ease-out; + image-rendering: optimizeQuality; +} + +#region-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 5; +} +/* Toast Notification */ +#toast-container { + position: absolute; + bottom: 20px; + left: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; + pointer-events: none; +} + +#toast-container .toast { + pointer-events: auto; +} +.toast { + background-color: #333; + color: #fff; + padding: 12px 20px; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-left: 4px solid #0e639c; /* Blue accent */ + font-size: 14px; + animation: slideIn 0.3s ease-out forwards; + opacity: 0; + transform: translateY(20px); + max-width: 300px; +} +.toast.success { + border-left-color: #4caf50; +} +.toast.error { + border-left-color: #f44336; +} + +@keyframes slideIn { + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes fadeOut { + to { + opacity: 0; + transform: translateY(-10px); + } +} +/* Multi-page switcher */ +#page-indicator { + font-size: 12px; + color: #ccc; + white-space: nowrap; + min-width: 64px; + text-align: center; +} +.page-nav-btn { + height: 24px; + width: 24px; + padding: 0; + font-size: 16px; + line-height: 1; + background-color: #3c3c3c; + color: #ccc; + border-color: #555; +} +.page-nav-btn:hover:not(:disabled) { + background-color: #4c4c4c; + color: white; + border-color: #666; +} +.page-nav-btn:disabled { + background-color: #333; + color: #555; + cursor: not-allowed; + border-color: #444; +} diff --git a/ui/css/layout.css b/ui/css/layout.css new file mode 100644 index 0000000..9590e69 --- /dev/null +++ b/ui/css/layout.css @@ -0,0 +1,161 @@ +/* App Bar */ +#app-bar { + height: 42px; + min-height: 42px; + padding: 0; + background-color: #252526; + border-bottom: 1px solid #444; + display: flex; + align-items: stretch; + flex-shrink: 0; + z-index: 10; + box-sizing: border-box; +} + +.app-bar-left { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + padding: 0 10px; + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + flex-shrink: 0; +} + +.app-bar-right { + flex: 1; + min-width: 0; + padding: 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +#btn-save-mod { + margin-left: auto; + flex-shrink: 0; +} + +#status-text { + font-size: 12px; + color: #aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-bar-divider { + width: 1px; + height: 20px; + background-color: #444; + flex-shrink: 0; +} + +.app-bar-panel-divider { + height: auto; + align-self: stretch; + width: 1px; + background-color: #444; + flex-shrink: 0; +} + +.mode-toggle { + display: flex; + flex: 1; + min-width: 0; + border: 1px solid #555; + border-radius: 3px; + overflow: hidden; +} + +.mode-toggle-btn { + flex: 1; + min-width: 0; + height: 26px; + padding: 0 8px; + border: none; + border-radius: 0; + background-color: #3c3c3c; + color: #888; + font-size: 11px; + cursor: pointer; +} + +.mode-toggle-btn.mode-view.active { + background-color: #3d7a8a; + color: white; +} + +.mode-toggle-btn.mode-edit.active { + background-color: #8a7344; + color: white; +} + +.mode-toggle-btn.active { + color: white; +} + +.mode-toggle-btn:disabled { + background-color: #333; + color: #555; + cursor: not-allowed; +} + +.mode-toggle-btn + .mode-toggle-btn { + border-left: 1px solid #555; +} + +/* Main Layout */ +#main-content { + flex: 1; + min-height: 0; + display: flex; +} + +#left-panel { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background-color: #1e1e1e; + border-right: 1px solid #444; + display: flex; + flex-direction: column; + position: relative; +} + +#sidebar-head, +#repack-options, +#status-bar { + height: var(--panel-header-h); + min-height: var(--panel-header-h); + padding: 0 15px; + background-color: #252526; + border-bottom: 1px solid #444; + display: flex; + align-items: center; + flex-shrink: 0; + box-sizing: border-box; +} + +#sidebar-head { + font-size: 12px; + font-weight: bold; + color: #aaa; +} + +#repack-options { + padding: 0 10px; +} + +#status-bar { + padding: 0 12px; +} + +#right-panel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background-color: #333; + position: relative; +} diff --git a/ui/css/overlays.css b/ui/css/overlays.css new file mode 100644 index 0000000..385944e --- /dev/null +++ b/ui/css/overlays.css @@ -0,0 +1,277 @@ +#modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2000; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(2px); +} + +#modal-box { + background-color: #252526; + padding: 24px; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + min-width: 320px; + max-width: 400px; + border: 1px solid #454545; + animation: modalPop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes modalPop { + from { + transform: scale(0.9); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +#modal-title { + margin-top: 0; + margin-bottom: 10px; + color: #ddd; + font-size: 18px; +} + +#modal-message { + color: #bbb; + margin-bottom: 24px; + line-height: 1.5; +} + +.modal-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-primary, +.btn-secondary { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #0e639c; + color: white; +} +.btn-primary:hover { + background-color: #1177bb; +} +.btn-primary:active { + background-color: #094771; +} + +.btn-secondary { + background-color: #3c3c3c; + color: #ccc; + border: 1px solid #555; +} +.btn-secondary:hover { + background-color: #4c4c4c; + color: white; +} +.btn-secondary:active { + background-color: #2d2d2d; +} +/* Context Menu */ +#context-menu { + position: fixed; + background-color: #2d2d2d; + border: 1px solid #454545; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + padding: 4px 0; + z-index: 4000; + min-width: 160px; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + font-size: 13px; + color: #ccc; + cursor: pointer; + transition: background-color 0.1s; +} + +.context-menu-item:hover { + background-color: #094771; + color: white; +} + +.context-menu-item .btn-icon { + width: 14px; + height: 14px; +} + +/* Drop Overlay Styling */ +#drop-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(14, 99, 156, 0.85); + z-index: 3000; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(8px); + pointer-events: none; +} + +#drop-overlay:not(.hidden) { + display: flex !important; + pointer-events: auto; +} + +.drop-message { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + color: white; + pointer-events: none; +} + +.drop-message svg { + width: 80px; + height: 80px; + fill: white; + animation: bounce 1s infinite alternate; +} + +@keyframes bounce { + from { + transform: translateY(0); + } + to { + transform: translateY(-15px); + } +} + +.drop-message span { + font-size: 24px; + font-weight: bold; +} +/* ========================================== + UPDATE NOTIFICATION BAR (Bottom of right panel) + ========================================== */ + +.toast-update { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: #1e2a38; + border-top: 1px solid #2d6a9f; + font-size: 12px; + animation: slideUpBar 0.25s ease-out forwards; + flex-shrink: 0; +} + +@keyframes slideUpBar { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.toast-update-icon { + width: 16px; + height: 16px; + fill: #4fc3f7; + flex-shrink: 0; +} + +.toast-update-title { + font-weight: 600; + color: #e0f0ff; + white-space: nowrap; +} + +.toast-update-sub { + color: #7bafd4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +.toast-update-btn-go { + background-color: #0e639c; + color: white; + border: none; + border-radius: 3px; + padding: 0 10px; + height: 22px; + font-size: 11px; + font-family: "Segoe UI", sans-serif; + cursor: pointer; + transition: background-color 0.15s; + white-space: nowrap; + flex-shrink: 0; + margin-left: auto; +} +.toast-update-btn-go:hover { + background-color: #1177bb; +} + +.toast-update-btn-go:disabled { + background-color: #4a4a4a; + color: #999; + cursor: not-allowed; +} + +.toast-update-btn-close { + background: transparent; + color: #ff6464; + border: 1px solid #ff6464; + padding: 0 6px; + height: 22px; + font-size: 11px; + cursor: pointer; + border-radius: 3px; + transition: color 0.15s, background-color 0.15s; + white-space: nowrap; + flex-shrink: 0; +} +.toast-update-btn-close:hover { + color: #e0f0ff; + background-color: rgba(255, 255, 255, 0.08); +} + +.toast-update-error { + background-color: #3a1f1f; + border-top: 1px solid #a94442; +} + +.toast-update-error .toast-update-title { + color: #ffd6d6; +} + +.toast-update-error .toast-update-sub { + color: #ffb3b3; +} \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index b2e8e48..21990a5 100644 --- a/ui/index.html +++ b/ui/index.html @@ -10,7 +10,10 @@ Atlas Extracter GUI - + + + + @@ -376,7 +379,19 @@ - + + + + + + + + + + + + + diff --git a/ui/js/app.js b/ui/js/app.js new file mode 100644 index 0000000..e3bff3f --- /dev/null +++ b/ui/js/app.js @@ -0,0 +1,17 @@ +// Application bootstrap (pywebview) +window.addEventListener("pywebviewready", async function () { + const repackPref = await pywebview.api.get_pref("repack", false); + document.getElementById("chk-repack").checked = repackPref; + + document.getElementById("mode-extract").addEventListener("click", async () => { + if (currentMode === "extract") return; + await exitModifyMode(); + }); + document.getElementById("mode-modify").addEventListener("click", async () => { + if (currentMode === "modify") return; + await enterModifyMode(); + }); + + const loaded = await pywebview.api.startup_check(); + if (loaded) await loadRegions(); +}); diff --git a/ui/js/atlas-load.js b/ui/js/atlas-load.js new file mode 100644 index 0000000..1d11e67 --- /dev/null +++ b/ui/js/atlas-load.js @@ -0,0 +1,30 @@ +// Atlas Toolkit UI module +async function openFile() { + try { + const success = await pywebview.api.choose_file(); + if (success) { + selectedIndices.clear(); + lastClickIndex = -1; + document.getElementById("preview-img").style.display = "none"; + resetPreview(); + updateButtons(); + await loadRegions(); + } + } catch (e) { + console.error(e); + } +} + +async function loadRegions() { + regionsData = await pywebview.api.get_region_names(); + if (!regionsData) return; + document.getElementById("count").innerText = regionsData.length; + renderRegionList(); + if (regionsData.length > 0) { + setStatus("Atlas loaded."); + document.getElementById("btn-extract-all").disabled = false; + } else { + document.getElementById("btn-extract-all").disabled = true; + } + updateModeToggleUI(); +} diff --git a/ui/js/context-menu.js b/ui/js/context-menu.js new file mode 100644 index 0000000..d84d78a --- /dev/null +++ b/ui/js/context-menu.js @@ -0,0 +1,39 @@ +// Atlas Toolkit UI module +var contextMenu = document.getElementById("context-menu"); + +previewContainer.addEventListener("contextmenu", (e) => { + e.preventDefault(); + + // Only show in extract mode and when there's a visible preview + if (currentMode !== "extract") return; + if (previewImg.style.display === "none" || !previewImg.src) return; + + contextMenu.style.left = e.clientX + "px"; + contextMenu.style.top = e.clientY + "px"; + contextMenu.classList.remove("hidden"); +}); + +window.addEventListener("click", () => { + contextMenu.classList.add("hidden"); +}); + +window.addEventListener("keydown", (e) => { + if (e.key === "Escape") contextMenu.classList.add("hidden"); +}); + +async function copyPreviewImage() { + contextMenu.classList.add("hidden"); + try { + const blob = await getPreviewPngBlob(); + if (!blob) { + showToast("No image to copy.", "error"); + return; + } + + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + showToast("Image copied to clipboard.", "success"); + } catch (e) { + console.error(e); + showToast("Failed to copy image.", "error"); + } +} diff --git a/ui/js/drag-drop.js b/ui/js/drag-drop.js new file mode 100644 index 0000000..d2d9a1f --- /dev/null +++ b/ui/js/drag-drop.js @@ -0,0 +1,64 @@ +// Atlas Toolkit UI module +var dropOverlay = document.getElementById("drop-overlay"); + +// 1. Global Prevention to stop browser from opening files +["dragover", "drop"].forEach((eventName) => { + window.addEventListener(eventName, (e) => e.preventDefault(), false); +}); + +// 2. Window logic to show/hide overlay +window.addEventListener("dragenter", (e) => { + e.preventDefault(); + if (e.dataTransfer.types.includes("Files")) { + dropOverlay.classList.remove("hidden"); + dropOverlay.style.pointerEvents = "auto"; + } +}); + +// 3. Overlay specific logic +dropOverlay.addEventListener("dragover", (e) => { + e.preventDefault(); +}); + +dropOverlay.addEventListener("dragleave", (e) => { + e.preventDefault(); + if (e.relatedTarget === null || !dropOverlay.contains(e.relatedTarget)) { + dropOverlay.classList.add("hidden"); + dropOverlay.style.pointerEvents = "none"; + } +}); + +dropOverlay.addEventListener("drop", (e) => { + e.preventDefault(); + dropOverlay.classList.add("hidden"); + dropOverlay.style.pointerEvents = "none"; + // Python handler (DOMEventHandler) will continue to process the file +}); + +// Callback called from Python after successful drop loading +window.onAtlasLoadedFromPython = async () => { + // If we were in modify mode, switch back + if (currentMode === "modify") { + setMode("extract"); + modifyRegionBounds = {}; // Clear modify state + modifyOverlayRects = {}; + hasModImage = false; + modifiedRegionNames = new Set(); + } + selectedIndices.clear(); + lastClickIndex = -1; + document.getElementById("preview-img").style.display = "none"; + resetPreview(); + clearOverlay(); // Ensure overlay is cleared + updateButtons(); + await loadRegions(); + showToast("Atlas loaded via drag & drop.", "success"); +}; + +// Callback called from Python after mod image processed via drag-drop +window.onModImageProcessed = (data) => { + if (data) { + onModPreviewReceived(data); + showToast("Mod image loaded via drag & drop.", "success"); + } +}; diff --git a/ui/js/extract.js b/ui/js/extract.js new file mode 100644 index 0000000..dd68f5b --- /dev/null +++ b/ui/js/extract.js @@ -0,0 +1,78 @@ +// Atlas Toolkit UI module +function getPreviewPngBlob() { + const img = previewImg; + if (!img.naturalWidth || !img.naturalHeight) return null; + + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext("2d").drawImage(img, 0, 0); + + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} + +function previewSaveDefaultName(names) { + if (!names || names.length === 0) return "image.png"; + const safe = names.map((n) => n.replace(/[<>:"/\\|?*]/g, "_")); + if (safe.length === 1) return `${safe[0]}.png`; + if (safe.length <= 5) return `${safe.join("+")}.png`; + const more = safe.length - 5; + return `${safe.slice(0, 5).join("+")}+ ${more} more.png`; +} + +async function saveMergedImage() { + try { + const blob = await getPreviewPngBlob(); + if (!blob) { + showToast("No image to save.", "error"); + return; + } + + const reader = new FileReader(); + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + const defaultName = previewSaveDefaultName(getSelectedNames()); + const result = await pywebview.api.save_preview_image(dataUrl, defaultName); + if (result === "Cancelled") { + showToast(result, "info"); + } else if (result.startsWith("Error")) { + showToast(result, "error"); + } else { + showToast(result, "success"); + } + } catch (e) { + console.error(e); + showToast("Failed to save image.", "error"); + } +} + +async function extractSelected() { + if (selectedIndices.size === 0) return; + const names = Array.from(selectedIndices).map((i) => regionsData[i]); + setStatus("Extracting..."); + const result = await pywebview.api.extract_files(names); + + showToast(result, result.includes("Error") ? "error" : "success"); + setStatus("Ready"); +} + +async function extractAll() { + if (document.getElementById("count").innerText === "0") return; + + const confirmed = await showConfirm( + "Are you sure you want to extract all regions?", + "Confirm Extraction", + ); + if (!confirmed) return; + + setStatus("Extracting ALL..."); + const result = await pywebview.api.extract_files(null); + + showToast(result, result.includes("Error") ? "error" : "success"); + setStatus("Ready"); +} + diff --git a/ui/js/keyboard.js b/ui/js/keyboard.js new file mode 100644 index 0000000..0419578 --- /dev/null +++ b/ui/js/keyboard.js @@ -0,0 +1,48 @@ +// Atlas Toolkit UI module +// ========================================== +// KEYBOARD NAVIGATION (Arrow Keys) +// ========================================== +window.addEventListener("keydown", (e) => { + if (regionsData.length === 0) return; + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + + e.preventDefault(); + + let newIndex = lastClickIndex; + + if (e.key === "ArrowDown") { + newIndex++; + if (newIndex >= regionsData.length) newIndex = regionsData.length - 1; + } else if (e.key === "ArrowUp") { + newIndex--; + if (newIndex < 0) newIndex = 0; + } + + if (newIndex === lastClickIndex && selectedIndices.size > 0) return; + + if (e.shiftKey) { + if (dragStartIndex === -1) dragStartIndex = lastClickIndex; + + selectedIndices.clear(); + const start = Math.min(dragStartIndex, newIndex); + const end = Math.max(dragStartIndex, newIndex); + for (let i = start; i <= end; i++) selectedIndices.add(i); + } else { + selectedIndices.clear(); + selectedIndices.add(newIndex); + dragStartIndex = newIndex; + } + + lastClickIndex = newIndex; + + renderSelection(); + if (currentMode === "extract") { + updatePreview(getSelectedNames()); + } else { + updateModifyPreview(getSelectedNames()); + } + updateButtons(); + + const item = document.querySelector(`.region-item[data-index="${newIndex}"]`); + if (item) item.scrollIntoView({ block: "nearest" }); +}); diff --git a/ui/js/mode.js b/ui/js/mode.js new file mode 100644 index 0000000..1e04d5c --- /dev/null +++ b/ui/js/mode.js @@ -0,0 +1,88 @@ +// Atlas Toolkit UI module +function updateModeToggleUI() { + const extractBtn = document.getElementById("mode-extract"); + const modifyBtn = document.getElementById("mode-modify"); + extractBtn.classList.toggle("active", currentMode === "extract"); + modifyBtn.classList.toggle("active", currentMode === "modify"); + const count = parseInt(document.getElementById("count").innerText, 10) || 0; + modifyBtn.disabled = count === 0; +} + +function clearRegionSelection() { + selectedIndices.clear(); + lastClickIndex = -1; + renderSelection(); + updateButtons(); +} + +function setMode(mode) { + if (mode !== currentMode) { + clearRegionSelection(); + } + currentMode = mode; + const extractControls = document.getElementById("extract-controls"); + const modifyControls = document.getElementById("modify-controls"); + const repackOptions = document.getElementById("repack-options"); + const saveBtn = document.getElementById("btn-save-mod"); + const dropMsg = document.getElementById("drop-message-text"); + + if (mode === "modify") { + extractControls.classList.add("hidden"); + modifyControls.classList.remove("hidden"); + repackOptions.classList.remove("hidden"); + saveBtn.classList.remove("hidden"); + dropMsg.textContent = "Drop image to modify, or .atlas to load"; + } else { + extractControls.classList.remove("hidden"); + modifyControls.classList.add("hidden"); + repackOptions.classList.add("hidden"); + saveBtn.classList.add("hidden"); + dropMsg.textContent = "Drop .atlas file here to load"; + clearOverlay(); + } + updateModeToggleUI(); + + if (mode === "extract") { + updatePreview(getSelectedNames()); + } else { + updateModifyPreview(getSelectedNames()); + } +} + +async function enterModifyMode() { + try { + const data = await pywebview.api.enter_modify_mode(); + if (data) { + setMode("modify"); + hasModImage = false; + applyModifyView(data, "Select regions and click Modify Selected"); + } else { + showToast("Load an atlas first.", "error"); + } + } catch (e) { + console.error(e); + showToast("Failed to enter modify mode.", "error"); + } +} +async function exitModifyMode() { + try { + await pywebview.api.exit_modify_mode(); + } catch (e) { + console.error(e); + } + setMode("extract"); + modifyRegionBounds = {}; + modifyOverlayRects = {}; + modifyPages = []; + modifyRegionPages = {}; + modifyActivePageIndex = 0; + document.getElementById("modify-page-switcher").classList.add("hidden"); + hasModImage = false; + modifiedRegionNames = new Set(); + renderRegionList(); + clearOverlay(); + // Restore preview from current selection + previewImg.style.display = "none"; + resetPreview(); + setStatus("Ready"); +} diff --git a/ui/js/modify.js b/ui/js/modify.js new file mode 100644 index 0000000..a1608e5 --- /dev/null +++ b/ui/js/modify.js @@ -0,0 +1,228 @@ +// Atlas Toolkit UI module +function updateModifyActionButtons() { + document.getElementById("btn-save-mod").disabled = !hasModImage; + document.getElementById("btn-reset-mod").disabled = !hasModImage; +} +function applyModifyView(data, statusText) { + modifyRegionBounds = data.regions || {}; + modifyOverlayRects = data.overlayRects || {}; + setupModifyPages(data); + modifiedRegionNames = new Set(data.modifiedRegions || []); + renderRegionList(); + setStatus(statusText); + updateModifyActionButtons(); + previewImg.src = data.image; + previewImg.style.display = "block"; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + applyTransform(); + } else { + applyTransform(); + } + previewImg.onload = null; + }; +} + +async function resetModify() { + if (!hasModImage) return; + try { + const data = await pywebview.api.reset_modify_mode(); + if (data) { + hasModImage = false; + applyModifyView(data, "Select regions and click Modify Selected"); + showToast("Modifications reset.", "success"); + } else { + showToast("Failed to reset modifications.", "error"); + } + } catch (e) { + console.error(e); + showToast("Failed to reset modifications.", "error"); + } +} + +async function exitModifyMode() { + try { + await pywebview.api.exit_modify_mode(); + } catch (e) { + console.error(e); + } + setMode("extract"); + modifyRegionBounds = {}; + modifyOverlayRects = {}; + modifyPages = []; + modifyRegionPages = {}; + modifyActivePageIndex = 0; + document.getElementById("modify-page-switcher").classList.add("hidden"); + hasModImage = false; + modifiedRegionNames = new Set(); + renderRegionList(); + clearOverlay(); + // Restore preview from current selection + previewImg.style.display = "none"; + resetPreview(); + setStatus("Ready"); +} + +async function modifySelected() { + const names = getSelectedNames(); + if (names.length === 0) { + showToast("Select at least one region to modify.", "error"); + return; + } + try { + setStatus("Selecting mod image..."); + const repack = document.getElementById("chk-repack").checked; + const result = await pywebview.api.select_mod_image(names, repack); + if (result) { + onModPreviewReceived(result); + } else { + setStatus("Cancelled or no image selected."); + } + } catch (e) { + console.error(e); + showToast("Error selecting mod image.", "error"); + } +} + +function onModPreviewReceived(data) { + hasModImage = true; + // Update region bounds from merged atlas + if (data.regions) { + modifyRegionBounds = data.regions; + } + if (data.overlayRects) { + modifyOverlayRects = data.overlayRects; + } + if (data.modifiedRegions) { + modifiedRegionNames = new Set(data.modifiedRegions); + renderRegionList(); + } + // Refresh multi-page state (regions were redistributed across pages by repack) + setupModifyPages(data); + previewImg.src = data.image; + previewImg.style.display = "block"; + setStatus("Mod image merged. Ready to save."); + updateModifyActionButtons(); + + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + + setStatus(`Merged preview (${imgW}x${imgH}). Ready to save.`); + + if (imgW > containerW || imgH > containerH) { + const scaleW = containerW / imgW; + const scaleH = containerH / imgH; + viewState.scale = Math.min(scaleW, scaleH); + } + applyTransform(); // This also redraws overlay + previewImg.onload = null; + }; +} + +async function saveModified() { + try { + setStatus("Saving..."); + const result = await pywebview.api.save_modified(); + if (result.startsWith("Error") || result === "Cancelled") { + showToast(result, result === "Cancelled" ? "info" : "error"); + } else { + showToast(result, "success"); + } + setStatus(result); + } catch (e) { + console.error(e); + showToast("Save failed.", "error"); + } +} + +// ========================================== +// MULTI-PAGE SWITCHER +// ========================================== +function setupModifyPages(data) { + modifyPages = Array.isArray(data.pages) ? data.pages : []; + modifyRegionPages = data.regionPages || {}; + modifyActivePageIndex = 0; + const switcher = document.getElementById("modify-page-switcher"); + const repackOptions = document.getElementById("repack-options"); + if (modifyPages.length > 1) { + switcher.classList.remove("hidden"); + updatePageIndicator(); + // Multi-page always repacks all pages; the per-page repack toggle is + // inert here (and toggling it post-merge errors), so hide it. + repackOptions.classList.add("hidden"); + } else { + switcher.classList.add("hidden"); + repackOptions.classList.remove("hidden"); + } +} + +function updatePageIndicator() { + const ind = document.getElementById("page-indicator"); + if (ind) + ind.innerText = `Page ${modifyActivePageIndex + 1} / ${modifyPages.length}`; + document.getElementById("page-prev").disabled = modifyActivePageIndex <= 0; + document.getElementById("page-next").disabled = + modifyActivePageIndex >= modifyPages.length - 1; +} + +async function showModifyPage(index) { + if (index < 0 || index >= modifyPages.length) return; + modifyActivePageIndex = index; + updatePageIndicator(); + try { + const dataUri = await pywebview.api.get_modify_page_preview(index); + if (!dataUri) return; + previewImg.src = dataUri; + previewImg.style.display = "block"; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + } + applyTransform(); // redraws overlay (filtered to this page) + previewImg.onload = null; + }; + } catch (e) { + console.error(e); + } +} + +function modifyPagePrev() { + showModifyPage(modifyActivePageIndex - 1); +} +function modifyPageNext() { + showModifyPage(modifyActivePageIndex + 1); +} + +document.getElementById("chk-repack").addEventListener("change", async (e) => { + pywebview.api.set_pref("repack", e.target.checked); + if (!hasModImage) return; + setStatus( + e.target.checked ? "Applying repack..." : "Reverting repack...", + ); + try { + const result = await pywebview.api.toggle_repack(e.target.checked); + if (result) { + onModPreviewReceived(result); + } else { + showToast("No merged data to repack.", "error"); + } + } catch (err) { + console.error(err); + showToast("Repack toggle failed.", "error"); + } +}); diff --git a/ui/js/preview.js b/ui/js/preview.js new file mode 100644 index 0000000..ed1d713 --- /dev/null +++ b/ui/js/preview.js @@ -0,0 +1,242 @@ +// Atlas Toolkit UI module +function updateModifyPreview(names) { + // Pure client-side: just redraw overlay canvas + drawRegionOverlay(); + if (!names || names.length === 0) { + setStatus( + hasModImage + ? "Mod image merged. Ready to save." + : "Select regions and click Modify Selected", + ); + } else { + setStatus( + hasModImage + ? `Merged preview. ${names.length} region(s) selected.` + : `${names.length} region(s) selected`, + ); + } +} + +var previewContainer = document.getElementById("preview-container"); +var previewImg = document.getElementById("preview-img"); + +function resetPreview() { + viewState = { + scale: 1, + x: 0, + y: 0, + isDragging: false, + startX: 0, + startY: 0, + }; + applyTransform(); +} + +function applyTransform() { + previewImg.style.transform = `translate(calc(-50% + ${viewState.x}px), calc(-50% + ${viewState.y}px)) scale(${viewState.scale})`; + // Redraw overlay if in modify mode + if (currentMode === "modify") { + drawRegionOverlay(); + } +} + +// ========================================== +// REGION OVERLAY (Canvas) +// ========================================== +function drawRegionOverlay() { + const canvas = document.getElementById("region-overlay"); + if (!canvas) return; + const ctx = canvas.getContext("2d"); + + const dpr = window.devicePixelRatio || 1; + const containerW = previewContainer.clientWidth; + const containerH = previewContainer.clientHeight; + canvas.width = containerW * dpr; + canvas.height = containerH * dpr; + canvas.style.width = containerW + "px"; + canvas.style.height = containerH + "px"; + ctx.scale(dpr, dpr); + + ctx.clearRect(0, 0, containerW, containerH); + + if (currentMode !== "modify" || selectedIndices.size === 0) return; + + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + if (!imgW || !imgH) return; + + // Compute image position on screen + const scale = viewState.scale; + const centerX = containerW / 2 + viewState.x; + const centerY = containerH / 2 + viewState.y; + const displayW = imgW * scale; + const displayH = imgH * scale; + const topLeftX = centerX - displayW / 2; + const topLeftY = centerY - displayH / 2; + + const lineWidth = 3; + const names = getSelectedNames(); + + // On multi-page atlases only draw regions that live on the visible page. + const activePage = + modifyPages.length > 1 ? modifyPages[modifyActivePageIndex] : null; + + for (const name of names) { + if (activePage && modifyRegionPages[name] && modifyRegionPages[name] !== activePage) + continue; + const bounds = modifyOverlayRects[name] || modifyRegionBounds[name]; + if (!bounds) continue; + const [bx, by, bw, bh] = bounds.length >= 5 + ? (() => { + const [x, y, w, h, rotate] = bounds; + const isRotated = rotate === 90 || rotate === 270; + return [x, y, isRotated ? h : w, isRotated ? w : h]; + })() + : bounds; + + const rx = topLeftX + bx * scale; + const ry = topLeftY + by * scale; + const rw = bw * scale; + const rh = bh * scale; + + // Draw rect — expand outward by lineWidth + ctx.strokeStyle = "rgba(255, 60, 60, 0.85)"; + ctx.lineWidth = lineWidth; + ctx.strokeRect( + rx - lineWidth / 2, + ry - lineWidth / 2, + rw + lineWidth, + rh + lineWidth, + ); + + // Draw label above the box + const fontSize = 13; + ctx.font = `bold ${fontSize}px "Segoe UI", sans-serif`; + const textMetrics = ctx.measureText(name); + const textW = textMetrics.width; + const labelX = rx; + const labelY = ry - lineWidth - 2; + + // Label background + ctx.fillStyle = "rgba(255, 60, 60, 0.85)"; + ctx.fillRect(labelX - 1, labelY - fontSize, textW + 8, fontSize + 4); + + // Label text + ctx.fillStyle = "white"; + ctx.fillText(name, labelX + 3, labelY - 1); + } +} + +function clearOverlay() { + const canvas = document.getElementById("region-overlay"); + if (!canvas) return; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); +} + +async function updatePreview(names) { + const status = document.getElementById("status-text"); + if (!names || names.length === 0) { + previewImg.style.display = "none"; + status.innerText = "No selection"; + updateButtons(); + return; + } + + const base64Img = await pywebview.api.get_preview(names); + if (base64Img) { + previewImg.src = base64Img; + previewImg.style.display = "block"; + if (names.length === 1) { + status.innerText = `Previewing: ${names[0]}`; + } else { + status.innerText = `Previewing: ${names.length} regions`; + } + + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + + if (names.length === 1) { + status.innerText = `Previewing: ${names[0]} (${imgW}x${imgH})`; + } else { + status.innerText = `Previewing: ${names.length} regions (${imgW}x${imgH})`; + } + + if (imgW > containerW || imgH > containerH) { + const scaleW = containerW / imgW; + const scaleH = containerH / imgH; + viewState.scale = Math.min(scaleW, scaleH); + applyTransform(); + } + updateButtons(); + previewImg.onload = null; + }; + } else { + previewImg.style.display = "none"; + status.innerText = "Preview failed"; + updateButtons(); + } +} + +previewContainer.addEventListener("wheel", (e) => { + e.preventDefault(); + const zoomIntensity = 0.1; + const direction = -Math.sign(e.deltaY); + const newScale = + viewState.scale + direction * zoomIntensity * viewState.scale; + + if (newScale > 0.1 && newScale < 50) { + const rect = previewContainer.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + const mx = e.clientX - rect.left - cx; + const my = e.clientY - rect.top - cy; + + viewState.x = mx - (mx - viewState.x) * (newScale / viewState.scale); + viewState.y = my - (my - viewState.y) * (newScale / viewState.scale); + + viewState.scale = newScale; + applyTransform(); + } +}); + +previewContainer.addEventListener("mousedown", (e) => { + if (e.button !== 0 && e.button !== 1) return; + e.preventDefault(); + viewState.isDragging = true; + viewState.startX = e.clientX - viewState.x; + viewState.startY = e.clientY - viewState.y; +}); + +window.addEventListener("mousemove", (e) => { + if (viewState.isDragging) { + viewState.x = e.clientX - viewState.startX; + viewState.y = e.clientY - viewState.startY; + applyTransform(); + } +}); + +function updateButtons() { + const btnSel = document.getElementById("btn-extract-sel"); + btnSel.disabled = selectedIndices.size === 0; + btnSel.innerText = `Extract Selected (${selectedIndices.size})`; + + const btnModSel = document.getElementById("btn-modify-sel"); + if (btnModSel) { + btnModSel.disabled = selectedIndices.size === 0; + btnModSel.innerText = `Modify Selected (${selectedIndices.size})`; + } + + const btnSaveMerged = document.getElementById("btn-save-merged"); + if (btnSaveMerged) { + const hasPreview = + previewImg.style.display !== "none" && + previewImg.naturalWidth > 0 && + previewImg.naturalHeight > 0; + btnSaveMerged.disabled = !hasPreview; + } +} diff --git a/ui/js/regions.js b/ui/js/regions.js new file mode 100644 index 0000000..e4acc7f --- /dev/null +++ b/ui/js/regions.js @@ -0,0 +1,207 @@ +// Atlas Toolkit UI module +function regionDisplayName(name) { + return modifiedRegionNames.has(name) ? `${name}*` : name; +} + +function renderRegionList() { + const listEl = document.getElementById("region-list"); + listEl.innerHTML = ""; + regionsData.forEach((name, index) => { + const li = document.createElement("li"); + li.className = "region-item"; + if (modifiedRegionNames.has(name)) { + li.classList.add("modified"); + } + li.innerText = regionDisplayName(name); + li.dataset.index = index; + li.addEventListener("mousedown", (e) => onRegionMouseDown(e, index, name)); + li.addEventListener("mouseenter", (e) => onRegionMouseEnter(e, index)); + listEl.appendChild(li); + }); + renderSelection(); +} + +function getSelectedNames() { + return Array.from(selectedIndices) + .sort((a, b) => a - b) + .map((i) => regionsData[i]); +} + +// --- Auto-Scroll State --- +var autoScrollSpeed = 0; +var autoScrollInterval = null; +var SCROLL_ZONE_SIZE = 50; +var MAX_SCROLL_SPEED = 15; +var lastMouseX = 0; +var lastMouseY = 0; + +function onRegionMouseDown(e, index, name) { + if (e.button !== 0) return; + if (e.shiftKey) e.preventDefault(); + + isDragSelecting = true; + dragStartIndex = index; + + if (e.ctrlKey || e.metaKey) { + toggleIndex(index); + lastClickIndex = index; + } else if (e.shiftKey && lastClickIndex !== -1) { + selectRange( + Math.min(lastClickIndex, index), + Math.max(lastClickIndex, index), + false, + ); + } else { + selectedIndices.clear(); + selectedIndices.add(index); + lastClickIndex = index; + } + + renderSelection(); + triggerPreviewUpdate(); + + window.addEventListener("mousemove", onWindowMouseMove); + startAutoScroll(); +} + +function onWindowMouseMove(e) { + if (!isDragSelecting) return; + e.preventDefault(); + + lastMouseX = e.clientX; + lastMouseY = e.clientY; + + const container = document.getElementById("region-list-container"); + const rect = container.getBoundingClientRect(); + + if (e.clientY < rect.top + SCROLL_ZONE_SIZE) { + const dist = Math.max(0, rect.top + SCROLL_ZONE_SIZE - e.clientY); + autoScrollSpeed = -(dist / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; + } else if (e.clientY > rect.bottom - SCROLL_ZONE_SIZE) { + const dist = Math.max(0, e.clientY - (rect.bottom - SCROLL_ZONE_SIZE)); + autoScrollSpeed = (dist / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; + } else { + autoScrollSpeed = 0; + } + + updateSelectionFromMouse(e.clientX, e.clientY); +} + +function updateSelectionFromMouse(clientX, clientY) { + const container = document.getElementById("region-list-container"); + const rect = container.getBoundingClientRect(); + + let checkY = Math.max(rect.top + 1, Math.min(clientY, rect.bottom - 1)); + let checkX = rect.left + rect.width / 2; + + const el = document.elementFromPoint(checkX, checkY); + const item = el?.closest(".region-item"); + + if (item) { + const index = parseInt(item.dataset.index); + if (!isNaN(index)) { + const start = Math.min(dragStartIndex, index); + const end = Math.max(dragStartIndex, index); + + selectedIndices.clear(); + for (let i = start; i <= end; i++) selectedIndices.add(i); + + lastClickIndex = index; + renderSelection(); + triggerPreviewUpdate(); + } + } +} + +// --- Preview Debounce State --- +var previewTimeout = null; +var lastSelectedJSON = "[]"; + +function triggerPreviewUpdate() { + const currentNames = getSelectedNames(); + const currentJSON = JSON.stringify(currentNames); + + if (currentJSON !== lastSelectedJSON) { + lastSelectedJSON = currentJSON; + + if (previewTimeout) clearTimeout(previewTimeout); + + previewTimeout = setTimeout(() => { + if (currentMode === "modify") { + updateModifyPreview(currentNames); + } else { + updatePreview(currentNames); + } + }, 50); + + updateButtons(); + } +} + +function startAutoScroll() { + if (autoScrollInterval) return; + + function scrollLoop() { + if (!isDragSelecting) { + stopAutoScroll(); + return; + } + + if (autoScrollSpeed !== 0) { + const container = document.getElementById("region-list-container"); + container.scrollTop += autoScrollSpeed; + + updateSelectionFromMouse(lastMouseX, lastMouseY); + } + + triggerPreviewUpdate(); + + autoScrollInterval = requestAnimationFrame(scrollLoop); + } + autoScrollInterval = requestAnimationFrame(scrollLoop); +} + +function stopAutoScroll() { + if (autoScrollInterval) { + cancelAnimationFrame(autoScrollInterval); + autoScrollInterval = null; + } + autoScrollSpeed = 0; +} + +function onRegionMouseEnter(e, index) { + // Deprecated in favor of global handler +} + +window.addEventListener("mouseup", () => { + if (isDragSelecting) { + isDragSelecting = false; + stopAutoScroll(); + window.removeEventListener("mousemove", onWindowMouseMove); + if (currentMode === "extract") { + updatePreview(getSelectedNames()); + } else { + updateModifyPreview(getSelectedNames()); + } + updateButtons(); + } + viewState.isDragging = false; +}); + +function toggleIndex(index) { + if (selectedIndices.has(index)) selectedIndices.delete(index); + else selectedIndices.add(index); +} + +function selectRange(start, end, keepExisting) { + if (!keepExisting) selectedIndices.clear(); + for (let i = start; i <= end; i++) selectedIndices.add(i); +} + +function renderSelection() { + const items = document.querySelectorAll(".region-item"); + items.forEach((el, idx) => { + if (selectedIndices.has(idx)) el.classList.add("selected"); + else el.classList.remove("selected"); + }); +} diff --git a/ui/js/state.js b/ui/js/state.js new file mode 100644 index 0000000..49a6674 --- /dev/null +++ b/ui/js/state.js @@ -0,0 +1,24 @@ +// Atlas Toolkit UI module +// --- Data State --- +var regionsData = []; +var selectedIndices = new Set(); +var lastClickIndex = -1; +var isDragSelecting = false; +var dragStartIndex = -1; +var currentMode = "extract"; // 'extract' | 'modify' +var modifyRegionBounds = {}; // {name: [x, y, w, h, rotate], ...} — atlas bounds +var modifyOverlayRects = {}; // {name: [x, y, w, h], ...} — pre-computed draw rects +var hasModImage = false; +// Multi-page modify state +var modifyPages = []; // ordered page filenames +var modifyActivePageIndex = 0; +var modifyRegionPages = {}; // {name: pageFilename} +var modifiedRegionNames = new Set(); // regions already modified this session +var viewState = { + scale: 1, + x: 0, + y: 0, + isDragging: false, + startX: 0, + startY: 0, +}; diff --git a/ui/js/ui.js b/ui/js/ui.js new file mode 100644 index 0000000..a431fa0 --- /dev/null +++ b/ui/js/ui.js @@ -0,0 +1,70 @@ +// Atlas Toolkit UI module +function setStatus(text) { + document.getElementById("status-text").innerText = text; +} +function showConfirm(message, title = "Confirm") { + return new Promise((resolve) => { + const overlay = document.getElementById("modal-overlay"); + const titleEl = document.getElementById("modal-title"); + const msgEl = document.getElementById("modal-message"); + const btnConfirm = document.getElementById("btn-modal-confirm"); + const btnCancel = document.getElementById("btn-modal-cancel"); + + titleEl.innerText = title; + msgEl.innerText = message; + overlay.classList.remove("hidden"); + + if (document.activeElement) document.activeElement.blur(); + + btnConfirm.focus(); + + function cleanup() { + overlay.classList.add("hidden"); + btnConfirm.removeEventListener("click", onConfirm); + btnCancel.removeEventListener("click", onCancel); + window.removeEventListener("keydown", onKey); + } + + function onConfirm() { + cleanup(); + resolve(true); + } + + function onCancel() { + cleanup(); + resolve(false); + } + + function onKey(e) { + if (e.key === "Escape") onCancel(); + } + + btnConfirm.addEventListener("click", onConfirm); + btnCancel.addEventListener("click", onCancel); + window.addEventListener("keydown", onKey); + }); +} + +// --- Toast Logic --- +function showToast(message, type = "info") { + const container = document.getElementById("toast-container"); + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.innerText = message; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = "1"; + toast.style.transform = "translateY(0)"; + + toast.style.animation = "none"; + toast.offsetHeight; /* trigger reflow */ + + toast.style.animation = "fadeOut 0.5s ease-out forwards"; + + toast.addEventListener("animationend", () => { + toast.remove(); + }); + }, 3000); +} diff --git a/ui/js/updates.js b/ui/js/updates.js new file mode 100644 index 0000000..f9d8ca2 --- /dev/null +++ b/ui/js/updates.js @@ -0,0 +1,289 @@ +// Atlas Toolkit UI module +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = unitIndex === 0 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +async function openExternalUrl(url, invalidMessage = "Invalid URL.") { + try { + const parsed = new URL(String(url)); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + showToast(invalidMessage, "error"); + return; + } + + if (window.pywebview && pywebview.api && pywebview.api.open_url) { + const result = await pywebview.api.open_url(parsed.toString()); + if (!result || !result.ok) { + showToast((result && result.error) || "Failed to open URL.", "error"); + } + return; + } + + window.open(parsed.toString(), "_blank"); + } catch (err) { + console.error(err); + showToast(invalidMessage, "error"); + } +} + +window.showUpdateNotification = function (...args) { + let payload = null; + + if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) { + payload = args[0]; + } else { + const legacyVersion = String(args[0] || ""); + // Backward compatibility with old signature + payload = { + latestVersion: legacyVersion, + releaseName: String(args[1] || ""), + releaseUrl: String(args[2] || ""), + tagName: legacyVersion + ? legacyVersion.startsWith("v") + ? legacyVersion + : `v${legacyVersion}` + : "", + sourceTreeUrl: String(args[2] || ""), + action: "open_source_tag", + }; + } + + const latestVersion = String(payload.latestVersion || ""); + const releaseName = String(payload.releaseName || latestVersion || "New release"); + const releaseUrl = String(payload.releaseUrl || ""); + const sourceTreeUrl = String(payload.sourceTreeUrl || releaseUrl); + const tagName = String(payload.tagName || latestVersion || ""); + const action = payload.action === "download" ? "download" : "open_source_tag"; + + // Remove any existing update toast first + const existing = document.getElementById("update-toast"); + if (existing) existing.remove(); + + const toast = document.createElement("div"); + toast.id = "update-toast"; + toast.className = "toast-update"; + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("class", "toast-update-icon"); + icon.setAttribute("viewBox", "0 -960 960 960"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute( + "d", + "M440-320v-326L336-542l-56-58 200-200 200 200-56 58-104-104v326h-80ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z", + ); + icon.appendChild(path); + + const title = document.createElement("span"); + title.className = "toast-update-title"; + title.textContent = `Update available - ${latestVersion}`; + + const sub = document.createElement("span"); + sub.className = "toast-update-sub"; + sub.textContent = releaseName; + + const actionBtn = document.createElement("button"); + actionBtn.className = "toast-update-btn-go"; + + let phase = action === "download" ? "download" : "external"; + let pollTimer = null; + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + async function pollDownloadProgress() { + try { + const progress = await pywebview.api.get_update_download_progress(); + if (!progress) return; + + const status = String(progress.status || "idle"); + if (status === "downloading") { + const total = Number(progress.total_bytes || 0); + const downloaded = Number(progress.downloaded_bytes || 0); + if (total > 0) { + const percent = Number(progress.percent || 0); + sub.textContent = `Downloading... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`; + actionBtn.textContent = `Downloading ${percent}%`; + } else { + sub.textContent = `Downloading... ${formatBytes(downloaded)}`; + actionBtn.textContent = "Downloading..."; + } + } else if (status === "error") { + stopPolling(); + } + } catch (err) { + console.error(err); + } + } + + async function handleDownload() { + actionBtn.disabled = true; + actionBtn.textContent = "Downloading..."; + sub.textContent = "Downloading update package..."; + + stopPolling(); + pollTimer = setInterval(pollDownloadProgress, 350); + + try { + const result = await pywebview.api.download_update(); + stopPolling(); + + if (result && result.ok) { + phase = "restart"; + actionBtn.disabled = false; + actionBtn.textContent = "Restart to Update"; + sub.textContent = `Downloaded v${result.version}. Restart to install.`; + } else { + const message = (result && result.error) || "Update download failed."; + showToast(message, "error"); + phase = "download"; + actionBtn.disabled = false; + actionBtn.textContent = "Download Update"; + sub.textContent = releaseName; + } + } catch (err) { + stopPolling(); + console.error(err); + showToast("Update download failed.", "error"); + phase = "download"; + actionBtn.disabled = false; + actionBtn.textContent = "Download Update"; + sub.textContent = releaseName; + } + } + + async function handleRestartToUpdate() { + actionBtn.disabled = true; + actionBtn.textContent = "Restarting..."; + sub.textContent = "Restarting app to install update..."; + + try { + const result = await pywebview.api.restart_and_install_update(); + if (!result || !result.ok) { + const message = (result && result.error) || "Failed to restart for update."; + showToast(message, "error"); + actionBtn.disabled = false; + actionBtn.textContent = "Restart to Update"; + sub.textContent = releaseName; + } + } catch (err) { + console.error(err); + showToast("Failed to restart for update.", "error"); + actionBtn.disabled = false; + actionBtn.textContent = "Restart to Update"; + sub.textContent = releaseName; + } + } + + actionBtn.addEventListener("click", () => { + if (phase === "external") { + openExternalUrl(sourceTreeUrl, "Invalid source URL."); + return; + } + + if (phase === "download") { + handleDownload(); + return; + } + + if (phase === "restart") { + handleRestartToUpdate(); + } + }); + + if (phase === "external") { + actionBtn.textContent = tagName ? `View Source (${tagName})` : "View Source"; + } else { + actionBtn.textContent = "Download Update"; + } + + const closeBtn = document.createElement("button"); + closeBtn.className = "toast-update-btn-close"; + closeBtn.textContent = "x"; + closeBtn.addEventListener("click", () => { + stopPolling(); + toast.remove(); + }); + + toast.appendChild(icon); + toast.appendChild(title); + toast.appendChild(sub); + toast.appendChild(actionBtn); + toast.appendChild(closeBtn); + + document.getElementById("right-panel").appendChild(toast); +}; + +window.showUpdateInstallFailed = function (payload) { + const info = payload && typeof payload === "object" ? payload : {}; + const message = String( + info.message || "Update installation failed. The app was relaunched.", + ); + const logPath = String(info.logPath || ""); + const releaseUrl = String(info.releaseUrl || ""); + + const existing = document.getElementById("update-failed-toast"); + if (existing) existing.remove(); + + const toast = document.createElement("div"); + toast.id = "update-failed-toast"; + toast.className = "toast-update toast-update-error"; + + const title = document.createElement("span"); + title.className = "toast-update-title"; + title.textContent = "Update failed"; + + const sub = document.createElement("span"); + sub.className = "toast-update-sub"; + sub.textContent = message; + + const logBtn = document.createElement("button"); + logBtn.className = "toast-update-btn-go"; + logBtn.textContent = "Open Log"; + logBtn.disabled = !logPath; + logBtn.addEventListener("click", async () => { + if (!logPath) return; + try { + const result = await pywebview.api.open_update_log(logPath); + if (!result || !result.ok) { + showToast((result && result.error) || "Failed to open log.", "error"); + } + } catch (err) { + console.error(err); + showToast("Failed to open log.", "error"); + } + }); + + const releaseBtn = document.createElement("button"); + releaseBtn.className = "toast-update-btn-go"; + releaseBtn.textContent = "Download Manually"; + releaseBtn.disabled = !releaseUrl; + releaseBtn.addEventListener("click", () => { + if (!releaseUrl) return; + openExternalUrl(releaseUrl, "Invalid release URL."); + }); + + const closeBtn = document.createElement("button"); + closeBtn.className = "toast-update-btn-close"; + closeBtn.textContent = "x"; + closeBtn.addEventListener("click", () => toast.remove()); + + toast.appendChild(title); + toast.appendChild(sub); + toast.appendChild(logBtn); + toast.appendChild(releaseBtn); + toast.appendChild(closeBtn); + + document.getElementById("right-panel").appendChild(toast); +}; diff --git a/ui/script.js b/ui/script.js deleted file mode 100644 index f459872..0000000 --- a/ui/script.js +++ /dev/null @@ -1,1419 +0,0 @@ -// --- Data State --- -let regionsData = []; -let selectedIndices = new Set(); -let lastClickIndex = -1; -let isDragSelecting = false; -let dragStartIndex = -1; -let currentMode = "extract"; // 'extract' | 'modify' -let modifyRegionBounds = {}; // {name: [x, y, w, h, rotate], ...} — atlas bounds -let modifyOverlayRects = {}; // {name: [x, y, w, h], ...} — pre-computed draw rects -let hasModImage = false; -// Multi-page modify state -let modifyPages = []; // ordered page filenames -let modifyActivePageIndex = 0; -let modifyRegionPages = {}; // {name: pageFilename} -let modifiedRegionNames = new Set(); // regions already modified this session -let viewState = { - scale: 1, - x: 0, - y: 0, - isDragging: false, - startX: 0, - startY: 0, -}; - -window.addEventListener("pywebviewready", async function () { - // Restore saved preferences - const repackPref = await pywebview.api.get_pref("repack", false); - document.getElementById("chk-repack").checked = repackPref; - - document.getElementById("mode-extract").addEventListener("click", async () => { - if (currentMode === "extract") return; - await exitModifyMode(); - }); - document.getElementById("mode-modify").addEventListener("click", async () => { - if (currentMode === "modify") return; - await enterModifyMode(); - }); - - const loaded = await pywebview.api.startup_check(); - if (loaded) await loadRegions(); -}); - -// ========================================== -// MODE SWITCHING -// ========================================== -function setStatus(text) { - document.getElementById("status-text").innerText = text; -} - -function updateModeToggleUI() { - const extractBtn = document.getElementById("mode-extract"); - const modifyBtn = document.getElementById("mode-modify"); - extractBtn.classList.toggle("active", currentMode === "extract"); - modifyBtn.classList.toggle("active", currentMode === "modify"); - const count = parseInt(document.getElementById("count").innerText, 10) || 0; - modifyBtn.disabled = count === 0; -} - -function clearRegionSelection() { - selectedIndices.clear(); - lastClickIndex = -1; - renderSelection(); - updateButtons(); -} - -function setMode(mode) { - if (mode !== currentMode) { - clearRegionSelection(); - } - currentMode = mode; - const extractControls = document.getElementById("extract-controls"); - const modifyControls = document.getElementById("modify-controls"); - const repackOptions = document.getElementById("repack-options"); - const saveBtn = document.getElementById("btn-save-mod"); - const dropMsg = document.getElementById("drop-message-text"); - - if (mode === "modify") { - extractControls.classList.add("hidden"); - modifyControls.classList.remove("hidden"); - repackOptions.classList.remove("hidden"); - saveBtn.classList.remove("hidden"); - dropMsg.textContent = "Drop image to modify, or .atlas to load"; - } else { - extractControls.classList.remove("hidden"); - modifyControls.classList.add("hidden"); - repackOptions.classList.add("hidden"); - saveBtn.classList.add("hidden"); - dropMsg.textContent = "Drop .atlas file here to load"; - clearOverlay(); - } - updateModeToggleUI(); - - if (mode === "extract") { - updatePreview(getSelectedNames()); - } else { - updateModifyPreview(getSelectedNames()); - } -} - -async function enterModifyMode() { - try { - const data = await pywebview.api.enter_modify_mode(); - if (data) { - setMode("modify"); - hasModImage = false; - applyModifyView(data, "Select regions and click Modify Selected"); - } else { - showToast("Load an atlas first.", "error"); - } - } catch (e) { - console.error(e); - showToast("Failed to enter modify mode.", "error"); - } -} - -function updateModifyActionButtons() { - document.getElementById("btn-save-mod").disabled = !hasModImage; - document.getElementById("btn-reset-mod").disabled = !hasModImage; -} - -function applyModifyView(data, statusText) { - modifyRegionBounds = data.regions || {}; - modifyOverlayRects = data.overlayRects || {}; - setupModifyPages(data); - modifiedRegionNames = new Set(data.modifiedRegions || []); - renderRegionList(); - setStatus(statusText); - updateModifyActionButtons(); - previewImg.src = data.image; - previewImg.style.display = "block"; - previewImg.onload = function () { - resetPreview(); - const containerW = previewContainer.clientWidth - 40; - const containerH = previewContainer.clientHeight - 40; - const imgW = previewImg.naturalWidth; - const imgH = previewImg.naturalHeight; - if (imgW > containerW || imgH > containerH) { - viewState.scale = Math.min(containerW / imgW, containerH / imgH); - applyTransform(); - } else { - applyTransform(); - } - previewImg.onload = null; - }; -} - -async function resetModify() { - if (!hasModImage) return; - try { - const data = await pywebview.api.reset_modify_mode(); - if (data) { - hasModImage = false; - applyModifyView(data, "Select regions and click Modify Selected"); - showToast("Modifications reset.", "success"); - } else { - showToast("Failed to reset modifications.", "error"); - } - } catch (e) { - console.error(e); - showToast("Failed to reset modifications.", "error"); - } -} - -async function exitModifyMode() { - try { - await pywebview.api.exit_modify_mode(); - } catch (e) { - console.error(e); - } - setMode("extract"); - modifyRegionBounds = {}; - modifyOverlayRects = {}; - modifyPages = []; - modifyRegionPages = {}; - modifyActivePageIndex = 0; - document.getElementById("modify-page-switcher").classList.add("hidden"); - hasModImage = false; - modifiedRegionNames = new Set(); - renderRegionList(); - clearOverlay(); - // Restore preview from current selection - previewImg.style.display = "none"; - resetPreview(); - setStatus("Ready"); -} - -async function modifySelected() { - const names = getSelectedNames(); - if (names.length === 0) { - showToast("Select at least one region to modify.", "error"); - return; - } - try { - setStatus("Selecting mod image..."); - const repack = document.getElementById("chk-repack").checked; - const result = await pywebview.api.select_mod_image(names, repack); - if (result) { - onModPreviewReceived(result); - } else { - setStatus("Cancelled or no image selected."); - } - } catch (e) { - console.error(e); - showToast("Error selecting mod image.", "error"); - } -} - -function onModPreviewReceived(data) { - hasModImage = true; - // Update region bounds from merged atlas - if (data.regions) { - modifyRegionBounds = data.regions; - } - if (data.overlayRects) { - modifyOverlayRects = data.overlayRects; - } - if (data.modifiedRegions) { - modifiedRegionNames = new Set(data.modifiedRegions); - renderRegionList(); - } - // Refresh multi-page state (regions were redistributed across pages by repack) - setupModifyPages(data); - previewImg.src = data.image; - previewImg.style.display = "block"; - setStatus("Mod image merged. Ready to save."); - updateModifyActionButtons(); - - previewImg.onload = function () { - resetPreview(); - const containerW = previewContainer.clientWidth - 40; - const containerH = previewContainer.clientHeight - 40; - const imgW = previewImg.naturalWidth; - const imgH = previewImg.naturalHeight; - - setStatus(`Merged preview (${imgW}x${imgH}). Ready to save.`); - - if (imgW > containerW || imgH > containerH) { - const scaleW = containerW / imgW; - const scaleH = containerH / imgH; - viewState.scale = Math.min(scaleW, scaleH); - } - applyTransform(); // This also redraws overlay - previewImg.onload = null; - }; -} - -async function saveModified() { - try { - setStatus("Saving..."); - const result = await pywebview.api.save_modified(); - if (result.startsWith("Error") || result === "Cancelled") { - showToast(result, result === "Cancelled" ? "info" : "error"); - } else { - showToast(result, "success"); - } - setStatus(result); - } catch (e) { - console.error(e); - showToast("Save failed.", "error"); - } -} - -// ========================================== -// MULTI-PAGE SWITCHER -// ========================================== -function setupModifyPages(data) { - modifyPages = Array.isArray(data.pages) ? data.pages : []; - modifyRegionPages = data.regionPages || {}; - modifyActivePageIndex = 0; - const switcher = document.getElementById("modify-page-switcher"); - const repackOptions = document.getElementById("repack-options"); - if (modifyPages.length > 1) { - switcher.classList.remove("hidden"); - updatePageIndicator(); - // Multi-page always repacks all pages; the per-page repack toggle is - // inert here (and toggling it post-merge errors), so hide it. - repackOptions.classList.add("hidden"); - } else { - switcher.classList.add("hidden"); - repackOptions.classList.remove("hidden"); - } -} - -function updatePageIndicator() { - const ind = document.getElementById("page-indicator"); - if (ind) - ind.innerText = `Page ${modifyActivePageIndex + 1} / ${modifyPages.length}`; - document.getElementById("page-prev").disabled = modifyActivePageIndex <= 0; - document.getElementById("page-next").disabled = - modifyActivePageIndex >= modifyPages.length - 1; -} - -async function showModifyPage(index) { - if (index < 0 || index >= modifyPages.length) return; - modifyActivePageIndex = index; - updatePageIndicator(); - try { - const dataUri = await pywebview.api.get_modify_page_preview(index); - if (!dataUri) return; - previewImg.src = dataUri; - previewImg.style.display = "block"; - previewImg.onload = function () { - resetPreview(); - const containerW = previewContainer.clientWidth - 40; - const containerH = previewContainer.clientHeight - 40; - const imgW = previewImg.naturalWidth; - const imgH = previewImg.naturalHeight; - if (imgW > containerW || imgH > containerH) { - viewState.scale = Math.min(containerW / imgW, containerH / imgH); - } - applyTransform(); // redraws overlay (filtered to this page) - previewImg.onload = null; - }; - } catch (e) { - console.error(e); - } -} - -function modifyPagePrev() { - showModifyPage(modifyActivePageIndex - 1); -} -function modifyPageNext() { - showModifyPage(modifyActivePageIndex + 1); -} - -document.getElementById("chk-repack").addEventListener("change", async (e) => { - pywebview.api.set_pref("repack", e.target.checked); - if (!hasModImage) return; - setStatus( - e.target.checked ? "Applying repack..." : "Reverting repack...", - ); - try { - const result = await pywebview.api.toggle_repack(e.target.checked); - if (result) { - onModPreviewReceived(result); - } else { - showToast("No merged data to repack.", "error"); - } - } catch (err) { - console.error(err); - showToast("Repack toggle failed.", "error"); - } -}); - -// ========================================== -// CORE LOGIC -// ========================================== -async function openFile() { - try { - const success = await pywebview.api.choose_file(); - if (success) { - selectedIndices.clear(); - lastClickIndex = -1; - document.getElementById("preview-img").style.display = "none"; - resetPreview(); - updateButtons(); - await loadRegions(); - } - } catch (e) { - console.error(e); - } -} - -async function loadRegions() { - regionsData = await pywebview.api.get_region_names(); - if (!regionsData) return; - document.getElementById("count").innerText = regionsData.length; - renderRegionList(); - if (regionsData.length > 0) { - setStatus("Atlas loaded."); - document.getElementById("btn-extract-all").disabled = false; - } else { - document.getElementById("btn-extract-all").disabled = true; - } - updateModeToggleUI(); -} - -function regionDisplayName(name) { - return modifiedRegionNames.has(name) ? `${name}*` : name; -} - -function renderRegionList() { - const listEl = document.getElementById("region-list"); - listEl.innerHTML = ""; - regionsData.forEach((name, index) => { - const li = document.createElement("li"); - li.className = "region-item"; - if (modifiedRegionNames.has(name)) { - li.classList.add("modified"); - } - li.innerText = regionDisplayName(name); - li.dataset.index = index; - li.addEventListener("mousedown", (e) => onRegionMouseDown(e, index, name)); - li.addEventListener("mouseenter", (e) => onRegionMouseEnter(e, index)); - listEl.appendChild(li); - }); - renderSelection(); -} - -function getSelectedNames() { - return Array.from(selectedIndices) - .sort((a, b) => a - b) - .map((i) => regionsData[i]); -} - -// --- Auto-Scroll State --- -let autoScrollSpeed = 0; -let autoScrollInterval = null; -const SCROLL_ZONE_SIZE = 50; -const MAX_SCROLL_SPEED = 15; -let lastMouseX = 0; -let lastMouseY = 0; - -function onRegionMouseDown(e, index, name) { - if (e.button !== 0) return; - if (e.shiftKey) e.preventDefault(); - - isDragSelecting = true; - dragStartIndex = index; - - if (e.ctrlKey || e.metaKey) { - toggleIndex(index); - lastClickIndex = index; - } else if (e.shiftKey && lastClickIndex !== -1) { - selectRange( - Math.min(lastClickIndex, index), - Math.max(lastClickIndex, index), - false, - ); - } else { - selectedIndices.clear(); - selectedIndices.add(index); - lastClickIndex = index; - } - - renderSelection(); - triggerPreviewUpdate(); - - window.addEventListener("mousemove", onWindowMouseMove); - startAutoScroll(); -} - -function onWindowMouseMove(e) { - if (!isDragSelecting) return; - e.preventDefault(); - - lastMouseX = e.clientX; - lastMouseY = e.clientY; - - const container = document.getElementById("region-list-container"); - const rect = container.getBoundingClientRect(); - - if (e.clientY < rect.top + SCROLL_ZONE_SIZE) { - const dist = Math.max(0, rect.top + SCROLL_ZONE_SIZE - e.clientY); - autoScrollSpeed = -(dist / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; - } else if (e.clientY > rect.bottom - SCROLL_ZONE_SIZE) { - const dist = Math.max(0, e.clientY - (rect.bottom - SCROLL_ZONE_SIZE)); - autoScrollSpeed = (dist / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; - } else { - autoScrollSpeed = 0; - } - - updateSelectionFromMouse(e.clientX, e.clientY); -} - -function updateSelectionFromMouse(clientX, clientY) { - const container = document.getElementById("region-list-container"); - const rect = container.getBoundingClientRect(); - - let checkY = Math.max(rect.top + 1, Math.min(clientY, rect.bottom - 1)); - let checkX = rect.left + rect.width / 2; - - const el = document.elementFromPoint(checkX, checkY); - const item = el?.closest(".region-item"); - - if (item) { - const index = parseInt(item.dataset.index); - if (!isNaN(index)) { - const start = Math.min(dragStartIndex, index); - const end = Math.max(dragStartIndex, index); - - selectedIndices.clear(); - for (let i = start; i <= end; i++) selectedIndices.add(i); - - lastClickIndex = index; - renderSelection(); - triggerPreviewUpdate(); - } - } -} - -// --- Preview Debounce State --- -let previewTimeout = null; -let lastSelectedJSON = "[]"; - -function triggerPreviewUpdate() { - const currentNames = getSelectedNames(); - const currentJSON = JSON.stringify(currentNames); - - if (currentJSON !== lastSelectedJSON) { - lastSelectedJSON = currentJSON; - - if (previewTimeout) clearTimeout(previewTimeout); - - previewTimeout = setTimeout(() => { - if (currentMode === "modify") { - updateModifyPreview(currentNames); - } else { - updatePreview(currentNames); - } - }, 50); - - updateButtons(); - } -} - -function updateModifyPreview(names) { - // Pure client-side: just redraw overlay canvas - drawRegionOverlay(); - if (!names || names.length === 0) { - setStatus( - hasModImage - ? "Mod image merged. Ready to save." - : "Select regions and click Modify Selected", - ); - } else { - setStatus( - hasModImage - ? `Merged preview. ${names.length} region(s) selected.` - : `${names.length} region(s) selected`, - ); - } -} - -function startAutoScroll() { - if (autoScrollInterval) return; - - function scrollLoop() { - if (!isDragSelecting) { - stopAutoScroll(); - return; - } - - if (autoScrollSpeed !== 0) { - const container = document.getElementById("region-list-container"); - container.scrollTop += autoScrollSpeed; - - updateSelectionFromMouse(lastMouseX, lastMouseY); - } - - triggerPreviewUpdate(); - - autoScrollInterval = requestAnimationFrame(scrollLoop); - } - autoScrollInterval = requestAnimationFrame(scrollLoop); -} - -function stopAutoScroll() { - if (autoScrollInterval) { - cancelAnimationFrame(autoScrollInterval); - autoScrollInterval = null; - } - autoScrollSpeed = 0; -} - -function onRegionMouseEnter(e, index) { - // Deprecated in favor of global handler -} - -window.addEventListener("mouseup", () => { - if (isDragSelecting) { - isDragSelecting = false; - stopAutoScroll(); - window.removeEventListener("mousemove", onWindowMouseMove); - if (currentMode === "extract") { - updatePreview(getSelectedNames()); - } else { - updateModifyPreview(getSelectedNames()); - } - updateButtons(); - } - viewState.isDragging = false; -}); - -function toggleIndex(index) { - if (selectedIndices.has(index)) selectedIndices.delete(index); - else selectedIndices.add(index); -} - -function selectRange(start, end, keepExisting) { - if (!keepExisting) selectedIndices.clear(); - for (let i = start; i <= end; i++) selectedIndices.add(i); -} - -function renderSelection() { - const items = document.querySelectorAll(".region-item"); - items.forEach((el, idx) => { - if (selectedIndices.has(idx)) el.classList.add("selected"); - else el.classList.remove("selected"); - }); -} - -const previewContainer = document.getElementById("preview-container"); -const previewImg = document.getElementById("preview-img"); - -function resetPreview() { - viewState = { - scale: 1, - x: 0, - y: 0, - isDragging: false, - startX: 0, - startY: 0, - }; - applyTransform(); -} - -function applyTransform() { - previewImg.style.transform = `translate(calc(-50% + ${viewState.x}px), calc(-50% + ${viewState.y}px)) scale(${viewState.scale})`; - // Redraw overlay if in modify mode - if (currentMode === "modify") { - drawRegionOverlay(); - } -} - -// ========================================== -// REGION OVERLAY (Canvas) -// ========================================== -function drawRegionOverlay() { - const canvas = document.getElementById("region-overlay"); - if (!canvas) return; - const ctx = canvas.getContext("2d"); - - const dpr = window.devicePixelRatio || 1; - const containerW = previewContainer.clientWidth; - const containerH = previewContainer.clientHeight; - canvas.width = containerW * dpr; - canvas.height = containerH * dpr; - canvas.style.width = containerW + "px"; - canvas.style.height = containerH + "px"; - ctx.scale(dpr, dpr); - - ctx.clearRect(0, 0, containerW, containerH); - - if (currentMode !== "modify" || selectedIndices.size === 0) return; - - const imgW = previewImg.naturalWidth; - const imgH = previewImg.naturalHeight; - if (!imgW || !imgH) return; - - // Compute image position on screen - const scale = viewState.scale; - const centerX = containerW / 2 + viewState.x; - const centerY = containerH / 2 + viewState.y; - const displayW = imgW * scale; - const displayH = imgH * scale; - const topLeftX = centerX - displayW / 2; - const topLeftY = centerY - displayH / 2; - - const lineWidth = 3; - const names = getSelectedNames(); - - // On multi-page atlases only draw regions that live on the visible page. - const activePage = - modifyPages.length > 1 ? modifyPages[modifyActivePageIndex] : null; - - for (const name of names) { - if (activePage && modifyRegionPages[name] && modifyRegionPages[name] !== activePage) - continue; - const bounds = modifyOverlayRects[name] || modifyRegionBounds[name]; - if (!bounds) continue; - const [bx, by, bw, bh] = bounds.length >= 5 - ? (() => { - const [x, y, w, h, rotate] = bounds; - const isRotated = rotate === 90 || rotate === 270; - return [x, y, isRotated ? h : w, isRotated ? w : h]; - })() - : bounds; - - const rx = topLeftX + bx * scale; - const ry = topLeftY + by * scale; - const rw = bw * scale; - const rh = bh * scale; - - // Draw rect — expand outward by lineWidth - ctx.strokeStyle = "rgba(255, 60, 60, 0.85)"; - ctx.lineWidth = lineWidth; - ctx.strokeRect( - rx - lineWidth / 2, - ry - lineWidth / 2, - rw + lineWidth, - rh + lineWidth, - ); - - // Draw label above the box - const fontSize = 13; - ctx.font = `bold ${fontSize}px "Segoe UI", sans-serif`; - const textMetrics = ctx.measureText(name); - const textW = textMetrics.width; - const labelX = rx; - const labelY = ry - lineWidth - 2; - - // Label background - ctx.fillStyle = "rgba(255, 60, 60, 0.85)"; - ctx.fillRect(labelX - 1, labelY - fontSize, textW + 8, fontSize + 4); - - // Label text - ctx.fillStyle = "white"; - ctx.fillText(name, labelX + 3, labelY - 1); - } -} - -function clearOverlay() { - const canvas = document.getElementById("region-overlay"); - if (!canvas) return; - const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, canvas.width, canvas.height); -} - -async function updatePreview(names) { - const status = document.getElementById("status-text"); - if (!names || names.length === 0) { - previewImg.style.display = "none"; - status.innerText = "No selection"; - updateButtons(); - return; - } - - const base64Img = await pywebview.api.get_preview(names); - if (base64Img) { - previewImg.src = base64Img; - previewImg.style.display = "block"; - if (names.length === 1) { - status.innerText = `Previewing: ${names[0]}`; - } else { - status.innerText = `Previewing: ${names.length} regions`; - } - - previewImg.onload = function () { - resetPreview(); - const containerW = previewContainer.clientWidth - 40; - const containerH = previewContainer.clientHeight - 40; - const imgW = previewImg.naturalWidth; - const imgH = previewImg.naturalHeight; - - if (names.length === 1) { - status.innerText = `Previewing: ${names[0]} (${imgW}x${imgH})`; - } else { - status.innerText = `Previewing: ${names.length} regions (${imgW}x${imgH})`; - } - - if (imgW > containerW || imgH > containerH) { - const scaleW = containerW / imgW; - const scaleH = containerH / imgH; - viewState.scale = Math.min(scaleW, scaleH); - applyTransform(); - } - updateButtons(); - previewImg.onload = null; - }; - } else { - previewImg.style.display = "none"; - status.innerText = "Preview failed"; - updateButtons(); - } -} - -previewContainer.addEventListener("wheel", (e) => { - e.preventDefault(); - const zoomIntensity = 0.1; - const direction = -Math.sign(e.deltaY); - const newScale = - viewState.scale + direction * zoomIntensity * viewState.scale; - - if (newScale > 0.1 && newScale < 50) { - const rect = previewContainer.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - const mx = e.clientX - rect.left - cx; - const my = e.clientY - rect.top - cy; - - viewState.x = mx - (mx - viewState.x) * (newScale / viewState.scale); - viewState.y = my - (my - viewState.y) * (newScale / viewState.scale); - - viewState.scale = newScale; - applyTransform(); - } -}); - -previewContainer.addEventListener("mousedown", (e) => { - if (e.button !== 0 && e.button !== 1) return; - e.preventDefault(); - viewState.isDragging = true; - viewState.startX = e.clientX - viewState.x; - viewState.startY = e.clientY - viewState.y; -}); - -window.addEventListener("mousemove", (e) => { - if (viewState.isDragging) { - viewState.x = e.clientX - viewState.startX; - viewState.y = e.clientY - viewState.startY; - applyTransform(); - } -}); - -function updateButtons() { - const btnSel = document.getElementById("btn-extract-sel"); - btnSel.disabled = selectedIndices.size === 0; - btnSel.innerText = `Extract Selected (${selectedIndices.size})`; - - const btnModSel = document.getElementById("btn-modify-sel"); - if (btnModSel) { - btnModSel.disabled = selectedIndices.size === 0; - btnModSel.innerText = `Modify Selected (${selectedIndices.size})`; - } - - const btnSaveMerged = document.getElementById("btn-save-merged"); - if (btnSaveMerged) { - const hasPreview = - previewImg.style.display !== "none" && - previewImg.naturalWidth > 0 && - previewImg.naturalHeight > 0; - btnSaveMerged.disabled = !hasPreview; - } -} - -function getPreviewPngBlob() { - const img = previewImg; - if (!img.naturalWidth || !img.naturalHeight) return null; - - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext("2d").drawImage(img, 0, 0); - - return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); -} - -function previewSaveDefaultName(names) { - if (!names || names.length === 0) return "image.png"; - const safe = names.map((n) => n.replace(/[<>:"/\\|?*]/g, "_")); - if (safe.length === 1) return `${safe[0]}.png`; - if (safe.length <= 5) return `${safe.join("+")}.png`; - const more = safe.length - 5; - return `${safe.slice(0, 5).join("+")}+ ${more} more.png`; -} - -async function saveMergedImage() { - try { - const blob = await getPreviewPngBlob(); - if (!blob) { - showToast("No image to save.", "error"); - return; - } - - const reader = new FileReader(); - const dataUrl = await new Promise((resolve, reject) => { - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - const defaultName = previewSaveDefaultName(getSelectedNames()); - const result = await pywebview.api.save_preview_image(dataUrl, defaultName); - if (result === "Cancelled") { - showToast(result, "info"); - } else if (result.startsWith("Error")) { - showToast(result, "error"); - } else { - showToast(result, "success"); - } - } catch (e) { - console.error(e); - showToast("Failed to save image.", "error"); - } -} - -async function extractSelected() { - if (selectedIndices.size === 0) return; - const names = Array.from(selectedIndices).map((i) => regionsData[i]); - setStatus("Extracting..."); - const result = await pywebview.api.extract_files(names); - - showToast(result, result.includes("Error") ? "error" : "success"); - setStatus("Ready"); -} - -async function extractAll() { - if (document.getElementById("count").innerText === "0") return; - - const confirmed = await showConfirm( - "Are you sure you want to extract all regions?", - "Confirm Extraction", - ); - if (!confirmed) return; - - setStatus("Extracting ALL..."); - const result = await pywebview.api.extract_files(null); - - showToast(result, result.includes("Error") ? "error" : "success"); - setStatus("Ready"); -} - -// --- Modal Logic --- -function showConfirm(message, title = "Confirm") { - return new Promise((resolve) => { - const overlay = document.getElementById("modal-overlay"); - const titleEl = document.getElementById("modal-title"); - const msgEl = document.getElementById("modal-message"); - const btnConfirm = document.getElementById("btn-modal-confirm"); - const btnCancel = document.getElementById("btn-modal-cancel"); - - titleEl.innerText = title; - msgEl.innerText = message; - overlay.classList.remove("hidden"); - - if (document.activeElement) document.activeElement.blur(); - - btnConfirm.focus(); - - function cleanup() { - overlay.classList.add("hidden"); - btnConfirm.removeEventListener("click", onConfirm); - btnCancel.removeEventListener("click", onCancel); - window.removeEventListener("keydown", onKey); - } - - function onConfirm() { - cleanup(); - resolve(true); - } - - function onCancel() { - cleanup(); - resolve(false); - } - - function onKey(e) { - if (e.key === "Escape") onCancel(); - } - - btnConfirm.addEventListener("click", onConfirm); - btnCancel.addEventListener("click", onCancel); - window.addEventListener("keydown", onKey); - }); -} - -// --- Toast Logic --- -function showToast(message, type = "info") { - const container = document.getElementById("toast-container"); - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.innerText = message; - - container.appendChild(toast); - - setTimeout(() => { - toast.style.opacity = "1"; - toast.style.transform = "translateY(0)"; - - toast.style.animation = "none"; - toast.offsetHeight; /* trigger reflow */ - - toast.style.animation = "fadeOut 0.5s ease-out forwards"; - - toast.addEventListener("animationend", () => { - toast.remove(); - }); - }, 3000); -} -// ========================================== -// KEYBOARD NAVIGATION (Arrow Keys) -// ========================================== -window.addEventListener("keydown", (e) => { - if (regionsData.length === 0) return; - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; - - e.preventDefault(); - - let newIndex = lastClickIndex; - - if (e.key === "ArrowDown") { - newIndex++; - if (newIndex >= regionsData.length) newIndex = regionsData.length - 1; - } else if (e.key === "ArrowUp") { - newIndex--; - if (newIndex < 0) newIndex = 0; - } - - if (newIndex === lastClickIndex && selectedIndices.size > 0) return; - - if (e.shiftKey) { - if (dragStartIndex === -1) dragStartIndex = lastClickIndex; - - selectedIndices.clear(); - const start = Math.min(dragStartIndex, newIndex); - const end = Math.max(dragStartIndex, newIndex); - for (let i = start; i <= end; i++) selectedIndices.add(i); - } else { - selectedIndices.clear(); - selectedIndices.add(newIndex); - dragStartIndex = newIndex; - } - - lastClickIndex = newIndex; - - renderSelection(); - if (currentMode === "extract") { - updatePreview(getSelectedNames()); - } else { - updateModifyPreview(getSelectedNames()); - } - updateButtons(); - - const item = document.querySelector(`.region-item[data-index="${newIndex}"]`); - if (item) item.scrollIntoView({ block: "nearest" }); -}); - -// ========================================== -// DRAG AND DROP SUPPORT -// ========================================== -const dropOverlay = document.getElementById("drop-overlay"); - -// 1. Global Prevention to stop browser from opening files -["dragover", "drop"].forEach((eventName) => { - window.addEventListener(eventName, (e) => e.preventDefault(), false); -}); - -// 2. Window logic to show/hide overlay -window.addEventListener("dragenter", (e) => { - e.preventDefault(); - if (e.dataTransfer.types.includes("Files")) { - dropOverlay.classList.remove("hidden"); - dropOverlay.style.pointerEvents = "auto"; - } -}); - -// 3. Overlay specific logic -dropOverlay.addEventListener("dragover", (e) => { - e.preventDefault(); -}); - -dropOverlay.addEventListener("dragleave", (e) => { - e.preventDefault(); - if (e.relatedTarget === null || !dropOverlay.contains(e.relatedTarget)) { - dropOverlay.classList.add("hidden"); - dropOverlay.style.pointerEvents = "none"; - } -}); - -dropOverlay.addEventListener("drop", (e) => { - e.preventDefault(); - dropOverlay.classList.add("hidden"); - dropOverlay.style.pointerEvents = "none"; - // Python handler (DOMEventHandler) will continue to process the file -}); - -// Callback called from Python after successful drop loading -window.onAtlasLoadedFromPython = async () => { - // If we were in modify mode, switch back - if (currentMode === "modify") { - setMode("extract"); - modifyRegionBounds = {}; // Clear modify state - modifyOverlayRects = {}; - hasModImage = false; - modifiedRegionNames = new Set(); - } - selectedIndices.clear(); - lastClickIndex = -1; - document.getElementById("preview-img").style.display = "none"; - resetPreview(); - clearOverlay(); // Ensure overlay is cleared - updateButtons(); - await loadRegions(); - showToast("Atlas loaded via drag & drop.", "success"); -}; - -// Callback called from Python after mod image processed via drag-drop -window.onModImageProcessed = (data) => { - if (data) { - onModPreviewReceived(data); - showToast("Mod image loaded via drag & drop.", "success"); - } -}; - -// ========================================== -// CONTEXT MENU (Right-click Copy) -// ========================================== -const contextMenu = document.getElementById("context-menu"); - -previewContainer.addEventListener("contextmenu", (e) => { - e.preventDefault(); - - // Only show in extract mode and when there's a visible preview - if (currentMode !== "extract") return; - if (previewImg.style.display === "none" || !previewImg.src) return; - - contextMenu.style.left = e.clientX + "px"; - contextMenu.style.top = e.clientY + "px"; - contextMenu.classList.remove("hidden"); -}); - -window.addEventListener("click", () => { - contextMenu.classList.add("hidden"); -}); - -window.addEventListener("keydown", (e) => { - if (e.key === "Escape") contextMenu.classList.add("hidden"); -}); - -async function copyPreviewImage() { - contextMenu.classList.add("hidden"); - try { - const blob = await getPreviewPngBlob(); - if (!blob) { - showToast("No image to copy.", "error"); - return; - } - - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); - showToast("Image copied to clipboard.", "success"); - } catch (e) { - console.error(e); - showToast("Failed to copy image.", "error"); - } -} - -// ========================================== -// UPDATE NOTIFICATION (Persistent Toast) -// ========================================== - -function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - let value = bytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - const digits = unitIndex === 0 ? 0 : 1; - return `${value.toFixed(digits)} ${units[unitIndex]}`; -} - -async function openExternalUrl(url, invalidMessage = "Invalid URL.") { - try { - const parsed = new URL(String(url)); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - showToast(invalidMessage, "error"); - return; - } - - if (window.pywebview && pywebview.api && pywebview.api.open_url) { - const result = await pywebview.api.open_url(parsed.toString()); - if (!result || !result.ok) { - showToast((result && result.error) || "Failed to open URL.", "error"); - } - return; - } - - window.open(parsed.toString(), "_blank"); - } catch (err) { - console.error(err); - showToast(invalidMessage, "error"); - } -} - -window.showUpdateNotification = function (...args) { - let payload = null; - - if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) { - payload = args[0]; - } else { - const legacyVersion = String(args[0] || ""); - // Backward compatibility with old signature - payload = { - latestVersion: legacyVersion, - releaseName: String(args[1] || ""), - releaseUrl: String(args[2] || ""), - tagName: legacyVersion - ? legacyVersion.startsWith("v") - ? legacyVersion - : `v${legacyVersion}` - : "", - sourceTreeUrl: String(args[2] || ""), - action: "open_source_tag", - }; - } - - const latestVersion = String(payload.latestVersion || ""); - const releaseName = String(payload.releaseName || latestVersion || "New release"); - const releaseUrl = String(payload.releaseUrl || ""); - const sourceTreeUrl = String(payload.sourceTreeUrl || releaseUrl); - const tagName = String(payload.tagName || latestVersion || ""); - const action = payload.action === "download" ? "download" : "open_source_tag"; - - // Remove any existing update toast first - const existing = document.getElementById("update-toast"); - if (existing) existing.remove(); - - const toast = document.createElement("div"); - toast.id = "update-toast"; - toast.className = "toast-update"; - const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - icon.setAttribute("class", "toast-update-icon"); - icon.setAttribute("viewBox", "0 -960 960 960"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute( - "d", - "M440-320v-326L336-542l-56-58 200-200 200 200-56 58-104-104v326h-80ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z", - ); - icon.appendChild(path); - - const title = document.createElement("span"); - title.className = "toast-update-title"; - title.textContent = `Update available - ${latestVersion}`; - - const sub = document.createElement("span"); - sub.className = "toast-update-sub"; - sub.textContent = releaseName; - - const actionBtn = document.createElement("button"); - actionBtn.className = "toast-update-btn-go"; - - let phase = action === "download" ? "download" : "external"; - let pollTimer = null; - - function stopPolling() { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - } - - async function pollDownloadProgress() { - try { - const progress = await pywebview.api.get_update_download_progress(); - if (!progress) return; - - const status = String(progress.status || "idle"); - if (status === "downloading") { - const total = Number(progress.total_bytes || 0); - const downloaded = Number(progress.downloaded_bytes || 0); - if (total > 0) { - const percent = Number(progress.percent || 0); - sub.textContent = `Downloading... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`; - actionBtn.textContent = `Downloading ${percent}%`; - } else { - sub.textContent = `Downloading... ${formatBytes(downloaded)}`; - actionBtn.textContent = "Downloading..."; - } - } else if (status === "error") { - stopPolling(); - } - } catch (err) { - console.error(err); - } - } - - async function handleDownload() { - actionBtn.disabled = true; - actionBtn.textContent = "Downloading..."; - sub.textContent = "Downloading update package..."; - - stopPolling(); - pollTimer = setInterval(pollDownloadProgress, 350); - - try { - const result = await pywebview.api.download_update(); - stopPolling(); - - if (result && result.ok) { - phase = "restart"; - actionBtn.disabled = false; - actionBtn.textContent = "Restart to Update"; - sub.textContent = `Downloaded v${result.version}. Restart to install.`; - } else { - const message = (result && result.error) || "Update download failed."; - showToast(message, "error"); - phase = "download"; - actionBtn.disabled = false; - actionBtn.textContent = "Download Update"; - sub.textContent = releaseName; - } - } catch (err) { - stopPolling(); - console.error(err); - showToast("Update download failed.", "error"); - phase = "download"; - actionBtn.disabled = false; - actionBtn.textContent = "Download Update"; - sub.textContent = releaseName; - } - } - - async function handleRestartToUpdate() { - actionBtn.disabled = true; - actionBtn.textContent = "Restarting..."; - sub.textContent = "Restarting app to install update..."; - - try { - const result = await pywebview.api.restart_and_install_update(); - if (!result || !result.ok) { - const message = (result && result.error) || "Failed to restart for update."; - showToast(message, "error"); - actionBtn.disabled = false; - actionBtn.textContent = "Restart to Update"; - sub.textContent = releaseName; - } - } catch (err) { - console.error(err); - showToast("Failed to restart for update.", "error"); - actionBtn.disabled = false; - actionBtn.textContent = "Restart to Update"; - sub.textContent = releaseName; - } - } - - actionBtn.addEventListener("click", () => { - if (phase === "external") { - openExternalUrl(sourceTreeUrl, "Invalid source URL."); - return; - } - - if (phase === "download") { - handleDownload(); - return; - } - - if (phase === "restart") { - handleRestartToUpdate(); - } - }); - - if (phase === "external") { - actionBtn.textContent = tagName ? `View Source (${tagName})` : "View Source"; - } else { - actionBtn.textContent = "Download Update"; - } - - const closeBtn = document.createElement("button"); - closeBtn.className = "toast-update-btn-close"; - closeBtn.textContent = "x"; - closeBtn.addEventListener("click", () => { - stopPolling(); - toast.remove(); - }); - - toast.appendChild(icon); - toast.appendChild(title); - toast.appendChild(sub); - toast.appendChild(actionBtn); - toast.appendChild(closeBtn); - - document.getElementById("right-panel").appendChild(toast); -}; - -window.showUpdateInstallFailed = function (payload) { - const info = payload && typeof payload === "object" ? payload : {}; - const message = String( - info.message || "Update installation failed. The app was relaunched.", - ); - const logPath = String(info.logPath || ""); - const releaseUrl = String(info.releaseUrl || ""); - - const existing = document.getElementById("update-failed-toast"); - if (existing) existing.remove(); - - const toast = document.createElement("div"); - toast.id = "update-failed-toast"; - toast.className = "toast-update toast-update-error"; - - const title = document.createElement("span"); - title.className = "toast-update-title"; - title.textContent = "Update failed"; - - const sub = document.createElement("span"); - sub.className = "toast-update-sub"; - sub.textContent = message; - - const logBtn = document.createElement("button"); - logBtn.className = "toast-update-btn-go"; - logBtn.textContent = "Open Log"; - logBtn.disabled = !logPath; - logBtn.addEventListener("click", async () => { - if (!logPath) return; - try { - const result = await pywebview.api.open_update_log(logPath); - if (!result || !result.ok) { - showToast((result && result.error) || "Failed to open log.", "error"); - } - } catch (err) { - console.error(err); - showToast("Failed to open log.", "error"); - } - }); - - const releaseBtn = document.createElement("button"); - releaseBtn.className = "toast-update-btn-go"; - releaseBtn.textContent = "Download Manually"; - releaseBtn.disabled = !releaseUrl; - releaseBtn.addEventListener("click", () => { - if (!releaseUrl) return; - openExternalUrl(releaseUrl, "Invalid release URL."); - }); - - const closeBtn = document.createElement("button"); - closeBtn.className = "toast-update-btn-close"; - closeBtn.textContent = "x"; - closeBtn.addEventListener("click", () => toast.remove()); - - toast.appendChild(title); - toast.appendChild(sub); - toast.appendChild(logBtn); - toast.appendChild(releaseBtn); - toast.appendChild(closeBtn); - - document.getElementById("right-panel").appendChild(toast); -}; diff --git a/ui/style.css b/ui/style.css deleted file mode 100644 index 78a7fb1..0000000 --- a/ui/style.css +++ /dev/null @@ -1,830 +0,0 @@ -:root { - --sidebar-width: 300px; - --panel-header-h: 30px; -} - -body { - margin: 0; - padding: 0; - font-family: "Segoe UI", sans-serif; - height: 100vh; - display: flex; - flex-direction: column; - background-color: #2b2b2b; - color: #eee; - overflow: hidden; - user-select: none; -} - -/* App Bar */ -#app-bar { - height: 42px; - min-height: 42px; - padding: 0; - background-color: #252526; - border-bottom: 1px solid #444; - display: flex; - align-items: stretch; - flex-shrink: 0; - z-index: 10; - box-sizing: border-box; -} - -.app-bar-left { - width: var(--sidebar-width); - min-width: var(--sidebar-width); - padding: 0 10px; - display: flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - flex-shrink: 0; -} - -.app-bar-right { - flex: 1; - min-width: 0; - padding: 0 10px; - display: flex; - align-items: center; - gap: 8px; -} - -#btn-save-mod { - margin-left: auto; - flex-shrink: 0; -} - -#status-text { - font-size: 12px; - color: #aaa; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.app-bar-divider { - width: 1px; - height: 20px; - background-color: #444; - flex-shrink: 0; -} - -.app-bar-panel-divider { - height: auto; - align-self: stretch; - width: 1px; - background-color: #444; - flex-shrink: 0; -} - -.mode-toggle { - display: flex; - flex: 1; - min-width: 0; - border: 1px solid #555; - border-radius: 3px; - overflow: hidden; -} - -.mode-toggle-btn { - flex: 1; - min-width: 0; - height: 26px; - padding: 0 8px; - border: none; - border-radius: 0; - background-color: #3c3c3c; - color: #888; - font-size: 11px; - cursor: pointer; -} - -.mode-toggle-btn.mode-view.active { - background-color: #3d7a8a; - color: white; -} - -.mode-toggle-btn.mode-edit.active { - background-color: #8a7344; - color: white; -} - -.mode-toggle-btn.active { - color: white; -} - -.mode-toggle-btn:disabled { - background-color: #333; - color: #555; - cursor: not-allowed; -} - -.mode-toggle-btn + .mode-toggle-btn { - border-left: 1px solid #555; -} - -/* Main Layout */ -#main-content { - flex: 1; - min-height: 0; - display: flex; -} - -#left-panel { - width: var(--sidebar-width); - min-width: var(--sidebar-width); - background-color: #1e1e1e; - border-right: 1px solid #444; - display: flex; - flex-direction: column; - position: relative; -} - -#sidebar-head, -#repack-options, -#status-bar { - height: var(--panel-header-h); - min-height: var(--panel-header-h); - padding: 0 15px; - background-color: #252526; - border-bottom: 1px solid #444; - display: flex; - align-items: center; - flex-shrink: 0; - box-sizing: border-box; -} - -#sidebar-head { - font-size: 12px; - font-weight: bold; - color: #aaa; -} - -#repack-options { - padding: 0 10px; -} - -#status-bar { - padding: 0 12px; -} - -#right-panel { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - background-color: #333; - position: relative; -} - -/* --- BUTTON STYLES (Shared) --- */ -button { - height: 28px; - padding: 0 12px; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - font-family: "Segoe UI", sans-serif; - font-size: 12px; - border-radius: 3px; - cursor: pointer; - border: 1px solid transparent; - outline: none; - box-sizing: border-box; - transition: all 0.2s; -} - -/* Open / Back buttons (grey) */ -.btn-open { - background-color: #3c3c3c; - color: #ccc; - border-color: #555; -} -.btn-open:hover { - background-color: #4c4c4c; - color: white; - border-color: #666; -} -.btn-open:disabled { - background-color: #333; - color: #666; - cursor: not-allowed; - border-color: #444; -} - -/* Extract buttons (blue) */ -.action-btn { - background-color: #0e639c; - color: white; - white-space: nowrap; - flex-shrink: 0; -} -.action-btn:hover { - background-color: #1177bb; -} -.action-btn:disabled { - background-color: #444; - color: #888; - cursor: not-allowed; - border-color: #444; -} - -/* Save button (green) */ -.btn-save { - background-color: #388e3c; - color: white; -} -.btn-save:hover { - background-color: #4caf50; -} -.btn-save:disabled { - background-color: #444; - color: #888; - cursor: not-allowed; - border-color: #444; -} - -.btn-reset { - background-color: #5d4037; - color: white; -} -.btn-reset:hover:not(:disabled) { - background-color: #795548; -} -.btn-reset:disabled { - background-color: #444; - color: #888; - cursor: not-allowed; - border-color: #444; -} - -/* Icon Style */ -.btn-icon { - width: 16px; - height: 16px; - fill: currentColor; -} - -/* List Area */ -#region-list-container { - flex: 1; - overflow-y: auto; -} -#region-list { - list-style: none; - padding: 0; - margin: 0; - padding-bottom: 20px; -} -.region-item { - padding: 6px 15px; - font-size: 13px; - cursor: pointer; - border-bottom: 1px solid #2a2a2a; - user-select: none; -} -.region-item:hover { - background-color: #2a2d2e; -} - -.region-item.modified { - font-weight: 700; - color: #89cfff; -} - -/* Highlight Styles */ -.region-item.selected { - background-color: #094771; - color: white; -} - -.region-item.modified.selected { - font-weight: 700; - color: #b8e8ff; -} - -#extract-controls, -#modify-controls { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - overflow: hidden; - flex: 1; -} - -#btn-save-merged { - margin-left: auto; - flex-shrink: 0; -} - -.btn-save-merged { - background-color: #388e3c; - color: white; -} - -.btn-save-merged:hover:not(:disabled) { - background-color: #4caf50; -} - -.btn-save-merged:disabled { - background-color: #444; - color: #888; - cursor: not-allowed; -} - -#modify-page-switcher { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} - -.toggle-label { - display: inline-flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 12px; - color: #bbb; - user-select: none; -} - -.toggle-label input[type="checkbox"] { - display: none; -} - -.toggle-switch { - position: relative; - width: 32px; - height: 18px; - background-color: #555; - border-radius: 9px; - transition: background-color 0.2s; - flex-shrink: 0; -} - -.toggle-switch::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 14px; - height: 14px; - background-color: #ccc; - border-radius: 50%; - transition: - transform 0.2s, - background-color 0.2s; -} - -.toggle-label input:checked + .toggle-switch { - background-color: #0e639c; -} - -.toggle-label input:checked + .toggle-switch::after { - transform: translateX(14px); - background-color: white; -} - -.info-icon { - width: 14px; - height: 14px; - fill: #777; - flex-shrink: 0; - cursor: help; -} - -.toggle-label:hover .info-icon { - fill: #aaa; -} - -/* Preview Area */ -#preview-container { - flex: 1; - min-height: 0; - overflow: hidden; - background-image: conic-gradient( - #222 90deg, - #333 90deg 180deg, - #222 180deg 270deg, - #333 270deg - ); - background-size: 20px 20px; - position: relative; - cursor: grab; - display: flex; - align-items: center; - justify-content: center; -} -#preview-container:active { - cursor: grabbing; -} - -img#preview-img { - position: absolute; - top: 50%; - left: 50%; - max-width: none; - max-height: none; - border: 1px solid #555; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - display: none; - transform-origin: center center; - transition: transform 0.05s ease-out; - image-rendering: optimizeQuality; -} - -#region-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 5; -} - - -/* Toast Notification */ -#toast-container { - position: absolute; - bottom: 20px; - left: 20px; - display: flex; - flex-direction: column; - gap: 10px; - z-index: 1000; - pointer-events: none; -} - -#toast-container .toast { - pointer-events: auto; -} -.toast { - background-color: #333; - color: #fff; - padding: 12px 20px; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-left: 4px solid #0e639c; /* Blue accent */ - font-size: 14px; - animation: slideIn 0.3s ease-out forwards; - opacity: 0; - transform: translateY(20px); - max-width: 300px; -} -.toast.success { - border-left-color: #4caf50; -} -.toast.error { - border-left-color: #f44336; -} - -@keyframes slideIn { - to { - opacity: 1; - transform: translateY(0); - } -} -@keyframes fadeOut { - to { - opacity: 0; - transform: translateY(-10px); - } -} - -/* Scrollbar Styling */ -::-webkit-scrollbar { - width: 10px; - height: 10px; - background-color: #1e1e1e; -} -::-webkit-scrollbar-thumb { - background-color: #555; - border-radius: 5px; -} -::-webkit-scrollbar-thumb:hover { - background-color: #666; -} -::-webkit-scrollbar-corner { - background-color: #1e1e1e; -} - -/* Multi-page switcher */ -#page-indicator { - font-size: 12px; - color: #ccc; - white-space: nowrap; - min-width: 64px; - text-align: center; -} -.page-nav-btn { - height: 24px; - width: 24px; - padding: 0; - font-size: 16px; - line-height: 1; - background-color: #3c3c3c; - color: #ccc; - border-color: #555; -} -.page-nav-btn:hover:not(:disabled) { - background-color: #4c4c4c; - color: white; - border-color: #666; -} -.page-nav-btn:disabled { - background-color: #333; - color: #555; - cursor: not-allowed; - border-color: #444; -} - -/* Modal Styling */ -.hidden { - display: none !important; -} - -#modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 2000; - display: flex; - justify-content: center; - align-items: center; - backdrop-filter: blur(2px); -} - -#modal-box { - background-color: #252526; - padding: 24px; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - min-width: 320px; - max-width: 400px; - border: 1px solid #454545; - animation: modalPop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes modalPop { - from { - transform: scale(0.9); - opacity: 0; - } - to { - transform: scale(1); - opacity: 1; - } -} - -#modal-title { - margin-top: 0; - margin-bottom: 10px; - color: #ddd; - font-size: 18px; -} - -#modal-message { - color: #bbb; - margin-bottom: 24px; - line-height: 1.5; -} - -.modal-buttons { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.btn-primary, -.btn-secondary { - padding: 8px 16px; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-size: 14px; - font-weight: 500; - transition: background-color 0.2s; -} - -.btn-primary { - background-color: #0e639c; - color: white; -} -.btn-primary:hover { - background-color: #1177bb; -} -.btn-primary:active { - background-color: #094771; -} - -.btn-secondary { - background-color: #3c3c3c; - color: #ccc; - border: 1px solid #555; -} -.btn-secondary:hover { - background-color: #4c4c4c; - color: white; -} -.btn-secondary:active { - background-color: #2d2d2d; -} - -/* Context Menu */ -#context-menu { - position: fixed; - background-color: #2d2d2d; - border: 1px solid #454545; - border-radius: 6px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - padding: 4px 0; - z-index: 4000; - min-width: 160px; -} - -.context-menu-item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 14px; - font-size: 13px; - color: #ccc; - cursor: pointer; - transition: background-color 0.1s; -} - -.context-menu-item:hover { - background-color: #094771; - color: white; -} - -.context-menu-item .btn-icon { - width: 14px; - height: 14px; -} - -/* Drop Overlay Styling */ -#drop-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(14, 99, 156, 0.85); - z-index: 3000; - display: flex; - justify-content: center; - align-items: center; - backdrop-filter: blur(8px); - pointer-events: none; -} - -#drop-overlay:not(.hidden) { - display: flex !important; - pointer-events: auto; -} - -.drop-message { - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - color: white; - pointer-events: none; -} - -.drop-message svg { - width: 80px; - height: 80px; - fill: white; - animation: bounce 1s infinite alternate; -} - -@keyframes bounce { - from { - transform: translateY(0); - } - to { - transform: translateY(-15px); - } -} - -.drop-message span { - font-size: 24px; - font-weight: bold; -} - - -/* ========================================== - UPDATE NOTIFICATION BAR (Bottom of right panel) - ========================================== */ - -.toast-update { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - background-color: #1e2a38; - border-top: 1px solid #2d6a9f; - font-size: 12px; - animation: slideUpBar 0.25s ease-out forwards; - flex-shrink: 0; -} - -@keyframes slideUpBar { - from { - opacity: 0; - transform: translateY(100%); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.toast-update-icon { - width: 16px; - height: 16px; - fill: #4fc3f7; - flex-shrink: 0; -} - -.toast-update-title { - font-weight: 600; - color: #e0f0ff; - white-space: nowrap; -} - -.toast-update-sub { - color: #7bafd4; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 1; -} - -.toast-update-btn-go { - background-color: #0e639c; - color: white; - border: none; - border-radius: 3px; - padding: 0 10px; - height: 22px; - font-size: 11px; - font-family: "Segoe UI", sans-serif; - cursor: pointer; - transition: background-color 0.15s; - white-space: nowrap; - flex-shrink: 0; - margin-left: auto; -} -.toast-update-btn-go:hover { - background-color: #1177bb; -} - -.toast-update-btn-go:disabled { - background-color: #4a4a4a; - color: #999; - cursor: not-allowed; -} - -.toast-update-btn-close { - background: transparent; - color: #ff6464; - border: 1px solid #ff6464; - padding: 0 6px; - height: 22px; - font-size: 11px; - cursor: pointer; - border-radius: 3px; - transition: color 0.15s, background-color 0.15s; - white-space: nowrap; - flex-shrink: 0; -} -.toast-update-btn-close:hover { - color: #e0f0ff; - background-color: rgba(255, 255, 255, 0.08); -} - -.toast-update-error { - background-color: #3a1f1f; - border-top: 1px solid #a94442; -} - -.toast-update-error .toast-update-title { - color: #ffd6d6; -} - -.toast-update-error .toast-update-sub { - color: #ffb3b3; -} \ No newline at end of file From 5606f2687225a0bea3624a6455d8e614632337e6 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:27:33 +0700 Subject: [PATCH 16/18] feat(app): missing-page modal, mod guards, preview cache, and --debug Replace alert() for missing atlas PNGs with a modal that accepts PNG-only row-targeted drops. Confirm before discarding unsaved modify work; keep region selection across view/edit toggles. Serve modify previews via a temp-file cache instead of base64. Enable pywebview debug from source with --debug only. Co-authored-by: Cursor --- atlas_toolkit/app/bridge.py | 198 ++++++++--- atlas_toolkit/app/launch.py | 28 +- atlas_toolkit/app/preview_cache.py | 67 ++++ atlas_toolkit/app/session.py | 529 ++++++++++++++++++++++------- atlas_toolkit/atlas/modifier.py | 138 +++++--- atlas_toolkit/atlas/repacker.py | 45 ++- atlas_toolkit/paths.py | 10 + ui/css/components.css | 23 +- ui/css/overlays.css | 120 +++++++ ui/index.html | 1 + ui/js/atlas-load.js | 16 + ui/js/drag-drop.js | 5 + ui/js/extract.js | 36 +- ui/js/missing-images.js | 341 +++++++++++++++++++ ui/js/mode.js | 14 +- ui/js/modify.js | 28 +- ui/js/preview.js | 6 +- ui/js/ui.js | 24 +- 18 files changed, 1319 insertions(+), 310 deletions(-) create mode 100644 atlas_toolkit/app/preview_cache.py create mode 100644 ui/js/missing-images.js diff --git a/atlas_toolkit/app/bridge.py b/atlas_toolkit/app/bridge.py index 566d760..f7df3c0 100644 --- a/atlas_toolkit/app/bridge.py +++ b/atlas_toolkit/app/bridge.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import json import logging import os @@ -12,33 +11,26 @@ import time import webbrowser import webview -from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Any, List, Optional +from typing import Any, List, Optional from urllib.parse import urlparse from atlas_toolkit.app.config import AppConfig +from atlas_toolkit.app.preview_cache import PreviewCache from atlas_toolkit.app.session import AtlasSession, ModifyResult, ModifyViewData from atlas_toolkit.update.controller import UpdateController from atlas_toolkit.update.updater import get_current_version -if TYPE_CHECKING: - from PIL.Image import Image - log = logging.getLogger(__name__) IMAGE_EXTENSIONS = {".png"} -def _image_to_base64(img: Image) -> str: - buffered = BytesIO() - img.save(buffered, format="PNG") - return f"data:image/png;base64,{base64.b64encode(buffered.getvalue()).decode('utf-8')}" - - -def _modify_view_to_payload(view: ModifyViewData) -> dict[str, object]: +def _modify_view_to_payload( + view: ModifyViewData, cache: PreviewCache +) -> dict[str, object]: payload: dict[str, object] = { - "image": _image_to_base64(view.image), + "image": cache.store_image(view.image, "modify_page_0"), "regions": view.regions, "overlayRects": view.overlay_rects, "pages": view.pages, @@ -51,9 +43,21 @@ def _modify_view_to_payload(view: ModifyViewData) -> dict[str, object]: return payload -def _modify_result_to_payload(result: ModifyResult) -> dict[str, object]: +def _modify_result_to_payload( + result: ModifyResult, cache: PreviewCache +) -> dict[str, object]: + page_key = "modify_page_0" + extra = result.extra or {} + preview_page = extra.get("previewPage") + pages = extra.get("pages") + if isinstance(pages, list) and pages and isinstance(preview_page, str): + try: + page_key = f"modify_page_{pages.index(preview_page)}" + except ValueError: + pass + payload: dict[str, object] = { - "image": _image_to_base64(result.image), + "image": cache.store_image(result.image, page_key), "regions": result.regions, "overlayRects": result.overlay_rects, "modifiedRegions": result.modified_regions, @@ -71,6 +75,7 @@ def __init__(self, pending_update_failure: Optional[dict[str, str]] = None) -> N self._session = AtlasSession() self._config = AppConfig() self._updates = UpdateController(pending_failure=pending_update_failure) + self._preview_cache = PreviewCache() def set_window(self, window: webview.Window) -> None: self._window = window @@ -81,6 +86,19 @@ def get_pref(self, key: str, default: Any = None) -> Any: def set_pref(self, key: str, value: Any) -> None: self._config.set(key, value) + def has_pending_modifications(self) -> bool: + return self._session.has_pending_modifications() + + def _confirm_discard_modifications(self) -> bool: + if not self._session.has_pending_modifications(): + return True + if not self._window: + return False + return self._window.create_confirmation_dialog( + "Discard modifications?", + "You have unsaved atlas modifications. Continue and discard them?", + ) + def startup_check(self) -> bool: time.sleep(0.5) threading.Thread(target=self._run_update_check, daemon=True).start() @@ -131,6 +149,50 @@ def choose_file(self) -> bool: return self.load_atlas(result[0]) return False + def pick_page_image(self, page_name: str, default_dir: str = "") -> Optional[str]: + if not self._window: + return None + file_types = ("PNG images (*.png)",) + result = self._window.create_file_dialog( + webview.FileDialog.OPEN, + allow_multiple=False, + file_types=file_types, + directory=default_dir or None, + ) + if not result: + return None + path = result[0] + if not path.lower().endswith(".png"): + return None + return path + + def _prompt_missing_page_images( + self, missing_pages: list[str], atlas_dir: str + ) -> Optional[dict[str, str]]: + if not self._window or not missing_pages: + return {} + pages_json = json.dumps(missing_pages) + atlas_dir_json = json.dumps(atlas_dir) + holder: dict[str, object] = {} + done = threading.Event() + + def on_result(value: object) -> None: + holder["value"] = value + done.set() + + self._window.evaluate_js( + f"showMissingAtlasImages({pages_json}, {atlas_dir_json})", + on_result, + ) + if not done.wait(timeout=600.0): + return None + value = holder.get("value") + if value is None: + return None + if not isinstance(value, dict): + return None + return {str(k): str(v) for k, v in value.items()} + def load_atlas(self, path_str: str) -> bool: log.debug("load_atlas received path: %r", path_str) if not self._window: @@ -140,31 +202,30 @@ def load_atlas(self, path_str: str) -> bool: atlas_path = Path(path_str) content = atlas_path.read_text(encoding="utf-8") page_images: dict[str, Path] = {} + resolved_pages = self._session.resolve_page_images(atlas_path, content) - for page_name, resolved in self._session.resolve_page_images( - atlas_path, content - ).items(): + for page_name, resolved in resolved_pages.items(): if resolved is not None: page_images[page_name] = resolved - continue - self._window.evaluate_js( - f"alert('Image \\\"{page_name}\\\" not found. Please locate it.')" - ) - file_types = ( - f"{Path(page_name).stem} (*{Path(page_name).suffix})", - "All files (*.*)", + missing_pages = [ + name for name, resolved in resolved_pages.items() if resolved is None + ] + if missing_pages: + selected = self._prompt_missing_page_images( + missing_pages, str(atlas_path.parent) ) - result = self._window.create_file_dialog( - webview.FileDialog.OPEN, - allow_multiple=False, - file_types=file_types, - directory=str(atlas_path.parent), - ) - if result: - page_images[page_name] = Path(result[0]) - else: - self._window.evaluate_js("alert('Load cancelled.')") + if selected is None: + return False + for page_name, image_path in selected.items(): + page_images[page_name] = Path(image_path) + + still_missing = [ + name + for name in self._session.required_page_names(content) + if name not in page_images + ] + if still_missing: return False self._session.load(atlas_path, page_images) @@ -173,7 +234,8 @@ def load_atlas(self, path_str: str) -> bool: ) return True except Exception as e: - self._window.evaluate_js(f"alert('Error: {str(e)}')") + msg = json.dumps(f"Error: {e}") + self._window.evaluate_js(f"showToast({msg}, 'error')") return False def get_region_names(self) -> List[str]: @@ -181,18 +243,20 @@ def get_region_names(self) -> List[str]: def get_preview(self, names: List[str]) -> Optional[str]: img = self._session.get_preview_image(names) - return _image_to_base64(img) if img else None + if img is None: + return None + return self._preview_cache.store_image(img, "view_preview") - def save_preview_image( - self, png_data_url: str, default_filename: str = "merged.png" + def save_preview( + self, region_names: List[str], default_filename: str = "merged.png" ) -> str: + """Save the composited extract preview for *region_names* via a save dialog.""" if not self._window: return "Error: Window not ready." - try: - b64 = png_data_url.split(",", 1)[1] if "," in png_data_url else png_data_url - data = base64.b64decode(b64) - except Exception as e: - return f"Error: Invalid image data ({e})." + + img = self._session.get_preview_image(region_names) + if img is None: + return "Error: No image to save." default_dir = ( str(self._session.atlas_path.parent) if self._session.atlas_path else "" @@ -205,10 +269,11 @@ def save_preview_image( if not result: return "Cancelled" try: - Path(result[0]).write_bytes(data) + Path(result[0]).parent.mkdir(parents=True, exist_ok=True) + img.save(result[0], format="PNG") return f"Saved to {result[0]}" except Exception as e: - log.error("Save preview image error: %s", e) + log.error("Save preview error: %s", e) return f"Error: {e}" def extract_files(self, region_names: Optional[List[str]]) -> str: @@ -259,18 +324,19 @@ def enter_modify_mode(self) -> Optional[dict[str, object]]: view = self._session.enter_modify_mode() if view is not None: log.debug("Entered modify mode") - return _modify_view_to_payload(view) + return _modify_view_to_payload(view, self._preview_cache) return None def reset_modify_mode(self) -> Optional[dict[str, object]]: view = self._session.reset_modify_mode() if view is not None: log.debug("Reset modify mode to original atlas") - return _modify_view_to_payload(view) + return _modify_view_to_payload(view, self._preview_cache) return None def exit_modify_mode(self) -> None: self._session.exit_modify_mode() + self._preview_cache.clear() log.debug("Exited modify mode") def select_mod_image( @@ -301,11 +367,13 @@ def process_mod_image( if self._window: self._window.evaluate_js("showToast('Error processing mod image.', 'error')") return None - return _modify_result_to_payload(result) + return _modify_result_to_payload(result, self._preview_cache) def get_modify_page_preview(self, index: int) -> Optional[str]: img = self._session.get_modify_page_image(index) - return _image_to_base64(img) if img else None + if img is None: + return None + return self._preview_cache.store_image(img, f"modify_page_{index}") def save_modified(self) -> str: if not self._session.has_merged_output() or not self._window: @@ -329,7 +397,7 @@ def save_modified(self) -> str: def toggle_repack(self, repack: bool) -> Optional[dict[str, object]]: result = self._session.toggle_repack(repack) - return _modify_result_to_payload(result) if result else None + return _modify_result_to_payload(result, self._preview_cache) if result else None def debug_log(self, msg: str) -> None: log.debug("JS: %s", msg) @@ -346,9 +414,27 @@ def on_drop(self, e: Any) -> None: return path_lower = path.lower() + + if self._window: + missing_open = self._window.evaluate_js( + "typeof isMissingDialogOpen === 'function' && isMissingDialogOpen()" + ) + if missing_open: + if not path_lower.endswith(".png"): + return + path_json = json.dumps(path) + client_x = e.get("clientX") + client_y = e.get("clientY") + if client_x is not None and client_y is not None: + self._window.evaluate_js( + f"applyMissingImageDrop({path_json}, {client_x}, {client_y})" + ) + return + if path_lower.endswith(".atlas"): - if self.load_atlas(path) and self._window: - self._window.evaluate_js("onAtlasLoadedFromPython()") + if self._window: + path_json = json.dumps(path) + self._window.evaluate_js(f"requestLoadAtlas({path_json})") elif any(path_lower.endswith(ext) for ext in IMAGE_EXTENSIONS): if self._session.modifier: self._handle_image_drop(path) @@ -418,12 +504,12 @@ def setup_drop(window: webview.Window, api: Api) -> None: try: from webview.dom import DOMEventHandler - def _no_op(e: Any) -> None: + def _drag_over_no_op(_e: Any) -> None: pass log.debug("Binding drop events...") doc = window.dom.document - doc.events.dragover += DOMEventHandler(_no_op, True, True, debounce=500) # type: ignore[operator] + doc.events.dragover += DOMEventHandler(_drag_over_no_op, True, True) # type: ignore[operator] doc.events.drop += DOMEventHandler(api.on_drop, True, True) # type: ignore[operator] log.debug("Drop events bound.") except Exception as e: diff --git a/atlas_toolkit/app/launch.py b/atlas_toolkit/app/launch.py index 1b7eb97..888cb77 100644 --- a/atlas_toolkit/app/launch.py +++ b/atlas_toolkit/app/launch.py @@ -9,22 +9,29 @@ import webview from atlas_toolkit.app.bridge import Api, setup_drop -from atlas_toolkit.paths import resource_path +from atlas_toolkit.paths import is_source_run, resource_path from atlas_toolkit.update.updater import get_current_version log = logging.getLogger(__name__) -def _consume_launch_flags(argv: list[str]) -> tuple[list[str], Optional[dict[str, str]]]: +def _consume_launch_flags( + argv: list[str], +) -> tuple[list[str], Optional[dict[str, str]], bool]: clean_args: list[str] = [] failed = False failed_log = "" failed_release_url = "" failed_message = "" + debug = False i = 0 while i < len(argv): arg = argv[i] + if arg == "--debug": + debug = True + i += 1 + continue if arg == "--update-install-failed": failed = True i += 1 @@ -51,7 +58,7 @@ def _consume_launch_flags(argv: list[str]) -> tuple[list[str], Optional[dict[str "logPath": failed_log, "releaseUrl": failed_release_url, } - return clean_args, payload + return clean_args, payload, debug def configure_stdio() -> None: @@ -73,9 +80,15 @@ def run() -> None: configure_stdio() configure_logging() - clean_argv, pending_failure = _consume_launch_flags(sys.argv[1:]) + clean_argv, pending_failure, debug_requested = _consume_launch_flags(sys.argv[1:]) sys.argv = [sys.argv[0], *clean_argv] + webview_debug = debug_requested and is_source_run() + if debug_requested and not webview_debug: + log.info("--debug is only available when running from source; ignored.") + elif webview_debug: + log.info("pywebview debug mode enabled (--debug).") + if sys.platform == "win32": try: import ctypes @@ -116,7 +129,12 @@ def run() -> None: if window: api.set_window(window) + + def on_closing() -> bool: + return api._confirm_discard_modifications() + + window.events.closing += on_closing else: sys.exit(1) - webview.start(func=setup_drop, args=(window, api)) + webview.start(func=setup_drop, args=(window, api), debug=webview_debug) diff --git a/atlas_toolkit/app/preview_cache.py b/atlas_toolkit/app/preview_cache.py new file mode 100644 index 0000000..b5f17df --- /dev/null +++ b/atlas_toolkit/app/preview_cache.py @@ -0,0 +1,67 @@ +"""Write preview PNGs to disk and return file:// URLs for the WebView.""" + +from __future__ import annotations + +import atexit +import os +import tempfile +from pathlib import Path + +from PIL.Image import Image + +from atlas_toolkit.app.config import get_config_dir + + +class PreviewCache: + """Full-resolution preview files — avoids base64 through the pywebview bridge.""" + + def __init__(self) -> None: + cache_root = get_config_dir() / "preview_cache" + cache_root.mkdir(parents=True, exist_ok=True) + self._session_dir = tempfile.TemporaryDirectory( + prefix="session_", + dir=cache_root, + ) + atexit.register(self._session_dir.cleanup) + self._dir = Path(self._session_dir.name) + self._generation = 0 + self._paths_by_key: dict[str, Path] = {} + + @property + def directory(self) -> Path: + return self._dir + + def store_image(self, img: Image, key: str = "preview") -> str: + """Save *img* to a managed temp file and return a cache-busted file URI.""" + safe_key = "".join(c if c.isalnum() or c in "._-" else "_" for c in key) + old_path = self._paths_by_key.pop(safe_key, None) + if old_path is not None: + try: + old_path.unlink() + except OSError: + pass + self._generation += 1 + fd, raw_path = tempfile.mkstemp( + suffix=".png", + prefix=f"{safe_key}_", + dir=self._dir, + ) + path = Path(raw_path) + try: + os.close(fd) + img.save(path, format="PNG", compress_level=1) + except Exception: + path.unlink(missing_ok=True) + raise + self._paths_by_key[safe_key] = path + return f"{path.resolve().as_uri()}?v={self._generation}" + + def clear(self) -> None: + """Drop all tracked preview files for the current session.""" + self._generation += 1 + for path in self._paths_by_key.values(): + try: + path.unlink() + except OSError: + pass + self._paths_by_key.clear() diff --git a/atlas_toolkit/app/session.py b/atlas_toolkit/app/session.py index 83d67a4..935c718 100644 --- a/atlas_toolkit/app/session.py +++ b/atlas_toolkit/app/session.py @@ -6,10 +6,10 @@ import shutil from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from atlas_toolkit.atlas.converter import auto_convert_atlas -from atlas_toolkit.core.document import AtlasDocument +from atlas_toolkit.core.document import AtlasDocument, Page from atlas_toolkit.atlas.extracter import AtlasProcessor from atlas_toolkit.atlas.modifier import AtlasModifier, parse_atlas from atlas_toolkit.atlas.repacker import repack_multi_page @@ -21,6 +21,18 @@ log = logging.getLogger(__name__) +PreparedMod = Tuple["Image", int, int, bool] + + +@dataclass +class ModBatch: + """One mod apply: region names, source file, and optional prepared image cache.""" + + names: tuple[str, ...] + path: Path + shared_canvas: bool = False + prepared: Optional[PreparedMod] = None + @dataclass class ModifyViewData: @@ -38,7 +50,7 @@ class ModifyViewData: @dataclass class ModifyResult: - """Result of merge/repack before bridge encodes image to base64.""" + """Result of merge/repack before bridge encodes preview for JS.""" image: Image atlas_text: str @@ -60,7 +72,23 @@ def __init__(self) -> None: self.merged_pages: Optional[List[Image]] = None self.pre_repack_image: Optional[Image] = None self.pre_repack_text: Optional[str] = None - self.modified_regions: set[str] = set() + self.pre_repack_pages: Optional[List[Image]] = None + self.repacked_image: Optional[Image] = None + self.repacked_text: Optional[str] = None + self.repacked_pages: Optional[List[Image]] = None + self.modded_sprites: dict[str, Image] = {} + self.mod_batches: list[ModBatch] = [] + self.modifications_saved: bool = False + + @property + def modified_regions(self) -> set[str]: + return set(self.modded_sprites.keys()) + + @modified_regions.setter + def modified_regions(self, value: set[str]) -> None: + if not value: + self.modded_sprites.clear() + self.mod_batches.clear() @property def is_loaded(self) -> bool: @@ -94,7 +122,13 @@ def clear_modify_state(self) -> None: self.merged_pages = None self.pre_repack_image = None self.pre_repack_text = None - self.modified_regions = set() + self.pre_repack_pages = None + self.repacked_image = None + self.repacked_text = None + self.repacked_pages = None + self.modded_sprites = {} + self.mod_batches = [] + self.modifications_saved = False def get_region_names(self) -> List[str]: if not self.processor: @@ -161,13 +195,19 @@ def build_modify_view(self, *, clear_modified: bool = False) -> Optional[ModifyV return None if clear_modified: - self.modified_regions = set() + self.modded_sprites = {} + self.mod_batches = [] + self.modifications_saved = False self.merged_image = None self.merged_atlas_text = None self.merged_pages = None self.pre_repack_image = None self.pre_repack_text = None + self.pre_repack_pages = None + self.repacked_image = None + self.repacked_text = None + self.repacked_pages = None self.modifier = AtlasModifier( auto_convert_atlas(atlas_text), self.atlas_path, base_image @@ -204,135 +244,341 @@ def reset_modify_mode(self) -> Optional[ModifyViewData]: def exit_modify_mode(self) -> None: self.clear_modify_state() + def _is_multi_page(self) -> bool: + return self.processor is not None and len(self.processor.pages) > 1 + + def _full_canvas_regions(self) -> set[str]: + regions: set[str] = set() + for batch in self.mod_batches: + if batch.shared_canvas: + regions.update(batch.names) + return regions + + def _invalidate_merge_cache(self) -> None: + self.pre_repack_image = None + self.pre_repack_text = None + self.pre_repack_pages = None + + def _invalidate_repack_cache(self) -> None: + self.repacked_image = None + self.repacked_text = None + self.repacked_pages = None + + def _register_mod_batch( + self, mod_path: Path, selected_names: List[str] + ) -> Optional[ModBatch]: + """Record one mod apply and store prepared sprites for repack rebuild.""" + if not selected_names or not self.atlas_path: + return None + names = tuple(selected_names) + batch = ModBatch(names=names, path=mod_path) + + if self._is_multi_page(): + page_images, atlas_text = self._get_original_page_images_and_text() + doc = AtlasDocument.parse(atlas_text) + selected_set = set(selected_names) + for page in doc.pages: + page_img = page_images.get(page.filename) + if page_img is None: + continue + page_selected = [ + r.name for r in page.regions if r.name in selected_set + ] + if not page_selected: + continue + page_text = AtlasDocument(pages=[page]).serialize() + modifier = AtlasModifier( + page_text, self.atlas_path, page_img.copy() + ) + prepared = modifier._prepare_mod_image(mod_path, page_selected) + mod_img = prepared[0] + if prepared[3]: + batch.shared_canvas = True + for name in page_selected: + self.modded_sprites[name] = mod_img + self.mod_batches.append(batch) + self.modifications_saved = False + return batch + + modifier = self._fresh_single_page_modifier() + if modifier is None: + return None + prepared = modifier._prepare_mod_image(mod_path, selected_names) + batch.prepared = prepared + batch.shared_canvas = prepared[3] + mod_img = prepared[0] + for name in selected_names: + self.modded_sprites[name] = mod_img + self.mod_batches.append(batch) + self.modifications_saved = False + return batch + + def _rebuild_single_page_merge(self) -> tuple[Image, str]: + modifier = self._fresh_single_page_modifier() + if modifier is None: + raise RuntimeError("No single-page modifier") + for batch in self.mod_batches: + if batch.prepared is not None: + merged, text = modifier.merge_mod_image( + batch.path, + list(batch.names), + prepared_mod=batch.prepared, + ) + else: + merged, text = modifier.merge_mod_image( + batch.path, list(batch.names) + ) + modifier.adopt_merge_result(merged, text) + return modifier.base_image, modifier.atlas_text + + def _rebuild_single_page_repack(self) -> tuple[Image, str]: + modifier = self._fresh_single_page_modifier() + if modifier is None: + raise RuntimeError("No single-page modifier") + return modifier.repack_with_modded_sprites( + self.modded_sprites, + full_canvas_regions=self._full_canvas_regions(), + ) + + def _rebuild_multi_page_merge(self) -> tuple[List[Image], str]: + if not self.processor or not self.atlas_path: + return [], "" + + page_images, atlas_text = self._get_original_page_images_and_text() + + for batch in self.mod_batches: + selected_set = set(batch.names) + current_doc = AtlasDocument.parse(atlas_text) + updated_pages: list[Page] = [] + updated_images: dict[str, Image] = {} + + for page in current_doc.pages: + page_img = page_images.get(page.filename) + if page_img is None: + updated_pages.append(page) + continue + + page_selected = [ + r.name for r in page.regions if r.name in selected_set + ] + if not page_selected: + updated_pages.append(page) + updated_images[page.filename] = page_img.copy() + continue + + page_text = AtlasDocument(pages=[page]).serialize() + modifier = AtlasModifier( + page_text, self.atlas_path, page_img.copy() + ) + merged_img, merged_page_text = modifier.merge_mod_image( + batch.path, page_selected + ) + updated_pages.append( + AtlasDocument.parse(merged_page_text).pages[0] + ) + updated_images[page.filename] = merged_img + + atlas_text = AtlasDocument(pages=updated_pages).serialize() + page_images = { + p.filename: updated_images[p.filename] + for p in updated_pages + if p.filename in updated_images + } + + ordered_images = [ + page_images[p.filename] + for p in AtlasDocument.parse(atlas_text).pages + if p.filename in page_images + ] + return ordered_images, atlas_text + + def _rebuild_multi_page_repack(self) -> tuple[List[Image], str]: + if not self.processor or not self.atlas_path: + return [], "" + + page_images, atlas_text = self._get_original_page_images_and_text() + doc = AtlasDocument.parse(atlas_text) + sprites: dict[str, Image] = {} + + for page in doc.pages: + page_img = page_images.get(page.filename) + if page_img is None: + continue + page_text = AtlasDocument(pages=[page]).serialize() + _, _, regions = parse_atlas(page_text) + for name, region in regions.items(): + sprites[name] = extract_raw_sprite(page_img, region) + + for name, sprite in self.modded_sprites.items(): + if name in sprites: + sprites[name] = sprite + + _, _, regions = parse_atlas(atlas_text) + region_metas = { + name: region.to_meta_dict() for name, region in regions.items() + } + return repack_multi_page( + sprites, + len(self.processor.pages), + self._page_infos_from_processor(), + region_metas, + full_canvas_regions=self._full_canvas_regions(), + ) + + def _apply_active_result( + self, repack: bool, image: Image, text: str + ) -> ModifyResult: + if repack: + self.repacked_image = image + self.repacked_text = text + self.merged_image = image + self.merged_atlas_text = text + self.merged_pages = None + self.pre_repack_image = None + self.pre_repack_text = None + self.pre_repack_pages = None + else: + self.pre_repack_image = image + self.pre_repack_text = text + self.merged_image = image + self.merged_atlas_text = text + self.merged_pages = None + self.repacked_image = None + self.repacked_text = None + self.repacked_pages = None + return self._build_modify_result(image, text) + + def _apply_active_multi_result( + self, repack: bool, pages: List[Image], text: str + ) -> ModifyResult: + if repack: + self.repacked_pages = pages + self.repacked_text = text + self.merged_pages = pages + self.merged_atlas_text = text + self.merged_image = None + self.pre_repack_pages = None + self.pre_repack_text = None + self.pre_repack_image = None + else: + self.pre_repack_pages = pages + self.pre_repack_text = text + self.merged_pages = pages + self.merged_atlas_text = text + self.merged_image = None + self.repacked_pages = None + self.repacked_text = None + self.repacked_image = None + return self._build_multi_page_modify_result(pages, text) + def process_mod_image( self, path_str: str, selected_names: List[str], repack: bool = False ) -> Optional[ModifyResult]: if not self.modifier: return None - if self.processor and len(self.processor.pages) > 1: - return self._process_mod_multi_page(path_str, selected_names) - + prev_batches = list(self.mod_batches) + prev_sprites = dict(self.modded_sprites) try: mod_path = Path(path_str) log.debug("Processing mod image: %s", mod_path) + if self._register_mod_batch(mod_path, selected_names) is None: + return None - merged_image, merged_atlas_text = self.modifier.merge_mod_image( - mod_path, selected_names - ) - - self.pre_repack_image = merged_image - self.pre_repack_text = merged_atlas_text + if self._is_multi_page(): + if repack: + self._invalidate_merge_cache() + pages, text = self._rebuild_multi_page_repack() + else: + self._invalidate_repack_cache() + pages, text = self._rebuild_multi_page_merge() + if not pages or not text: + raise RuntimeError("Multi-page rebuild produced empty result") + return self._apply_active_multi_result(repack, pages, text) if repack: - log.debug("Running repack...") - merged_image, merged_atlas_text = self.modifier.repack( - merged_image, merged_atlas_text - ) - - self.merged_image = merged_image - self.merged_atlas_text = merged_atlas_text - self.modified_regions.update(selected_names) - self.modifier.adopt_merge_result( - self.pre_repack_image, self.pre_repack_text - ) - - return self._build_modify_result(merged_image, merged_atlas_text) + self._invalidate_merge_cache() + image, text = self._rebuild_single_page_repack() + else: + self._invalidate_repack_cache() + image, text = self._rebuild_single_page_merge() + return self._apply_active_result(repack, image, text) except Exception as e: + self.mod_batches = prev_batches + self.modded_sprites = prev_sprites log.error("Processing mod image: %s", e) return None - def _extract_sprites_from_merged_pages(self) -> dict[str, Image]: - if not self.merged_pages or not self.merged_atlas_text: - return {} - - page_names = AtlasDocument.parse(self.merged_atlas_text).page_filenames() - page_images = { - name: self.merged_pages[i] - for i, name in enumerate(page_names) - if i < len(self.merged_pages) - } - - _, _, regions = parse_atlas(self.merged_atlas_text) - sprites: dict[str, Image] = {} - for name, region in regions.items(): - page_img = page_images.get(region.page_filename) - if page_img is not None: - sprites[name] = extract_raw_sprite(page_img, region) - return sprites + def _get_original_page_images_and_text(self) -> tuple[dict[str, Image], str]: + """Always read page images and atlas text from the loaded original.""" + if not self.processor or not self.atlas_path: + return {}, "" - def _process_mod_multi_page( - self, path_str: str, selected_names: List[str] - ) -> Optional[ModifyResult]: - if not self.processor: + atlas_text = auto_convert_atlas( + self.atlas_path.read_text(encoding="utf-8") + ) + page_images: dict[str, Image] = {} + for page in self.processor.pages: + img = self.processor.get_page_image(page.filename) + if img is not None: + page_images[page.filename] = img + return page_images, atlas_text + + def _fresh_single_page_modifier(self) -> Optional[AtlasModifier]: + if not self.processor or not self.atlas_path: return None + atlas_text = auto_convert_atlas( + self.atlas_path.read_text(encoding="utf-8") + ) + base_image = self.processor.get_page_image() + if base_image is None: + return None + return AtlasModifier(atlas_text, self.atlas_path, base_image.copy()) - try: - from PIL import Image - - if self.merged_pages and self.merged_atlas_text: - all_sprites = self._extract_sprites_from_merged_pages() - else: - all_sprites = {} - for name in self.processor.regions: - sprite = self.processor.extract_region(name) - if sprite is not None: - all_sprites[name] = sprite - - mod_img = Image.open(Path(path_str)).convert("RGBA") - for name in selected_names: - if name in all_sprites: - all_sprites[name] = mod_img - - page_infos: list[dict[str, object]] = [ - { - "page": p.filename, - "format": p.format, - "filter": f"{p.filter[0]}, {p.filter[1]}", - "repeat": p.repeat, - "pma": p.pma, - } - for p in self.processor.pages - ] - region_metas: dict[str, dict[str, object]] = { - name: r.to_meta_dict() for name, r in self.processor.regions.items() + def _page_infos_from_processor(self) -> list[dict[str, object]]: + if not self.processor: + return [] + return [ + { + "page": p.filename, + "format": p.format, + "filter": f"{p.filter[0]}, {p.filter[1]}", + "repeat": p.repeat, + "pma": p.pma, } + for p in self.processor.pages + ] - pages, atlas_text = repack_multi_page( - all_sprites, len(self.processor.pages), page_infos, region_metas - ) + def _get_multi_page_images_and_text(self) -> tuple[dict[str, Image], str]: + return self._get_original_page_images_and_text() - self.merged_pages = pages - self.merged_atlas_text = atlas_text - self.merged_image = None - self.pre_repack_image = None - self.pre_repack_text = None - self.modified_regions.update(selected_names) + def _build_multi_page_modify_result( + self, pages: List[Image], atlas_text: str + ) -> ModifyResult: + _, _, merged_regions = parse_atlas(atlas_text) + region_bounds: dict[str, list[int]] = {} + region_pages: dict[str, str] = {} + for name, region in merged_regions.items(): + region_bounds[name] = region.bounds_with_rotate() + region_pages[name] = region.page_filename - _, _, merged_regions = parse_atlas(atlas_text) - region_bounds: dict[str, list[int]] = {} - region_pages: dict[str, str] = {} - for name, region in merged_regions.items(): - region_bounds[name] = region.bounds_with_rotate() - region_pages[name] = region.page_filename - - preview = pages[0] if pages else Image.new("RGBA", (1, 1)) - return ModifyResult( - image=preview, - atlas_text=atlas_text, - regions=region_bounds, - overlay_rects=overlay_rects_for_regions(merged_regions), - modified_regions=sorted(self.modified_regions), - extra={ - "regionPages": region_pages, - "pages": [str(pi["page"]) for pi in page_infos], - "pageCount": len(pages), - "previewPage": str(page_infos[0]["page"]) if page_infos else None, - }, - ) - except Exception as e: - log.error("Processing multi-page mod image: %s", e) - return None + page_names = AtlasDocument.parse(atlas_text).page_filenames() + from PIL import Image + + preview = pages[0] if pages else Image.new("RGBA", (1, 1)) + return ModifyResult( + image=preview, + atlas_text=atlas_text, + regions=region_bounds, + overlay_rects=overlay_rects_for_regions(merged_regions), + modified_regions=sorted(self.modified_regions), + extra={ + "regionPages": region_pages, + "pages": page_names, + "pageCount": len(pages), + "previewPage": page_names[0] if page_names else None, + }, + ) def _build_modify_result( self, image: Image, atlas_text: str, extra: Optional[dict[str, object]] = None @@ -366,6 +612,10 @@ def get_modify_page_image(self, index: int) -> Optional[Image]: log.error("get_modify_page_preview: %s", e) return None + def has_pending_modifications(self) -> bool: + """True when unsaved modify-mode changes would be lost on exit or reload.""" + return bool(self.mod_batches) and not self.modifications_saved + def has_merged_output(self) -> bool: if self.merged_atlas_text is None: return False @@ -384,6 +634,8 @@ def save_merged_to(self, output_dir: Path) -> None: else: raise RuntimeError("No merged data to save") + self.modifications_saved = True + def _save_multi_page(self, output_dir: Path) -> None: if not self.processor or not self.atlas_path or self.merged_pages is None: return @@ -406,26 +658,57 @@ def _save_multi_page(self, output_dir: Path) -> None: shutil.copy(skel_path, output_dir / skel_path.name) def toggle_repack(self, repack: bool) -> Optional[ModifyResult]: - if not self.modifier or not self.pre_repack_image or not self.pre_repack_text: + if not self.mod_batches: return None try: + if self._is_multi_page(): + return self._toggle_repack_multi_page(repack) + if repack: - log.debug("Applying repack...") - image, text = self.modifier.repack( - self.pre_repack_image, self.pre_repack_text - ) + if self.repacked_image is None or self.repacked_text is None: + log.debug("Lazy-building repacked result for toggle") + image, text = self._rebuild_single_page_repack() + self.repacked_image = image + self.repacked_text = text + image, text = self.repacked_image, self.repacked_text else: - log.debug("Reverting to pre-repack merge result") - image = self.pre_repack_image - text = self.pre_repack_text + if self.pre_repack_image is None or self.pre_repack_text is None: + log.debug("Lazy-building merge result for toggle") + image, text = self._rebuild_single_page_merge() + self.pre_repack_image = image + self.pre_repack_text = text + image, text = self.pre_repack_image, self.pre_repack_text self.merged_image = image self.merged_atlas_text = text - self.modifier.adopt_merge_result( - self.pre_repack_image, self.pre_repack_text - ) + self.merged_pages = None return self._build_modify_result(image, text) except Exception as e: log.error("toggle_repack: %s", e) return None + + def _toggle_repack_multi_page(self, repack: bool) -> Optional[ModifyResult]: + try: + if repack: + if self.repacked_pages is None or not self.repacked_text: + log.debug("Lazy-building multi-page repacked result for toggle") + pages, text = self._rebuild_multi_page_repack() + self.repacked_pages = pages + self.repacked_text = text + pages, text = self.repacked_pages, self.repacked_text + else: + if self.pre_repack_pages is None or not self.pre_repack_text: + log.debug("Lazy-building multi-page merge result for toggle") + pages, text = self._rebuild_multi_page_merge() + self.pre_repack_pages = pages + self.pre_repack_text = text + pages, text = self.pre_repack_pages, self.pre_repack_text + + self.merged_pages = pages + self.merged_atlas_text = text + self.merged_image = None + return self._build_multi_page_modify_result(pages, text) + except Exception as e: + log.error("toggle_repack multi-page: %s", e) + return None diff --git a/atlas_toolkit/atlas/modifier.py b/atlas_toolkit/atlas/modifier.py index c9a3a11..773ea1a 100644 --- a/atlas_toolkit/atlas/modifier.py +++ b/atlas_toolkit/atlas/modifier.py @@ -21,7 +21,8 @@ Region, UpdatedRegionData, ) -from atlas_toolkit.atlas.repacker import repack_single_page +from atlas_toolkit.atlas.repacker import repack_from_sprites, repack_single_page +from atlas_toolkit.core.region_ops import extract_raw_sprite def parse_atlas( @@ -303,7 +304,11 @@ def _selected_share_canvas(self, selected_regions: List[str]) -> bool: # ------------------------------------------------------------------ # def merge_mod_image( - self, mod_image_path: Path, selected_regions: List[str] + self, + mod_image_path: Path, + selected_regions: List[str], + *, + prepared_mod: Optional[Tuple[Image.Image, int, int, bool]] = None, ) -> Tuple[Image.Image, str]: """ Merges a mod image onto the base atlas canvas for the selected regions. @@ -317,55 +322,14 @@ def merge_mod_image( if not selected_regions: raise ValueError("No regions selected for modification") - mod_img = Image.open(mod_image_path).convert("RGBA") - - base_w, base_h = self.base_image.size - mod_w, mod_h = mod_img.size - - logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") - - # offsets format: [left, bottom, originalWidth, originalHeight] (Spine spec) - ( - orig_canvas_w, - orig_canvas_h, - base_orig_w, - base_orig_h, - off_x_orig, - off_y_orig, - is_full_canvas, - ) = self._resolve_mod_canvas(selected_regions, mod_w, mod_h) - - if is_full_canvas: - logging.info("Mod image treated as full canvas replacement") - - # Pad trimmed sprite mods onto the logical canvas. - # Full-canvas mods (combined multi-region sheets, or mod ≈ canvas size) - # are pasted at (0, 0) — trim offsets only apply to partial sprites. - if not is_full_canvas and ( - mod_w != orig_canvas_w or mod_h != orig_canvas_h - ): - scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 - scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 - paste_x = round(off_x_orig * scale_x) - paste_y = orig_canvas_h - mod_h - round(off_y_orig * scale_y) - logging.info( - f"Padding mod image to canvas: " - f"{orig_canvas_w}x{orig_canvas_h} at ({paste_x}, {paste_y})" - ) - padded_mod = Image.new( - "RGBA", (orig_canvas_w, orig_canvas_h), (0, 0, 0, 0) + if prepared_mod is not None: + mod_img, mod_w, mod_h, shared_canvas_mod = prepared_mod + else: + mod_img, mod_w, mod_h, shared_canvas_mod = self._prepare_mod_image( + mod_image_path, selected_regions ) - padded_mod.paste(mod_img, (paste_x, paste_y)) - mod_img = padded_mod - mod_w, mod_h = orig_canvas_w, orig_canvas_h - shared_canvas_mod = ( - is_full_canvas and self._selected_share_canvas(selected_regions) - ) - if shared_canvas_mod: - logging.info( - "Shared logical canvas: all selected regions use one atlas area" - ) + base_w, base_h = self.base_image.size # --- Find the best placement --- best = self._find_best_placement( @@ -427,6 +391,80 @@ def merge_mod_image( return merged, new_atlas_text + def _prepare_mod_image( + self, mod_image_path: Path, selected_regions: List[str] + ) -> Tuple[Image.Image, int, int, bool]: + """Load and pad a mod image for the selected regions' logical canvas.""" + mod_img = Image.open(mod_image_path).convert("RGBA") + + base_w, base_h = self.base_image.size + mod_w, mod_h = mod_img.size + + logging.info(f"Base: {base_w}x{base_h}, Mod: {mod_w}x{mod_h}") + + ( + orig_canvas_w, + orig_canvas_h, + base_orig_w, + base_orig_h, + off_x_orig, + off_y_orig, + is_full_canvas, + ) = self._resolve_mod_canvas(selected_regions, mod_w, mod_h) + + if is_full_canvas: + logging.info("Mod image treated as full canvas replacement") + + if not is_full_canvas and ( + mod_w != orig_canvas_w or mod_h != orig_canvas_h + ): + scale_x = (orig_canvas_w / base_orig_w) if base_orig_w > 0 else 1 + scale_y = (orig_canvas_h / base_orig_h) if base_orig_h > 0 else 1 + paste_x = round(off_x_orig * scale_x) + paste_y = orig_canvas_h - mod_h - round(off_y_orig * scale_y) + logging.info( + f"Padding mod image to canvas: " + f"{orig_canvas_w}x{orig_canvas_h} at ({paste_x}, {paste_y})" + ) + padded_mod = Image.new( + "RGBA", (orig_canvas_w, orig_canvas_h), (0, 0, 0, 0) + ) + padded_mod.paste(mod_img, (paste_x, paste_y)) + mod_img = padded_mod + mod_w, mod_h = orig_canvas_w, orig_canvas_h + + shared_canvas_mod = ( + is_full_canvas and self._selected_share_canvas(selected_regions) + ) + if shared_canvas_mod: + logging.info( + "Shared logical canvas: all selected regions use one atlas area" + ) + + return mod_img, mod_w, mod_h, shared_canvas_mod + + def repack_with_modded_sprites( + self, + modded_sprites: Dict[str, Image.Image], + *, + full_canvas_regions: Optional[set[str]] = None, + ) -> Tuple[Image.Image, str]: + """Unpack base sprites, overlay *modded_sprites*, then shelf-pack.""" + sprites: Dict[str, Image.Image] = { + name: extract_raw_sprite(self.base_image, region) + for name, region in self.regions.items() + } + logging.info("Repack: extracted %s sprites from base", len(sprites)) + for name, sprite in modded_sprites.items(): + if name in sprites: + sprites[name] = sprite + result = repack_from_sprites( + sprites, + self.atlas_text, + full_canvas_regions=full_canvas_regions, + ) + return result.image, result.atlas_text + def save(self, output_dir: Path, merged_image: Image.Image, atlas_text: str) -> Path: """ Save the merged PNG, updated atlas text, and copy .skel if it exists. @@ -459,6 +497,6 @@ def repack( merged_image: Image.Image, atlas_text: str, ) -> Tuple[Image.Image, str]: - """Repack all regions into an optimally-sized canvas.""" + """Repack all regions from an already-merged canvas (legacy helper).""" result = repack_single_page(merged_image, atlas_text) return result.image, result.atlas_text \ No newline at end of file diff --git a/atlas_toolkit/atlas/repacker.py b/atlas_toolkit/atlas/repacker.py index fb3b45d..8ca2cb7 100644 --- a/atlas_toolkit/atlas/repacker.py +++ b/atlas_toolkit/atlas/repacker.py @@ -165,7 +165,26 @@ def repack_single_page( name: extract_raw_sprite(merged_image, region) for name, region in regions.items() } - log.info("Repack: extracted %s sprites", len(sprites)) + return repack_from_sprites( + sprites, atlas_text, page_info=page_info, deduplicate=deduplicate + ) + + +def repack_from_sprites( + sprites: Dict[str, Image.Image], + atlas_text: str, + *, + page_info: Optional[Dict[str, object]] = None, + deduplicate: bool = True, + full_canvas_regions: Optional[set[str]] = None, +) -> RepackSingleResult: + """Shelf-pack *sprites* and emit updated single-page atlas text.""" + if page_info is None: + page_info, region_names, regions = _parse_atlas(atlas_text) + else: + _, region_names, regions = _parse_atlas(atlas_text) + + log.info("Repack: packing %s sprites", len(sprites)) if deduplicate: canonical_map = _deduplicate_sprites(sprites, region_names) @@ -188,6 +207,7 @@ def repack_single_page( ) region_data: Dict[str, tuple] = {} + full_canvas = full_canvas_regions or set() for name in region_names: if name not in canonical_map: continue @@ -197,15 +217,27 @@ def repack_single_page( orig_sprite = sprites[name] bounds = (px, py, orig_sprite.width, orig_sprite.height) info = regions[name] + if name in full_canvas: + offsets: Optional[Tuple[int, int, int, int]] = ( + 0, + 0, + orig_sprite.width, + orig_sprite.height, + ) + else: + offsets = info.offsets region_data[name] = ( bounds, - info.offsets, + offsets, rotate_val, info.to_meta_dict(), ) new_atlas_text = AtlasDocument.from_rebuild_args( - page_info, (canvas_w, canvas_h), region_names, region_data + page_info or _parse_atlas(atlas_text)[0], + (canvas_w, canvas_h), + region_names, + region_data, ).serialize() return RepackSingleResult(image=canvas, atlas_text=new_atlas_text) @@ -220,8 +252,11 @@ def repack_multi_page( num_pages: int, page_infos: List[Dict[str, object]], region_metas: Dict[str, Dict[str, object]], + *, + full_canvas_regions: Optional[set[str]] = None, ) -> Tuple[List[Image.Image], str]: """Repack sprites across multiple pages; emit canonical atlas via AtlasDocument.""" + full_canvas = full_canvas_regions or set() sprite_names = list(all_sprites.keys()) if not sprite_names or num_pages == 0: return [], "" @@ -286,6 +321,9 @@ def repack_multi_page( sprite = all_sprites[name] meta = region_metas.get(name, {}) rotate_val = 90 if rotated else 0 + offsets: Optional[Tuple[int, int, int, int]] = None + if name in full_canvas: + offsets = (0, 0, sprite.width, sprite.height) page_regions.append( Region( name=name, @@ -296,6 +334,7 @@ def repack_multi_page( w=sprite.width, h=sprite.height, rotate=rotate_val, + offsets=offsets, index=int(meta["index"]) if isinstance(meta.get("index"), int) else -1, split=list(meta["split"]) if isinstance(meta.get("split"), (list, tuple)) else None, pad=list(meta["pad"]) if isinstance(meta.get("pad"), (list, tuple)) else None, diff --git a/atlas_toolkit/paths.py b/atlas_toolkit/paths.py index 28a83b8..86e1edc 100644 --- a/atlas_toolkit/paths.py +++ b/atlas_toolkit/paths.py @@ -13,5 +13,15 @@ def app_root() -> Path: return Path(__file__).resolve().parent.parent +def is_source_run() -> bool: + """True when launched via Python from the repo (not a packaged executable).""" + if getattr(sys, "frozen", False): + return False + main = sys.modules.get("__main__") + if main is not None and getattr(main, "__compiled__", None) is not None: + return False + return True + + def resource_path(relative: str) -> Path: return app_root() / relative diff --git a/ui/css/components.css b/ui/css/components.css index b0a40f9..892186b 100644 --- a/ui/css/components.css +++ b/ui/css/components.css @@ -111,7 +111,7 @@ button { .region-item.modified { font-weight: 700; - color: #89cfff; + color: #81c784; } /* Highlight Styles */ @@ -122,7 +122,7 @@ button { .region-item.modified.selected { font-weight: 700; - color: #b8e8ff; + color: #a5d6a7; } #extract-controls, @@ -249,7 +249,7 @@ img#preview-img { left: 50%; max-width: none; max-height: none; - border: 1px solid #555; + border: 2px solid #555; box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); display: none; transform-origin: center center; @@ -271,11 +271,15 @@ img#preview-img { position: absolute; bottom: 20px; left: 20px; + right: 20px; display: flex; flex-direction: column; + align-items: flex-start; gap: 10px; z-index: 1000; pointer-events: none; + max-width: calc(100% - 40px); + box-sizing: border-box; } #toast-container .toast { @@ -284,15 +288,20 @@ img#preview-img { .toast { background-color: #333; color: #fff; - padding: 12px 20px; - border-radius: 4px; + padding: 12px 16px; + border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); border-left: 4px solid #0e639c; /* Blue accent */ - font-size: 14px; + font-size: 13px; + line-height: 1.45; animation: slideIn 0.3s ease-out forwards; opacity: 0; transform: translateY(20px); - max-width: 300px; + width: fit-content; + max-width: min(520px, 100%); + box-sizing: border-box; + word-break: break-word; + overflow-wrap: anywhere; } .toast.success { border-left-color: #4caf50; diff --git a/ui/css/overlays.css b/ui/css/overlays.css index 385944e..2138ec1 100644 --- a/ui/css/overlays.css +++ b/ui/css/overlays.css @@ -88,6 +88,126 @@ .btn-secondary:active { background-color: #2d2d2d; } + +.btn-primary:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; +} + +.missing-images-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.55); + z-index: 2600; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(2px); +} + +.missing-images-overlay.missing-images-file-drag { + cursor: not-allowed; +} + +.missing-images-overlay.missing-images-file-drag .missing-images-dialog { + cursor: not-allowed; +} + +.missing-images-overlay.missing-images-file-drag .missing-images-row.missing-images-drop-target { + cursor: copy; +} + +.missing-images-dialog { + width: min(680px, calc(100vw - 24px)); + max-height: min(80vh, 760px); + overflow: hidden; + background-color: #252526; + border: 1px solid #454545; + border-radius: 8px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; + animation: modalPop 0.2s ease-out; +} + +.missing-images-title { + margin: 0; + font-size: 18px; + color: #e5e5e5; +} + +.missing-images-subtitle { + margin: 0; + font-size: 13px; + color: #b7b7b7; +} + +.missing-images-list { + display: flex; + flex-direction: column; + gap: 8px; + overflow: auto; + min-width: 0; +} + +.missing-images-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px; + border: 1px solid #3c3c3c; + border-radius: 6px; + background-color: #2b2b2b; +} + +.missing-images-row.selected { + border-color: #0e639c; +} + +.missing-images-row.drag-over { + border-color: #0e639c; + border-style: dashed; + background-color: #28415a; +} + +.missing-images-page { + color: #e4e4e4; + font-size: 13px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.missing-images-status { + color: #a6a6a6; + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.missing-images-row.selected .missing-images-status { + color: #d8ecff; +} + +.missing-images-btn { + height: 30px; +} + +.missing-images-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + /* Context Menu */ #context-menu { position: fixed; diff --git a/ui/index.html b/ui/index.html index 21990a5..da556a2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -381,6 +381,7 @@ + diff --git a/ui/js/atlas-load.js b/ui/js/atlas-load.js index 1d11e67..b94ff06 100644 --- a/ui/js/atlas-load.js +++ b/ui/js/atlas-load.js @@ -1,5 +1,7 @@ // Atlas Toolkit UI module async function openFile() { + const ok = await confirmDiscardModifications(); + if (!ok) return; try { const success = await pywebview.api.choose_file(); if (success) { @@ -28,3 +30,17 @@ async function loadRegions() { } updateModeToggleUI(); } + +async function requestLoadAtlas(path) { + const ok = await confirmDiscardModifications(); + if (!ok) return; + try { + const success = await pywebview.api.load_atlas(path); + if (success) { + await onAtlasLoadedFromPython(); + } + } catch (e) { + console.error(e); + } +} +window.requestLoadAtlas = requestLoadAtlas; diff --git a/ui/js/drag-drop.js b/ui/js/drag-drop.js index d2d9a1f..ed51ef1 100644 --- a/ui/js/drag-drop.js +++ b/ui/js/drag-drop.js @@ -9,6 +9,7 @@ var dropOverlay = document.getElementById("drop-overlay"); // 2. Window logic to show/hide overlay window.addEventListener("dragenter", (e) => { e.preventDefault(); + if (typeof isMissingDialogOpen === "function" && isMissingDialogOpen()) return; if (e.dataTransfer.types.includes("Files")) { dropOverlay.classList.remove("hidden"); dropOverlay.style.pointerEvents = "auto"; @@ -32,6 +33,10 @@ dropOverlay.addEventListener("drop", (e) => { e.preventDefault(); dropOverlay.classList.add("hidden"); dropOverlay.style.pointerEvents = "none"; + if (typeof isMissingDialogOpen === "function" && isMissingDialogOpen()) { + e.stopPropagation(); + return; + } // Python handler (DOMEventHandler) will continue to process the file }); diff --git a/ui/js/extract.js b/ui/js/extract.js index dd68f5b..13ffe57 100644 --- a/ui/js/extract.js +++ b/ui/js/extract.js @@ -1,16 +1,4 @@ // Atlas Toolkit UI module -function getPreviewPngBlob() { - const img = previewImg; - if (!img.naturalWidth || !img.naturalHeight) return null; - - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext("2d").drawImage(img, 0, 0); - - return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); -} - function previewSaveDefaultName(names) { if (!names || names.length === 0) return "image.png"; const safe = names.map((n) => n.replace(/[<>:"/\\|?*]/g, "_")); @@ -21,22 +9,15 @@ function previewSaveDefaultName(names) { } async function saveMergedImage() { - try { - const blob = await getPreviewPngBlob(); - if (!blob) { - showToast("No image to save.", "error"); - return; - } - - const reader = new FileReader(); - const dataUrl = await new Promise((resolve, reject) => { - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); + const names = getSelectedNames(); + if (!names.length) { + showToast("No regions selected.", "error"); + return; + } - const defaultName = previewSaveDefaultName(getSelectedNames()); - const result = await pywebview.api.save_preview_image(dataUrl, defaultName); + try { + const defaultName = previewSaveDefaultName(names); + const result = await pywebview.api.save_preview(names, defaultName); if (result === "Cancelled") { showToast(result, "info"); } else if (result.startsWith("Error")) { @@ -75,4 +56,3 @@ async function extractAll() { showToast(result, result.includes("Error") ? "error" : "success"); setStatus("Ready"); } - diff --git a/ui/js/missing-images.js b/ui/js/missing-images.js new file mode 100644 index 0000000..d122bfb --- /dev/null +++ b/ui/js/missing-images.js @@ -0,0 +1,341 @@ +// Missing atlas page image picker (modal) — mirrors js-project dialogs.js + +function isMissingDialogOpen() { + return document.body.dataset.missingDialogOpen === "true"; +} +window.isMissingDialogOpen = isMissingDialogOpen; + +function formatSelectedImageLabel(path, pageName) { + if (!path) return "No image selected"; + const parts = String(path).split(/[/\\]/); + const name = parts[parts.length - 1]; + if (!name) return `Selected for ${pageName}`; + if (/^\d+(\.[a-z0-9]+)?$/i.test(name)) return "Selected"; + return name; +} + +let _missingDialogState = null; + +function isFileDragEvent(e) { + const types = e.dataTransfer?.types; + if (!types) return false; + return [...types].includes("Files"); +} + +function fileItemDragHint(item) { + if (item.kind !== "file") return "skip"; + if (item.type === "image/png") return "png"; + if (item.type && item.type !== "image/png") return "blocked"; + try { + const entry = item.webkitGetAsEntry?.() ?? item.getAsEntry?.(); + if (entry?.isFile && entry.name) { + return /\.png$/i.test(entry.name) ? "png" : "blocked"; + } + } catch (_) { + /* entry name unavailable during drag */ + } + return "unknown"; +} + +/** During drag: png | blocked | none — default blocked when type cannot be verified. */ +function missingDragFileHint(e) { + if (!isFileDragEvent(e)) return "none"; + const types = [...e.dataTransfer.types]; + if (types.includes("image/png")) return "png"; + + let sawPng = false; + let sawUnknown = false; + try { + for (const item of e.dataTransfer.items) { + const hint = fileItemDragHint(item); + if (hint === "skip") continue; + if (hint === "blocked") return "blocked"; + if (hint === "png") { + sawPng = true; + } else if (hint === "unknown") { + sawUnknown = true; + } + } + } catch (_) { + /* items unavailable during drag on some hosts */ + } + + if (sawPng && !sawUnknown) return "png"; + return "blocked"; +} + +function missingImageRowAt(clientX, clientY) { + if (typeof clientX !== "number" || typeof clientY !== "number") return null; + const el = document.elementFromPoint(clientX, clientY); + return el ? el.closest(".missing-images-row") : null; +} + +function clearMissingDragUi() { + if (!_missingDialogState) return; + const { overlay, rowByPage } = _missingDialogState; + overlay.classList.remove( + "missing-images-file-drag", + "missing-images-type-blocked", + "missing-images-drop-ok" + ); + for (const [, row] of rowByPage) { + row.classList.remove("missing-images-drop-target", "drag-over"); + } + _missingDialogState.dragDepth = 0; + _missingDialogState.dropAllowed = false; +} + +function updateMissingDragUi(e) { + if (!_missingDialogState) return; + const { overlay, rowByPage } = _missingDialogState; + const hint = missingDragFileHint(e); + if (hint === "none") { + clearMissingDragUi(); + return; + } + + overlay.classList.add("missing-images-file-drag"); + overlay.classList.toggle("missing-images-type-blocked", hint !== "png"); + + const row = missingImageRowAt(e.clientX, e.clientY); + const rowAllowed = !!row && hint === "png"; + _missingDialogState.dropAllowed = rowAllowed; + + for (const [, r] of rowByPage) { + const onTarget = r === row && rowAllowed; + r.classList.toggle("missing-images-drop-target", onTarget); + r.classList.toggle("drag-over", onTarget); + } + + overlay.classList.toggle("missing-images-drop-ok", rowAllowed); + + if (!rowAllowed) { + e.dataTransfer.dropEffect = "none"; + } else { + e.dataTransfer.dropEffect = "copy"; + } +} + +function bindMissingImagesDragLayer(overlay, rowByPage) { + let dragDepth = 0; + + const onDragEnter = (e) => { + if (!isFileDragEvent(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragDepth += 1; + _missingDialogState.dragDepth = dragDepth; + updateMissingDragUi(e); + }; + + const onDragOver = (e) => { + if (!isFileDragEvent(e)) return; + e.preventDefault(); + updateMissingDragUi(e); + }; + + const onDragLeave = (e) => { + if (!isFileDragEvent(e)) return; + e.stopPropagation(); + if (e.relatedTarget && overlay.contains(e.relatedTarget)) return; + dragDepth = Math.max(0, dragDepth - 1); + _missingDialogState.dragDepth = dragDepth; + if (dragDepth === 0) { + clearMissingDragUi(); + } + }; + + const onDrop = (e) => { + if (!isFileDragEvent(e)) return; + e.preventDefault(); + dragDepth = 0; + clearMissingDragUi(); + // Bubble to document so pywebview can deliver the file path to Python. + }; + + overlay.addEventListener("dragenter", onDragEnter, true); + overlay.addEventListener("dragover", onDragOver, true); + overlay.addEventListener("dragleave", onDragLeave, true); + overlay.addEventListener("drop", onDrop, true); + + return () => { + overlay.removeEventListener("dragenter", onDragEnter, true); + overlay.removeEventListener("dragover", onDragOver, true); + overlay.removeEventListener("dragleave", onDragLeave, true); + overlay.removeEventListener("drop", onDrop, true); + }; +} + +function showMissingAtlasImagesDialog(missingPages, atlasDir) { + return new Promise((resolve) => { + const pages = Array.from(new Set((missingPages || []).filter(Boolean))); + if (pages.length === 0) { + resolve({}); + return; + } + + const selectedByPage = {}; + const rowByPage = new Map(); + const statusByPage = new Map(); + const btnByPage = new Map(); + let confirmBtn = null; + let unbindDrag = null; + + const overlay = document.createElement("div"); + overlay.className = "missing-images-overlay"; + + const dialog = document.createElement("div"); + dialog.className = "missing-images-dialog"; + + const title = document.createElement("h3"); + title.className = "missing-images-title"; + title.innerText = "Missing image files for atlas pages"; + + const subtitle = document.createElement("p"); + subtitle.className = "missing-images-subtitle"; + subtitle.innerText = + "Choose one image for each page below, or drag & drop a PNG onto a row."; + + const list = document.createElement("div"); + list.className = "missing-images-list"; + + function updateConfirmState() { + if (confirmBtn) { + confirmBtn.disabled = !pages.every((p) => !!selectedByPage[p]); + } + } + + function applySelection(pageName, path) { + if (!path || !/\.png$/i.test(path)) return; + const row = rowByPage.get(pageName); + const statusEl = statusByPage.get(pageName); + const actionBtn = btnByPage.get(pageName); + if (!row || !statusEl || !actionBtn) return; + selectedByPage[pageName] = path; + statusEl.innerText = formatSelectedImageLabel(path, pageName); + statusEl.title = path; + row.classList.add("selected"); + actionBtn.innerText = "Change"; + actionBtn.classList.add("btn-save"); + updateConfirmState(); + } + + _missingDialogState = { + applySelection, + rowByPage, + overlay, + dragDepth: 0, + dropAllowed: false, + }; + + for (const pageName of pages) { + const row = document.createElement("div"); + row.className = "missing-images-row"; + row.dataset.pageName = pageName; + + const pageEl = document.createElement("div"); + pageEl.className = "missing-images-page"; + pageEl.innerText = pageName; + + const statusEl = document.createElement("div"); + statusEl.className = "missing-images-status"; + statusEl.innerText = "No image selected"; + + const actionBtn = document.createElement("button"); + actionBtn.className = "action-btn missing-images-btn"; + actionBtn.type = "button"; + actionBtn.innerText = "Add image"; + actionBtn.addEventListener("click", async () => { + try { + const path = await pywebview.api.pick_page_image(pageName, atlasDir || ""); + if (path) applySelection(pageName, path); + } catch (e) { + console.error(e); + } + }); + + row.appendChild(pageEl); + row.appendChild(statusEl); + row.appendChild(actionBtn); + list.appendChild(row); + rowByPage.set(pageName, row); + statusByPage.set(pageName, statusEl); + btnByPage.set(pageName, actionBtn); + } + + unbindDrag = bindMissingImagesDragLayer(overlay, rowByPage); + + const buttons = document.createElement("div"); + buttons.className = "missing-images-buttons"; + + const cancelBtn = document.createElement("button"); + cancelBtn.className = "btn-secondary"; + cancelBtn.type = "button"; + cancelBtn.innerText = "Cancel"; + + confirmBtn = document.createElement("button"); + confirmBtn.className = "btn-primary"; + confirmBtn.type = "button"; + confirmBtn.innerText = "Load"; + confirmBtn.disabled = true; + + const close = (result) => { + window.removeEventListener("keydown", onKeyDown); + if (unbindDrag) unbindDrag(); + clearMissingDragUi(); + delete document.body.dataset.missingDialogOpen; + _missingDialogState = null; + overlay.remove(); + resolve(result); + }; + + function onKeyDown(e) { + if (e.key === "Escape") close(null); + } + + cancelBtn.addEventListener("click", () => close(null)); + confirmBtn.addEventListener("click", () => { + if (confirmBtn.disabled) return; + close({ ...selectedByPage }); + }); + + buttons.appendChild(cancelBtn); + buttons.appendChild(confirmBtn); + dialog.appendChild(title); + dialog.appendChild(subtitle); + dialog.appendChild(list); + dialog.appendChild(buttons); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + document.body.dataset.missingDialogOpen = "true"; + const dropOverlay = document.getElementById("drop-overlay"); + if (dropOverlay) { + dropOverlay.classList.add("hidden"); + dropOverlay.style.pointerEvents = "none"; + } + + window.addEventListener("keydown", onKeyDown); + updateConfirmState(); + const firstBtn = rowByPage.get(pages[0])?.querySelector("button"); + if (firstBtn) firstBtn.focus(); + }); +} + +/** Called from Python when a PNG is dropped while the missing-images dialog is open. */ +window.applyMissingImageDrop = function (path, clientX, clientY) { + if (!_missingDialogState || !path || !/\.png$/i.test(path)) return false; + + const row = missingImageRowAt(clientX, clientY); + if (!row) return false; + + const targetPage = row.dataset.pageName; + if (!targetPage) return false; + + clearMissingDragUi(); + _missingDialogState.applySelection(targetPage, path); + return true; +}; + +window.showMissingAtlasImages = async function (missingPages, atlasDir) { + return showMissingAtlasImagesDialog(missingPages, atlasDir || ""); +}; diff --git a/ui/js/mode.js b/ui/js/mode.js index 1e04d5c..e8d9ec4 100644 --- a/ui/js/mode.js +++ b/ui/js/mode.js @@ -16,9 +16,6 @@ function clearRegionSelection() { } function setMode(mode) { - if (mode !== currentMode) { - clearRegionSelection(); - } currentMode = mode; const extractControls = document.getElementById("extract-controls"); const modifyControls = document.getElementById("modify-controls"); @@ -41,6 +38,8 @@ function setMode(mode) { clearOverlay(); } updateModeToggleUI(); + renderSelection(); + updateButtons(); if (mode === "extract") { updatePreview(getSelectedNames()); @@ -65,10 +64,13 @@ async function enterModifyMode() { } } async function exitModifyMode() { + const ok = await confirmDiscardModifications(); + if (!ok) return false; try { await pywebview.api.exit_modify_mode(); } catch (e) { console.error(e); + return false; } setMode("extract"); modifyRegionBounds = {}; @@ -81,8 +83,6 @@ async function exitModifyMode() { modifiedRegionNames = new Set(); renderRegionList(); clearOverlay(); - // Restore preview from current selection - previewImg.style.display = "none"; - resetPreview(); - setStatus("Ready"); + await updatePreview(getSelectedNames()); + return true; } diff --git a/ui/js/modify.js b/ui/js/modify.js index a1608e5..4ccc1f5 100644 --- a/ui/js/modify.js +++ b/ui/js/modify.js @@ -31,6 +31,8 @@ function applyModifyView(data, statusText) { async function resetModify() { if (!hasModImage) return; + const ok = await showConfirm(RESET_MOD_MESSAGE, "Reset modifications?"); + if (!ok) return; try { const data = await pywebview.api.reset_modify_mode(); if (data) { @@ -46,29 +48,6 @@ async function resetModify() { } } -async function exitModifyMode() { - try { - await pywebview.api.exit_modify_mode(); - } catch (e) { - console.error(e); - } - setMode("extract"); - modifyRegionBounds = {}; - modifyOverlayRects = {}; - modifyPages = []; - modifyRegionPages = {}; - modifyActivePageIndex = 0; - document.getElementById("modify-page-switcher").classList.add("hidden"); - hasModImage = false; - modifiedRegionNames = new Set(); - renderRegionList(); - clearOverlay(); - // Restore preview from current selection - previewImg.style.display = "none"; - resetPreview(); - setStatus("Ready"); -} - async function modifySelected() { const names = getSelectedNames(); if (names.length === 0) { @@ -157,9 +136,6 @@ function setupModifyPages(data) { if (modifyPages.length > 1) { switcher.classList.remove("hidden"); updatePageIndicator(); - // Multi-page always repacks all pages; the per-page repack toggle is - // inert here (and toggling it post-merge errors), so hide it. - repackOptions.classList.add("hidden"); } else { switcher.classList.add("hidden"); repackOptions.classList.remove("hidden"); diff --git a/ui/js/preview.js b/ui/js/preview.js index ed1d713..eb369a2 100644 --- a/ui/js/preview.js +++ b/ui/js/preview.js @@ -143,9 +143,9 @@ async function updatePreview(names) { return; } - const base64Img = await pywebview.api.get_preview(names); - if (base64Img) { - previewImg.src = base64Img; + const previewUrl = await pywebview.api.get_preview(names); + if (previewUrl) { + previewImg.src = previewUrl; previewImg.style.display = "block"; if (names.length === 1) { status.innerText = `Previewing: ${names[0]}`; diff --git a/ui/js/ui.js b/ui/js/ui.js index a431fa0..1efe931 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -1,7 +1,27 @@ // Atlas Toolkit UI module +const DISCARD_MOD_MESSAGE = + "You have unsaved atlas modifications.\nContinue and discard them?"; +const RESET_MOD_MESSAGE = + "Reset all modifications and restore the original atlas preview?"; + function setStatus(text) { document.getElementById("status-text").innerText = text; } + +async function confirmDiscardModifications( + message = DISCARD_MOD_MESSAGE, + title = "Discard modifications?" +) { + let pending = false; + try { + pending = await pywebview.api.has_pending_modifications(); + } catch (e) { + pending = hasModImage || modifiedRegionNames.size > 0; + } + if (!pending) return true; + return showConfirm(message, title); +} + function showConfirm(message, title = "Confirm") { return new Promise((resolve) => { const overlay = document.getElementById("modal-overlay"); @@ -50,7 +70,7 @@ function showToast(message, type = "info") { const container = document.getElementById("toast-container"); const toast = document.createElement("div"); toast.className = `toast ${type}`; - toast.innerText = message; + toast.textContent = message; container.appendChild(toast); @@ -66,5 +86,5 @@ function showToast(message, type = "info") { toast.addEventListener("animationend", () => { toast.remove(); }); - }, 3000); + }, 5000); } From d004d5c1608cf37e24c830508411aaa878eed3f3 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:35:08 +0700 Subject: [PATCH 17/18] fix(app): add debounce to dragover event handler Enhance the dragover event handling in the app bridge by introducing a debounce of 500ms to prevent excessive event firing. Additionally, refactor the import of the crop_and_rotate function in the extracter module for clarity and consistency. --- atlas_toolkit/app/bridge.py | 4 +++- atlas_toolkit/atlas/extracter.py | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/atlas_toolkit/app/bridge.py b/atlas_toolkit/app/bridge.py index f7df3c0..e44f2ec 100644 --- a/atlas_toolkit/app/bridge.py +++ b/atlas_toolkit/app/bridge.py @@ -509,7 +509,9 @@ def _drag_over_no_op(_e: Any) -> None: log.debug("Binding drop events...") doc = window.dom.document - doc.events.dragover += DOMEventHandler(_drag_over_no_op, True, True) # type: ignore[operator] + doc.events.dragover += DOMEventHandler( + _drag_over_no_op, True, True, debounce=500 + ) # type: ignore[operator] doc.events.drop += DOMEventHandler(api.on_drop, True, True) # type: ignore[operator] log.debug("Drop events bound.") except Exception as e: diff --git a/atlas_toolkit/atlas/extracter.py b/atlas_toolkit/atlas/extracter.py index 6cdf0ef..4072f3b 100644 --- a/atlas_toolkit/atlas/extracter.py +++ b/atlas_toolkit/atlas/extracter.py @@ -8,7 +8,10 @@ from PIL import Image from atlas_toolkit.core.document import AtlasDocument, Page, Region -from atlas_toolkit.core.region_ops import extract_region_from_page +from atlas_toolkit.core.region_ops import ( + crop_and_rotate as _crop_and_rotate, + extract_region_from_page, +) logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") @@ -94,9 +97,7 @@ def _load_images( def crop_and_rotate( image: Image.Image, x: int, y: int, w: int, h: int, rotate: int ) -> Image.Image: - from region_ops import crop_and_rotate - - return crop_and_rotate(image, x, y, w, h, rotate) + return _crop_and_rotate(image, x, y, w, h, rotate) def get_page_image(self, page_filename: Optional[str] = None) -> Optional[Image.Image]: if page_filename: From a7f0263d9c2a9099d40cd45e6ab04751796c3257 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:58:59 +0700 Subject: [PATCH 18/18] chore: bump version to 0.3.0 Co-authored-by: Cursor --- atlas_toolkit/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/atlas_toolkit/__init__.py b/atlas_toolkit/__init__.py index 69da59b..5e1d7b3 100644 --- a/atlas_toolkit/__init__.py +++ b/atlas_toolkit/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.2.2" +__version__ = "0.3.0" diff --git a/pyproject.toml b/pyproject.toml index b215901..806bb0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "AtlasToolkit" -version = "0.2.2" +version = "0.3.0" description = "Spine Atlas Toolkit - Extract, modify, and repack atlas sprites." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 6c85653..89929d8 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "atlastoolkit" -version = "0.2.2" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "pillow" },