From 96ff07d44874f3cf1f282af04635edc157694ed8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 8 Jun 2026 17:02:32 +0200 Subject: [PATCH] feat: add maps layouts --- src-tauri/src/config.rs | 9 ++- src-tauri/src/lib.rs | 90 +++++++++++++++++++++++-- src/lib/Layouts.svelte | 146 +++++++++++++++++++++++++++++++--------- src/lib/Settings.svelte | 20 +++++- src/lib/api.ts | 5 +- src/lib/layouts.ts | 11 ++- 6 files changed, 233 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ca3d7b5..930e8fe 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -58,8 +58,10 @@ pub struct Config { /// Saved position of the layout window (logical pixels). pub layout_x: i32, pub layout_y: i32, - /// Size (px) of the main layout image rendered in the viewer. - pub layout_size: u32, + /// Saved size of the layout window (logical pixels). The image scales to fill + /// it; the user adjusts it via the corner resize grip or the settings fields. + pub layout_w: u32, + pub layout_h: u32, // --- campaign timer --- pub timer_enabled: bool, @@ -107,7 +109,8 @@ impl Default for Config { feature_layouts: true, layout_x: 600, layout_y: 160, - layout_size: 360, + layout_w: 480, + layout_h: 600, timer_enabled: true, timer_pause_in_town: true, timer_afk_enabled: true, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c7272e7..142c804 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,10 @@ pub struct AppState { // the offset from the window's top-left to the cursor; a background thread // follows the mouse via xdotool (Tauri/KWin won't reposition this window). pub layout_drag: Mutex>, + // While the user drags the layout window's corner grip: Some(()) signals a + // background thread to follow the mouse and resize the window via set_size + // (KWin gives this decorationless window no resize borders of its own). + pub layout_resize: Mutex>, } // --------------------------------------------------------------------------- @@ -284,12 +288,80 @@ fn toggle_layout(app: AppHandle, state: tauri::State) { } } -/// Resize the layout window to fit its rendered content (called by the frontend -/// whenever the viewer's content size changes). +/// Resize the layout window to an explicit size and persist it (called by the +/// settings fields). The grip uses `start_layout_resize` for live dragging. #[tauri::command] -fn set_layout_size(app: AppHandle, width: u32, height: u32) { +fn set_layout_size(app: AppHandle, width: u32, height: u32, state: tauri::State) { + let (w_px, h_px) = (width.max(MIN_LAYOUT_W), height.max(MIN_LAYOUT_H)); if let Some(w) = app.get_webview_window("layout") { - let _ = w.set_size(LogicalSize::new(width.max(80), height.max(80))); + let _ = w.set_size(LogicalSize::new(w_px, h_px)); + } + let mut cfg = state.config.lock().unwrap(); + cfg.layout_w = w_px; + cfg.layout_h = h_px; + cfg.save(); +} + +/// Minimum layout-window size (logical px), shared by the grip and settings. +const MIN_LAYOUT_W: u32 = 240; +const MIN_LAYOUT_H: u32 = 200; + +/// Begin resizing the layout window by its corner grip. Mirrors the drag +/// machinery: a background thread follows the mouse (pointermove is unreliable +/// in this WebKitGTK window) and resizes via `set_size` until `end_layout_resize`. +#[tauri::command] +fn start_layout_resize(app: AppHandle, state: tauri::State) { + let Some(w) = app.get_webview_window("layout") else { + return; + }; + let Some((mx, my)) = crate::poe::mouse_position() else { + return; + }; + let scale = w.scale_factor().unwrap_or(1.0); + let Ok(base) = w.inner_size() else { + return; + }; + let base = base.to_logical::(scale); + *state.layout_resize.lock().unwrap() = Some(()); + + let app = app.clone(); + std::thread::spawn(move || { + let start = std::time::Instant::now(); + loop { + if app.state::().layout_resize.lock().unwrap().is_none() { + break; + } + // Safety net so a missed pointerup can't pin the size to the cursor. + if start.elapsed().as_secs() > 30 { + *app.state::().layout_resize.lock().unwrap() = None; + break; + } + if let (Some(w), Some((cx, cy))) = + (app.get_webview_window("layout"), crate::poe::mouse_position()) + { + let nw = (base.width + (cx - mx)).max(MIN_LAYOUT_W as i32) as u32; + let nh = (base.height + (cy - my)).max(MIN_LAYOUT_H as i32) as u32; + let _ = w.set_size(LogicalSize::new(nw, nh)); + } + std::thread::sleep(std::time::Duration::from_millis(16)); + } + }); +} + +/// Stop resizing the layout window and persist its final size. +#[tauri::command] +fn end_layout_resize(app: AppHandle, state: tauri::State) { + *state.layout_resize.lock().unwrap() = None; + std::thread::sleep(std::time::Duration::from_millis(30)); + if let Some(w) = app.get_webview_window("layout") { + let scale = w.scale_factor().unwrap_or(1.0); + if let Ok(s) = w.inner_size() { + let ls = s.to_logical::(scale); + let mut cfg = state.config.lock().unwrap(); + cfg.layout_w = ls.width.max(MIN_LAYOUT_W); + cfg.layout_h = ls.height.max(MIN_LAYOUT_H); + cfg.save(); + } } } @@ -625,6 +697,7 @@ pub fn run() { status: Mutex::new(Status::default()), timer: Mutex::new(live_timer), layout_drag: Mutex::new(None), + layout_resize: Mutex::new(None), }; tauri::Builder::default() @@ -675,6 +748,8 @@ pub fn run() { save_layout_geometry, start_layout_drag, end_layout_drag, + start_layout_resize, + end_layout_resize, ]) .setup(|app| { let handle = app.handle().clone(); @@ -736,10 +811,10 @@ pub fn run() { // Unlike the overlay it is NOT transparent: it's an opaque panel, and // transparent WebKitGTK windows don't reliably repaint here (the view // stays blank/ghosted), whereas an opaque window composites fine. - let (lx, ly, lsz) = { + let (lx, ly, lw, lh) = { let state = handle.state::(); let cfg = state.config.lock().unwrap(); - (cfg.layout_x, cfg.layout_y, cfg.layout_size) + (cfg.layout_x, cfg.layout_y, cfg.layout_w, cfg.layout_h) }; let _ = WebviewWindowBuilder::new( &handle, @@ -747,7 +822,8 @@ pub fn run() { WebviewUrl::App("index.html".into()), ) .title("Exile UI Layouts") - .inner_size(lsz as f64 + 24.0, lsz as f64 + 110.0) + .inner_size(lw as f64, lh as f64) + .min_inner_size(MIN_LAYOUT_W as f64, MIN_LAYOUT_H as f64) .position(lx as f64, ly as f64) .decorations(false) .always_on_top(true) diff --git a/src/lib/Layouts.svelte b/src/lib/Layouts.svelte index 6a2fc1c..798532c 100644 --- a/src/lib/Layouts.svelte +++ b/src/lib/Layouts.svelte @@ -2,26 +2,24 @@ import { onMount } from "svelte"; import { getStatus, - getConfig, toggleLayout, - setLayoutSize, startLayoutDrag, endLayoutDrag, + startLayoutResize, + endLayoutResize, type Status, - type Config, } from "$lib/api"; import { loadManifest, childrenOf, imageUrl, - isDeadEnd, + isLegend, rotationBlocked, type LayoutManifest, } from "$lib/layouts"; let manifest = $state({}); let status = $state(null); - let config = $state(null); // Decision-tree cursor + per-zone view state (reset whenever the zone changes). let path = $state(""); @@ -47,9 +45,29 @@ const areaName = $derived(status?.area_name || areaId || "—"); const paths = $derived(manifest[areaId] ?? []); const hasZone = $derived(paths.length > 0); + // Real layout candidates at the current branch (legends are pulled out below). const candidates = $derived( - childrenOf(paths, path).filter((p) => !excluded.includes(p)), + childrenOf(paths, path).filter((p) => !excluded.includes(p) && !isLegend(p)), ); + // The zone's legend pages ("x", "x_x", …), ordered by depth. A legend at depth + // d documents the layout candidates at hierarchy level d (verified: e.g. g1_8 + // ~x explains the level-1 symbols, ~x_x the level-2 ones). + const legendPaths = $derived( + paths + .filter((p) => isLegend(p)) + .sort((a, b) => a.split("_").length - b.split("_").length), + ); + // The single legend to show for the current level: the i-th legend at level i, + // clamped to the last one (so it carries forward when a level has none of its + // own), and the last legend once the layout is identified. + const activeLegend = $derived.by(() => { + if (legendPaths.length === 0) return null; + const pathDepth = path === "" ? 0 : path.split("_").length; + const idx = resolved + ? legendPaths.length - 1 + : Math.min(pathDepth, legendPaths.length - 1); + return legendPaths[idx]; + }); const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0); const blocked = $derived(rotationBlocked(areaId)); const transform = $derived( @@ -77,13 +95,6 @@ flipV = false; } - // Keep the OS window sized to the rendered content. - let boxW = $state(0); - let boxH = $state(0); - $effect(() => { - if (boxW > 0 && boxH > 0) setLayoutSize(Math.ceil(boxW), Math.ceil(boxH)); - }); - onMount(() => { let alive = true; // The layout window is created hidden, and its `listen` event subscription @@ -92,7 +103,6 @@ // poll it and only react when the area actually changes (cheap; no flicker). (async () => { manifest = await loadManifest(); - config = await getConfig(); while (alive) { const s = await getStatus(); if (!status || s.area_id !== status.area_id) { @@ -107,8 +117,6 @@ }; }); - const imgSize = $derived(config?.layout_size ?? 360); - // Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and // Tauri's `setPosition` don't move this window under KWin, so the Rust side // follows the mouse with xdotool between start/end (driven by pointerdown/up, @@ -125,9 +133,26 @@ window.removeEventListener("blur", endDrag); endLayoutDrag(); } + + // Resize the window via the corner grip. Same approach as the drag: the Rust + // side follows the mouse (pointermove is flaky in this WebKitGTK window) and + // resizes via set_size; pointerdown/up drive start/end. + function startResize(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + startLayoutResize(); + window.addEventListener("pointerup", endResize, { once: true }); + window.addEventListener("blur", endResize, { once: true }); + } + function endResize() { + window.removeEventListener("pointerup", endResize); + window.removeEventListener("blur", endResize); + endLayoutResize(); + } -
+
diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index e144c5f..966bbd3 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -10,6 +10,7 @@ setOverlayLocked, toggleOverlay, toggleLayout, + setLayoutSize, saveOverlayGeometry, getTimer, timerStart, @@ -194,8 +195,23 @@ -

diff --git a/src/lib/api.ts b/src/lib/api.ts index 7daddda..99fb216 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -17,7 +17,8 @@ export interface Config { feature_layouts: boolean; layout_x: number; layout_y: number; - layout_size: number; + layout_w: number; + layout_h: number; timer_enabled: boolean; timer_pause_in_town: boolean; timer_afk_enabled: boolean; @@ -107,6 +108,8 @@ export const restoreOverlayPosition = () => invoke("restore_overlay_position"); export const toggleLayout = () => invoke("toggle_layout"); export const setLayoutSize = (width: number, height: number) => invoke("set_layout_size", { width, height }); +export const startLayoutResize = () => invoke("start_layout_resize"); +export const endLayoutResize = () => invoke("end_layout_resize"); export const saveLayoutGeometry = () => invoke("save_layout_geometry"); export const startLayoutDrag = () => invoke("start_layout_drag"); export const endLayoutDrag = () => invoke("end_layout_drag"); diff --git a/src/lib/layouts.ts b/src/lib/layouts.ts index 687574c..9f45947 100644 --- a/src/lib/layouts.ts +++ b/src/lib/layouts.ts @@ -4,7 +4,8 @@ // where the path is a decision tree encoded as underscore-separated segments: // "1", "2", "3" = the candidate layouts you first see entering a zone // "3_1", "3_2" = refinements of "3" revealed as you explore deeper -// "x", "x_x" = "none of these / not catalogued" dead-ends +// "x", "x_x" = the zone's legend pages (key to the map symbols), NOT +// decision candidates — they're shown persistently. // You pick the candidate matching what you see in-game, then keep refining until // no children remain. (On disk the space is stored as "~" for URL-safe serving.) @@ -56,8 +57,12 @@ export function childrenOf(paths: string[], path: string): string[] { .sort(sortPaths); } -/** A path is an "x" (none-of-these / not catalogued) marker. */ -export function isDeadEnd(path: string): boolean { +/** + * A path is a legend page rather than a layout candidate. These are named with + * a leading "x" segment ("x", "x_x", …) and hold the key to the map symbols, so + * the viewer pulls them out of the decision tree and keeps them always visible. + */ +export function isLegend(path: string): boolean { return path.split("_")[0] === "x"; }