feat: adapt Exile-ui for linux

This commit is contained in:
2026-06-01 08:22:29 +02:00
parent f7bc80c86b
commit 166ce7816d
426 changed files with 13082 additions and 8 deletions

24
src/app.css Normal file
View File

@ -0,0 +1,24 @@
:root {
color-scheme: dark;
--bg: #14110d;
--panel: #1d1812;
--border: #3a2f22;
--text: #d8cdbb;
--muted: #8c8170;
--accent: #c8a24a;
--accent2: #66b2ff;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: transparent;
color: var(--text);
font-family: "Fontin SmallCaps", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}

13
src/app.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exile UI</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

374
src/lib/Layouts.svelte Normal file
View File

@ -0,0 +1,374 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getStatus,
getConfig,
toggleLayout,
setLayoutSize,
startLayoutDrag,
endLayoutDrag,
type Status,
type Config,
} from "$lib/api";
import {
loadManifest,
childrenOf,
imageUrl,
isDeadEnd,
rotationBlocked,
type LayoutManifest,
} from "$lib/layouts";
let manifest = $state<LayoutManifest>({});
let status = $state<Status | null>(null);
let config = $state<Config | null>(null);
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
let path = $state("");
let excluded = $state<string[]>([]);
let rot = $state(0); // degrees, multiples of 90
let flipH = $state(false);
let flipV = $state(false);
let lastArea = "";
function syncArea(s: Status | null) {
const id = s?.area_id ?? "";
if (id !== lastArea) {
lastArea = id;
path = "";
excluded = [];
rot = 0;
flipH = false;
flipV = false;
}
}
const areaId = $derived(status?.area_id ?? "");
const areaName = $derived(status?.area_name || areaId || "—");
const paths = $derived(manifest[areaId] ?? []);
const hasZone = $derived(paths.length > 0);
const candidates = $derived(
childrenOf(paths, path).filter((p) => !excluded.includes(p)),
);
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
const blocked = $derived(rotationBlocked(areaId));
const transform = $derived(
`rotate(${rot}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`,
);
const crumbs = $derived(path === "" ? [] : path.split("_"));
function pick(p: string) {
path = p;
}
function back() {
const seg = path.split("_");
seg.pop();
path = seg.join("_");
}
function exclude(p: string) {
if (!excluded.includes(p)) excluded = [...excluded, p];
}
function rotate(dir: number) {
rot = (((rot + dir * 90) % 360) + 360) % 360;
}
function resetOrientation() {
rot = 0;
flipH = false;
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
// never resolves in that state (unlike the always-visible overlay), so live
// status events don't arrive here. The `get_status` command does work, so we
// 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) {
status = s;
syncArea(s);
}
await new Promise((r) => setTimeout(r, 500));
}
})();
return () => {
alive = false;
};
});
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,
// which fire reliably — only pointermove is flaky here).
function startDrag(e: PointerEvent) {
if (e.button !== 0 || (e.target as HTMLElement).closest("button")) return;
e.preventDefault();
startLayoutDrag();
window.addEventListener("pointerup", endDrag, { once: true });
window.addEventListener("blur", endDrag, { once: true });
}
function endDrag() {
window.removeEventListener("pointerup", endDrag);
window.removeEventListener("blur", endDrag);
endLayoutDrag();
}
</script>
<div class="viewer" bind:clientWidth={boxW} bind:clientHeight={boxH}>
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
<span class="title">🗺 {areaName}</span>
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
</div>
{#if !hasZone}
<div class="empty">Pas de layout répertorié pour cette zone.</div>
{:else}
<!-- Main image: the deepest committed pick, with the zone's orientation. -->
<div class="stage" style="width:{imgSize}px;height:{imgSize}px;">
{#if path !== ""}
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
{:else}
<div class="hint">Clique le layout qui correspond à ce que tu vois en jeu.</div>
{/if}
</div>
<!-- Orientation controls (the whole zone is placed at a random rotation). -->
{#if !blocked}
<div class="ctrls">
<button title="Pivoter à gauche" onclick={() => rotate(-1)}>⟲</button>
<button title="Pivoter à droite" onclick={() => rotate(1)}>⟳</button>
<button class:on={flipH} title="Miroir horizontal" onclick={() => (flipH = !flipH)}>⇋</button>
<button class:on={flipV} title="Miroir vertical" onclick={() => (flipV = !flipV)}>⇅</button>
<button title="Réinitialiser l'orientation" onclick={resetOrientation}>↺</button>
<span class="deg">{rot}°</span>
</div>
{/if}
<!-- Breadcrumb / back navigation. -->
{#if path !== ""}
<div class="crumbs">
<button class="link" onclick={() => (path = "")}>début</button>
{#each crumbs as seg, i}
<span class="sep"></span>
<button
class="link"
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
>{seg === "x" ? "?" : seg}</button>
{/each}
<button class="back" onclick={back}> retour</button>
</div>
{/if}
<!-- Candidate thumbnails at the current branch. -->
{#if candidates.length}
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
<div class="thumbs">
{#each candidates as c}
<button
class="thumb"
class:dead={isDeadEnd(c)}
title={isDeadEnd(c) ? "Aucun de ceux-ci / non répertorié" : c}
onclick={() => pick(c)}
oncontextmenu={(e) => {
e.preventDefault();
exclude(c);
}}
>
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
<span class="cap">{isDeadEnd(c) ? "?" : c.split("_").slice(-1)[0]}</span>
</button>
{/each}
</div>
<div class="tip">clic = choisir · clic droit = exclure</div>
{:else if resolved}
<div class="label done">Layout identifié.</div>
{/if}
{/if}
</div>
<style>
.viewer {
display: inline-flex;
flex-direction: column;
background: #0f0c08;
border: 1px solid var(--border);
overflow: hidden;
width: max-content;
}
.bar {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 6px 3px 10px;
background: var(--accent);
color: #14110d;
cursor: move;
user-select: none;
}
.bar .title {
font-weight: 700;
font-size: 0.9em;
text-transform: capitalize;
flex: 1;
}
.bar .close {
background: transparent;
border: none;
color: #14110d;
cursor: pointer;
font-weight: 700;
padding: 0 4px;
}
.empty {
padding: 18px;
color: var(--muted);
font-size: 0.9em;
text-align: center;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
margin: 8px auto 4px;
background: #000;
border: 1px solid var(--border);
overflow: hidden;
}
.stage img {
max-width: 100%;
max-height: 100%;
transition: transform 0.12s ease;
}
.stage .hint {
color: var(--muted);
font-size: 0.85em;
text-align: center;
padding: 12px;
}
.ctrls {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 2px 6px;
}
.ctrls button {
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 1em;
line-height: 1.2;
}
.ctrls button:hover {
border-color: var(--accent);
}
.ctrls button.on {
border-color: var(--accent);
background: rgba(200, 162, 74, 0.18);
}
.ctrls .deg {
color: var(--muted);
font-size: 0.8em;
margin-left: 4px;
}
.crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 0.8em;
}
.crumbs .sep {
color: var(--muted);
}
.crumbs .link {
background: transparent;
border: none;
color: var(--accent2);
cursor: pointer;
padding: 0 2px;
}
.crumbs .back {
margin-left: auto;
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 8px;
cursor: pointer;
}
.label {
padding: 2px 10px 0;
font-size: 0.82em;
color: var(--muted);
}
.label.done {
color: #7cfc00;
padding-bottom: 8px;
}
.thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 8px;
justify-content: center;
max-width: 460px;
}
.thumb {
position: relative;
width: 92px;
height: 92px;
padding: 0;
background: #000;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
overflow: hidden;
}
.thumb:hover {
border-color: var(--accent);
}
.thumb.dead {
opacity: 0.6;
border-style: dashed;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.12s ease;
}
.thumb .cap {
position: absolute;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: var(--accent);
font-size: 0.75em;
padding: 0 4px;
border-top-left-radius: 4px;
}
.tip {
padding: 0 8px 8px;
font-size: 0.72em;
color: var(--muted);
text-align: center;
}
</style>

239
src/lib/Overlay.svelte Normal file
View File

@ -0,0 +1,239 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getGuide,
getConfig,
getStatus,
onStep,
onStatus,
onConfig,
onFocus,
onOverlayToggle,
onOverlayLocked,
onGuideReload,
getTimer,
onTimer,
fmtTime,
setOverlayHeight,
restoreOverlayPosition,
type GuideData,
type Config,
type Status,
type RunTimer,
} from "$lib/api";
import StepView from "$lib/StepView.svelte";
let guide = $state<GuideData | null>(null);
let config = $state<Config | null>(null);
let status = $state<Status | null>(null);
let timer = $state<RunTimer | null>(null);
let focused = $state(false);
let userHidden = $state(false);
let locked = $state(true);
// Measured height of the rendered overlay box; drives the OS window height so
// the window is only as tall as its content (else the WM clamps it on-screen
// and the overlay jumps to the middle when placed/restored near a screen edge).
let boxHeight = $state(0);
// Whether the overlay content should be shown. When unlocked (editing the
// position) it is always shown; otherwise it follows the focus/toggle rules.
const visible = $derived.by(() => {
if (!config) return false;
if (!locked) return true;
if (userHidden) return false;
return config.overlay_only_when_focused ? focused : true;
});
const current = $derived(config?.current_step ?? 0);
const currentStep = $derived(guide?.steps[current] ?? null);
const lookahead = $derived.by(() => {
if (!guide || !config) return [];
const n = config.lookahead;
return guide.steps.slice(current + 1, current + 1 + n);
});
// Recommended level for the area this step heads to.
const recommendation = $derived.by(() => {
if (!guide || !currentStep?.target_area) return null;
return guide.areas[currentStep.target_area]?.recommendation ?? null;
});
onMount(() => {
const unlisten: Array<() => void> = [];
(async () => {
guide = await getGuide();
config = await getConfig();
status = await getStatus();
timer = await getTimer();
focused = status.game_focused;
unlisten.push(
await onStep((i) => {
if (config) config = { ...config, current_step: i };
}),
);
unlisten.push(await onStatus((s) => (status = s)));
unlisten.push(await onConfig((c) => (config = c)));
unlisten.push(await onFocus((f) => (focused = f)));
unlisten.push(await onOverlayToggle(() => (userHidden = !userHidden)));
unlisten.push(await onOverlayLocked((l) => (locked = l)));
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
unlisten.push(await onTimer((t) => (timer = t)));
})();
return () => unlisten.forEach((u) => u());
});
// Keep the OS window height matched to the rendered content.
let positionRestored = false;
$effect(() => {
if (visible && boxHeight > 0) {
setOverlayHeight(Math.ceil(boxHeight) + 2);
// Once the window is shrunk to content, restore its saved position (the WM
// would clamp a still-tall window on-screen if we did this any earlier).
if (!positionRestored) {
positionRestored = true;
setTimeout(() => restoreOverlayPosition(), 150);
}
}
});
</script>
{#if guide && config && visible}
<div class="overlay" class:editing={!locked} style="font-size:{config.overlay_font_size}px;" bind:clientHeight={boxHeight}>
{#if !locked}
<div class="dragbar" data-tauri-drag-region>⠿ déplacer · reverrouille dans les réglages pour sauver</div>
{/if}
<div class="header">
<span class="act">Act {currentStep ? currentStep.section + 1 : "?"}</span>
{#if status}
<span class="lvl">Lv {status.level || "?"}</span>
{#if status.area_name}<span class="area">{status.area_name}</span>{/if}
{/if}
{#if recommendation && config.show_recommendation}
<span class="rec">rec. {recommendation}</span>
{/if}
<span class="prog">{current + 1}/{guide.steps.length}</span>
</div>
{#if config.timer_enabled && timer && timer.active}
<div class="timer" class:paused={timer.manual_pause || timer.auto_paused || timer.afk_paused}>
<span class="t-total">{fmtTime(timer.total_seconds + timer.act_seconds)}</span>
<span class="t-act">acte {timer.current_act} · {fmtTime(timer.act_seconds)}</span>
{#if timer.finished}
<span class="t-flag done">terminé</span>
{:else if timer.manual_pause}
<span class="t-flag">pause</span>
{:else if timer.afk_paused}
<span class="t-flag">{focused ? "AFK" : "fenêtre"}</span>
{:else if timer.auto_paused}
<span class="t-flag">ville</span>
{/if}
</div>
{#if timer.splits.some((s) => s > 0)}
<div class="splits">
{#each timer.splits as s, i}
{#if s > 0}<span class="split">A{i} {fmtTime(s)}</span>{/if}
{/each}
</div>
{/if}
{/if}
{#if currentStep}
<StepView step={currentStep} areas={guide.areas} current={true} showOptionals={config.show_optionals} />
{/if}
{#each lookahead as step}
<StepView {step} areas={guide.areas} showOptionals={config.show_optionals} />
{/each}
</div>
{/if}
<style>
.overlay {
background: rgba(15, 12, 8, 0.82);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
width: 100%;
backdrop-filter: blur(2px);
}
.overlay.editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(200, 162, 74, 0.4);
}
.dragbar {
background: var(--accent);
color: #14110d;
font-size: 0.8em;
text-align: center;
padding: 2px;
cursor: move;
user-select: none;
}
.header {
display: flex;
gap: 8px;
align-items: center;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.35);
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
.header .act {
color: var(--accent);
font-weight: 700;
}
.header .lvl {
color: #cfa;
}
.header .area {
color: var(--accent2);
text-transform: capitalize;
}
.header .rec {
color: var(--muted);
}
.header .prog {
margin-left: auto;
color: var(--muted);
}
.timer {
display: flex;
gap: 10px;
align-items: center;
padding: 2px 10px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
.timer .t-total {
color: var(--accent);
font-weight: 700;
}
.timer .t-act {
color: var(--text);
}
.timer.paused .t-total,
.timer.paused .t-act {
color: var(--muted);
}
.timer .t-flag {
margin-left: auto;
color: #e0c84a;
text-transform: uppercase;
font-size: 0.85em;
}
.timer .t-flag.done {
color: #7CFC00;
}
.splits {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 10px 4px;
background: rgba(0, 0, 0, 0.18);
border-bottom: 1px solid var(--border);
font-size: 0.78em;
}
.splits .split {
color: var(--muted);
}
</style>

488
src/lib/Settings.svelte Normal file
View File

@ -0,0 +1,488 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getConfig,
saveConfig,
getStatus,
getGuide,
detectLogs,
stepDelta,
setOverlayLocked,
toggleOverlay,
toggleLayout,
saveOverlayGeometry,
getTimer,
timerStart,
timerPause,
timerReset,
createProfile,
deleteProfile,
selectProfile,
onStatus,
onStep,
onConfig,
onGuideReload,
onTimer,
fmtTime,
type Config,
type Status,
type GuideData,
type RunTimer,
} from "$lib/api";
let config = $state<Config | null>(null);
let status = $state<Status | null>(null);
let guide = $state<GuideData | null>(null);
let timer = $state<RunTimer | null>(null);
let detected = $state<string[]>([]);
let overlayUnlocked = $state(false);
// --- character profiles ---
let newProfileName = $state("");
let newProfileLeagueStart = $state(true);
async function refreshDetect() {
detected = await detectLogs();
}
async function addProfile() {
const name = newProfileName.trim();
if (!name) return;
await createProfile(name, newProfileLeagueStart);
newProfileName = "";
// config (profiles + active_character) is pushed back via config://update
}
async function save() {
if (config) await saveConfig($state.snapshot(config));
}
async function toggleLock() {
overlayUnlocked = !overlayUnlocked;
await setOverlayLocked(!overlayUnlocked); // locked = click-through
if (!overlayUnlocked) await saveOverlayGeometry();
if (overlayUnlocked) config = await getConfig();
}
onMount(() => {
const unlisten: Array<() => void> = [];
(async () => {
config = await getConfig();
status = await getStatus();
guide = await getGuide();
timer = await getTimer();
await refreshDetect();
unlisten.push(await onStatus((s) => (status = s)));
unlisten.push(
await onStep((i) => {
if (config) config = { ...config, current_step: i };
}),
);
unlisten.push(await onConfig((c) => (config = c)));
unlisten.push(await onGuideReload(async () => (guide = await getGuide())));
unlisten.push(await onTimer((t) => (timer = t)));
})();
return () => unlisten.forEach((u) => u());
});
</script>
<main>
<h1>Exile UI <small>· Act-Tracker (PoE2)</small></h1>
{#if status}
<section class="status">
<div><b>{status.character || "—"}</b> ({status.class || "?"}) · Lv {status.level || "?"}</div>
<div>
Zone : <span class="hl">{status.area_name || status.area_id || "—"}</span>
{#if status.area_level}· niveau zone {status.area_level}{/if}
</div>
<div class="muted">
Jeu focus : {status.game_focused ? "✔️" : "✖️"} · areaID : {status.area_id || "—"}
</div>
</section>
{/if}
{#if config && guide}
<section>
<h2>Guide</h2>
<div class="row">
<button onclick={() => stepDelta(-1)}> Précédent</button>
<span class="prog">Étape {config.current_step + 1} / {guide.steps.length}</span>
<button onclick={() => stepDelta(1)}>Suivant ▶</button>
</div>
<div class="row">
<button onclick={() => toggleOverlay()}>Afficher/Masquer overlay</button>
<button onclick={toggleLock}>
{overlayUnlocked ? "🔒 Verrouiller (click-through)" : "🔓 Déverrouiller (déplacer)"}
</button>
</div>
{#if overlayUnlocked}
<p class="muted">Overlay déverrouillé : déplace/redimensionne la fenêtre, puis reverrouille pour sauvegarder.</p>
{/if}
</section>
<section>
<h2>Profils <small>· un profil = un personnage</small></h2>
<p class="muted">
Crée un profil par personnage (le nom doit correspondre au nom du perso
en jeu). La progression du guide est mémorisée <b>par profil</b>, et le
profil devient actif tout seul quand le log détecte ce personnage.
</p>
<div class="status">
Profil actif :
<span class="hl">{config.active_character || "— aucun —"}</span>
{#if status?.character && status.character !== config.active_character}
· perso en jeu : <span class="hl">{status.character}</span>
{#if !config.profiles.some((p) => p.name === status?.character)}
<span class="muted">(pas de profil)</span>
{/if}
{/if}
</div>
{#if config.profiles.length}
<div class="char-list">
{#each config.profiles as p}
<div class="char" class:active={config.active_character === p.name}>
<button class="char-pick" onclick={() => selectProfile(p.name)}>
<b>{p.name}</b>
<span class="muted">
étape {p.current_step + 1} · {p.league_start ? "league-start" : "standard"}
</span>
</button>
<button class="del" title="Supprimer ce profil" onclick={() => deleteProfile(p.name)}>✕</button>
</div>
{/each}
</div>
{:else}
<p class="muted">Aucun profil pour l'instant.</p>
{/if}
<div class="row">
<input
type="text"
bind:value={newProfileName}
placeholder="nom du personnage"
style="flex:1"
onkeydown={(e) => e.key === "Enter" && addProfile()}
/>
<label class="ck" style="white-space:nowrap">
<input type="checkbox" bind:checked={newProfileLeagueStart} />
league-start
</label>
<button onclick={addProfile} disabled={!newProfileName.trim()}>+ Créer</button>
</div>
</section>
<section>
<h2>Layouts de zones <small>· identificateur de layout (PoE2)</small></h2>
<p class="muted">
Ouvre une fenêtre interactive affichant les layouts possibles de la zone
courante. Clique celui qui correspond à ce que tu vois pour l'affiner, et
pivote/miroir l'image pour l'aligner sur l'orientation en jeu (chaque
instance de zone est tournée aléatoirement).
</p>
<label class="ck">
<input type="checkbox" bind:checked={config.feature_layouts} onchange={save} />
Activer le viewer de layouts
</label>
{#if config.feature_layouts}
<div class="row">
<button onclick={() => toggleLayout()}>Afficher / masquer la fenêtre layouts</button>
</div>
<div class="grid">
<label>Hotkey « layouts »
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
</label>
<label>Taille image (px)
<input type="number" min="160" max="800" bind:value={config.layout_size} onchange={save} />
</label>
</div>
<p class="muted">
La fenêtre est interactive (elle prend le focus). Si KWin ne la garde pas
au-dessus du jeu, ajoute une règle de fenêtre pour « Exile UI Layouts ».
</p>
{/if}
</section>
<section>
<h2>Timer de campagne</h2>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_enabled} onchange={save} />
Activer le timer
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_pause_in_town} onchange={save} />
Mettre en pause automatiquement en ville / hideout
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.timer_afk_enabled} onchange={save} />
Anti-AFK : mettre en pause si la souris ne bouge plus
</label>
{#if config.timer_afk_enabled}
<label>Délai avant pause AFK (secondes)
<input type="number" min="10" bind:value={config.timer_afk_seconds} onchange={save} />
</label>
{/if}
<label class="ck">
<input type="checkbox" bind:checked={config.timer_pause_unfocused} onchange={save} />
Mettre en pause quand PoE2 n'a pas le focus (alt-tab)
</label>
{#if config.timer_enabled}
{#if timer && timer.active}
<div class="status timer-box">
<div>
Total : <b class="hl">{fmtTime(timer.total_seconds + timer.act_seconds)}</b>
· acte {timer.current_act} : {fmtTime(timer.act_seconds)}
{#if timer.finished}<span class="flag done">terminé</span>
{:else if timer.manual_pause}<span class="flag">en pause</span>
{:else if timer.afk_paused}<span class="flag">{status?.game_focused ? "AFK" : "fenêtre (pas de focus)"}</span>
{:else if timer.auto_paused}<span class="flag">pause auto (ville)</span>{/if}
</div>
{#if timer.run_name}<div class="muted">Run : {timer.run_name}</div>{/if}
{#if timer.splits.some((s) => s > 0)}
<div class="splits">
{#each timer.splits as s, i}
{#if s > 0}<span class="split">A{i}: {fmtTime(s)}</span>{/if}
{/each}
</div>
{/if}
</div>
{:else}
<p class="muted">Aucun run en cours. Le timer démarre tout seul en entrant dans la première zone de campagne, ou via « Démarrer ».</p>
{/if}
<div class="row">
<button onclick={() => timerStart()}> Démarrer / redémarrer</button>
<button onclick={() => timerPause()} disabled={!timer || !timer.active}>
{timer && timer.manual_pause ? "Reprendre" : "⏸ Pause"}
</button>
<button onclick={() => timerReset()} disabled={!timer || !timer.active}>↺ Reset</button>
</div>
<label>Hotkey « pause timer »
<input type="text" bind:value={config.hotkey_timer_pause} onchange={save} />
</label>
{/if}
</section>
<section>
<h2>Fichier de log (Client.txt)</h2>
<div class="row">
<input
type="text"
bind:value={config.log_path}
placeholder="/chemin/vers/Path of Exile 2/logs/Client.txt"
style="flex:1"
/>
<button onclick={save}>Enregistrer</button>
</div>
{#if detected.length}
<label>Détectés :
<select onchange={(e) => { if (config) { config.log_path = (e.target as HTMLSelectElement).value; save(); } }}>
<option value="">— choisir —</option>
{#each detected as d}<option value={d}>{d}</option>{/each}
</select>
</label>
{:else}
<p class="muted">Aucun log détecté automatiquement. Renseigne le chemin manuellement.</p>
{/if}
<button onclick={refreshDetect}>Re-scanner</button>
</section>
<section>
<h2>Réglages</h2>
<label class="ck">
<input type="checkbox" bind:checked={config.overlay_only_when_focused} onchange={save} />
Afficher l'overlay uniquement quand le jeu est au premier plan
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.league_start} onchange={save} />
Mode « league-start » (profil) — sinon branche non-league-start du guide
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.show_optionals} onchange={save} />
Afficher le contenu optionnel (profil) — loot/quêtes/rencontres « + »
</label>
<label class="ck">
<input type="checkbox" bind:checked={config.show_recommendation} onchange={save} />
Afficher les niveaux recommandés par zone
</label>
<label>Titre fenêtre du jeu (détection focus)
<input type="text" bind:value={config.poe_window_match} onchange={save} />
</label>
<div class="grid">
<label>Largeur overlay (px)
<input type="number" bind:value={config.overlay_width} onchange={save} />
</label>
<label>Taille police (px)
<input type="number" bind:value={config.overlay_font_size} onchange={save} />
</label>
<label>Étapes à anticiper
<input type="number" min="0" max="6" bind:value={config.lookahead} onchange={save} />
</label>
</div>
<div class="grid">
<label>Hotkey « suivant »
<input type="text" bind:value={config.hotkey_next} onchange={save} />
</label>
<label>Hotkey « précédent »
<input type="text" bind:value={config.hotkey_prev} onchange={save} />
</label>
<label>Hotkey « afficher/masquer »
<input type="text" bind:value={config.hotkey_toggle} onchange={save} />
</label>
</div>
<p class="muted">Format hotkey : ex. <code>Alt+X</code>, <code>Control+Shift+G</code>, <code>F8</code>.</p>
</section>
{/if}
</main>
<style>
main {
background: var(--bg);
min-height: 100vh;
padding: 16px 22px;
max-width: 760px;
}
h1 {
margin: 0 0 12px;
font-size: 1.4em;
color: var(--accent);
}
h1 small {
color: var(--muted);
font-size: 0.6em;
}
h2 {
font-size: 1.05em;
color: var(--accent2);
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
}
section {
margin-bottom: 18px;
}
.status {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 14px;
line-height: 1.6;
}
.hl {
color: var(--accent2);
}
.muted {
color: var(--muted);
font-size: 0.9em;
}
.row {
display: flex;
gap: 8px;
align-items: center;
margin: 6px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 6px 0;
}
label {
display: block;
margin: 6px 0;
font-size: 0.92em;
}
label.ck {
display: flex;
gap: 6px;
align-items: center;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
background: #100d09;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 5px 7px;
}
label.ck input {
width: auto;
}
button {
background: #2a2218;
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
.prog {
color: var(--muted);
}
.timer-box {
margin: 8px 0;
}
.splits {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.split {
background: #100d09;
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-size: 0.85em;
}
.flag {
margin-left: 8px;
color: #e0c84a;
text-transform: uppercase;
font-size: 0.85em;
}
.flag.done {
color: #7cfc00;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
code {
background: #100d09;
padding: 1px 4px;
border-radius: 3px;
}
.char-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 0;
}
.char {
display: flex;
align-items: stretch;
gap: 4px;
}
.char.active .char-pick {
border-color: var(--accent);
background: rgba(200, 162, 74, 0.12);
}
button.char-pick {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
text-align: left;
gap: 10px;
}
button.del {
color: #e05555;
padding: 6px 10px;
}
</style>

92
src/lib/StepView.svelte Normal file
View File

@ -0,0 +1,92 @@
<script lang="ts">
import type { Area, Step } from "./api";
import { renderLine } from "./markup";
let {
step,
areas,
current = false,
showOptionals = true,
}: {
step: Step;
areas: Record<string, Area>;
current?: boolean;
showOptionals?: boolean;
} = $props();
const lines = $derived(
step.lines
.map((l) => renderLine(l, areas))
.filter((l) => showOptionals || l.kind !== "optional"),
);
</script>
<div class="step" class:current>
{#each lines as line}
<div class="line {line.kind}" class:indent={line.indent > 0}>
{@html line.html}
</div>
{/each}
</div>
<style>
.step {
padding: 6px 10px;
border-left: 3px solid transparent;
}
.step.current {
border-left-color: var(--accent);
background: rgba(200, 162, 74, 0.08);
}
.line {
line-height: 1.35;
margin: 1px 0;
}
.line.hint {
color: var(--muted);
font-size: 0.9em;
}
.line.optional {
color: #9fb88f;
font-size: 0.95em;
}
.line.info {
color: #d8a0a0;
font-size: 0.95em;
}
.line.indent {
padding-left: 16px;
}
:global(.step .area) {
color: var(--accent2);
font-weight: 600;
text-transform: capitalize;
}
:global(.step .chip) {
display: inline-block;
padding: 0 5px;
margin: 0 1px;
border-radius: 4px;
font-size: 0.82em;
vertical-align: middle;
background: #2a2218;
border: 1px solid var(--border);
}
:global(.step .chip.quest) {
background: #2a2030;
border-color: #5a4a6a;
color: #c9a6e0;
}
:global(.step .chip.img) {
background: #23282e;
border-color: #3a4654;
color: #a9c4dd;
}
:global(.step .icon) {
height: 1.25em;
width: auto;
vertical-align: -0.25em;
margin: 0 1px;
image-rendering: -webkit-optimize-contrast;
}
</style>

140
src/lib/api.ts Normal file
View File

@ -0,0 +1,140 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface Config {
log_path: string | null;
poe_window_match: string;
overlay_only_when_focused: boolean;
overlay_x: number;
overlay_y: number;
overlay_width: number;
overlay_font_size: number;
hotkey_next: string;
hotkey_prev: string;
hotkey_toggle: string;
hotkey_timer_pause: string;
hotkey_layout: string;
feature_layouts: boolean;
layout_x: number;
layout_y: number;
layout_size: number;
timer_enabled: boolean;
timer_pause_in_town: boolean;
timer_afk_enabled: boolean;
timer_afk_seconds: number;
timer_pause_unfocused: boolean;
current_step: number;
show_optionals: boolean;
league_start: boolean;
lookahead: number;
show_recommendation: boolean;
active_character: string | null;
profiles: Profile[];
}
export interface Profile {
name: string;
current_step: number;
league_start: boolean;
show_optionals: boolean;
}
export interface Status {
character: string;
class: string;
level: number;
area_id: string;
area_name: string;
area_level: number;
area_seed: string;
act: number;
game_focused: boolean;
}
export interface Area {
id: string;
name: string;
recommendation: string | null;
group: number;
}
export interface Step {
lines: string[];
section: number;
target_area: string | null;
}
export interface GuideData {
steps: Step[];
areas: Record<string, Area>;
section_count: number;
}
export interface RunTimer {
active: boolean;
finished: boolean;
manual_pause: boolean;
auto_paused: boolean;
afk_paused: boolean;
total_seconds: number;
act_seconds: number;
current_act: number;
splits: number[];
run_name: string | null;
}
export const getConfig = () => invoke<Config>("get_config");
export const saveConfig = (cfg: Config) => invoke("save_config", { new: cfg });
export const getStatus = () => invoke<Status>("get_status");
export const getGuide = () => invoke<GuideData>("get_guide");
export const getGems = () => invoke<Record<string, Record<string, number[]>>>("get_gems");
export const detectLogs = () => invoke<string[]>("detect_logs");
export const getTimer = () => invoke<RunTimer>("get_timer");
export const timerStart = () => invoke("timer_start");
export const timerPause = () => invoke("timer_pause");
export const timerReset = () => invoke("timer_reset");
export const stepDelta = (delta: number) => invoke("step_delta", { delta });
export const stepGoto = (newIndex: number) => invoke("step_goto", { new: newIndex });
export const createProfile = (name: string, leagueStart: boolean) =>
invoke("create_profile", { name, leagueStart });
export const deleteProfile = (name: string) => invoke("delete_profile", { name });
export const selectProfile = (name: string) => invoke("select_profile", { name });
export const setOverlayLocked = (locked: boolean) => invoke("set_overlay_locked", { locked });
export const toggleOverlay = () => invoke("toggle_overlay");
export const saveOverlayGeometry = () => invoke("save_overlay_geometry");
export const setOverlayHeight = (height: number) => invoke("set_overlay_height", { height });
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 saveLayoutGeometry = () => invoke("save_layout_geometry");
export const startLayoutDrag = () => invoke("start_layout_drag");
export const endLayoutDrag = () => invoke("end_layout_drag");
// Event helpers ------------------------------------------------------------
export const onStatus = (cb: (s: Status) => void): Promise<UnlistenFn> =>
listen<Status>("status://update", (e) => cb(e.payload));
export const onStep = (cb: (i: number) => void): Promise<UnlistenFn> =>
listen<number>("tracker://step", (e) => cb(e.payload));
export const onConfig = (cb: (c: Config) => void): Promise<UnlistenFn> =>
listen<Config>("config://update", (e) => cb(e.payload));
export const onFocus = (cb: (f: boolean) => void): Promise<UnlistenFn> =>
listen<boolean>("focus://update", (e) => cb(e.payload));
export const onOverlayToggle = (cb: () => void): Promise<UnlistenFn> =>
listen("overlay://toggle", () => cb());
export const onOverlayLocked = (cb: (locked: boolean) => void): Promise<UnlistenFn> =>
listen<boolean>("overlay://locked", (e) => cb(e.payload));
export const onGuideReload = (cb: () => void): Promise<UnlistenFn> =>
listen("guide://reload", () => cb());
export const onTimer = (cb: (t: RunTimer) => void): Promise<UnlistenFn> =>
listen<RunTimer>("timer://update", (e) => cb(e.payload));
/** Format seconds as h:mm:ss or m:ss. */
export function fmtTime(total: number): string {
const s = Math.floor(total);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => n.toString().padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}

75
src/lib/layouts.ts Normal file
View File

@ -0,0 +1,75 @@
// Zone-layout decision tree, ported from the AHK `act-decoder`.
//
// The bundled PoE2 layout images (static/layouts/) are named `<areaID> <path>.jpg`
// 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
// 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.)
export type LayoutManifest = Record<string, string[]>; // areaID -> sorted paths
let manifestPromise: Promise<LayoutManifest> | null = null;
/** Load (once) the areaID -> paths manifest generated at build time. */
export function loadManifest(): Promise<LayoutManifest> {
if (!manifestPromise) {
manifestPromise = fetch("/layouts/index.json")
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
}
return manifestPromise;
}
/** URL of the image for a given area + decision path. */
export function imageUrl(areaId: string, path: string): string {
return `/layouts/${areaId}~${path}.jpg`;
}
const depth = (p: string): number => (p === "" ? 0 : p.split("_").length);
/** Order paths numerically per segment, "x" sorting last. */
function sortPaths(a: string, b: string): number {
const sa = a.split("_");
const sb = b.split("_");
const n = Math.min(sa.length, sb.length);
for (let i = 0; i < n; i++) {
if (sa[i] !== sb[i]) {
const na = sa[i] === "x" ? Infinity : parseInt(sa[i], 10);
const nb = sb[i] === "x" ? Infinity : parseInt(sb[i], 10);
return na - nb;
}
}
return sa.length - sb.length;
}
/**
* Direct children of `path` ("" = root) among `paths`: entries one segment
* deeper that extend `path`.
*/
export function childrenOf(paths: string[], path: string): string[] {
const want = depth(path) + 1;
const prefix = path === "" ? "" : path + "_";
return paths
.filter((p) => p !== path && depth(p) === want && (path === "" || p.startsWith(prefix)))
.sort(sortPaths);
}
/** A path is an "x" (none-of-these / not catalogued) marker. */
export function isDeadEnd(path: string): boolean {
return path.split("_")[0] === "x";
}
// Zones whose layout placement is fixed (or where rotation gave wrong results in
// the original), so the viewer hides its rotation controls. Ported from the AHK
// `rota_block`; keys there may be "<areaID>" or "<areaID> <path>" — we block the
// whole area if any key matches (a single orientation is applied per zone).
const ROTA_BLOCK_AREAS = new Set([
"g1_2", "g1_4", "g1_7", "g1_8", "g1_11", "g1_12", "g1_14", "g1_15",
"g2_2", "g2_4_3", "g2_6", "g3_11", "g3_16",
]);
export function rotationBlocked(areaId: string): boolean {
return ROTA_BLOCK_AREAS.has(areaId);
}

129
src/lib/markup.ts Normal file
View File

@ -0,0 +1,129 @@
import type { Area } from "./api";
// Named colors used by the guide markup; anything else that is 3/6 hex chars
// is treated as a hex color.
const COLOR_NAMES: Record<string, string> = {
red: "#e05555",
lime: "#7CFC00",
aqua: "#55e0e0",
yellow: "#e0c84a",
green: "#5fbf5f",
white: "#ffffff",
orange: "#e08a3c",
};
function resolveColor(name: string): string {
if (COLOR_NAMES[name]) return COLOR_NAMES[name];
if (/^[0-9a-fA-F]{6}$/.test(name) || /^[0-9a-fA-F]{3}$/.test(name)) return "#" + name;
return "inherit";
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Icon names available under static/icons/leveling/ (from the original AHK
// "leveling tracker" assets). Only these render as real <img>; anything else
// falls back to a text chip so unknown markup never shows a broken image.
const ICONS = new Set([
"0", "1", "2", "3", "4", "5", "6", "7",
"arena", "artificer", "b-rune", "checkpoint", "craft", "exa", "flasks",
"gcp", "hideout", "in-out", "in-out2", "jeweller", "lab", "portal",
"quest", "quest_2", "regal", "ring", "rune", "skill", "skill2", "skip",
"spirit", "spirit2", "support", "support2", "town", "waypoint",
]);
// Underscores stand in for spaces in plain guide text.
function plain(text: string, color: string): string {
const t = escapeHtml(text.replace(/_/g, " "));
if (!t) return "";
if (color === "inherit") return t;
return `<span style="color:${color}">${t}</span>`;
}
export interface RenderedLine {
html: string;
kind: "normal" | "hint" | "optional" | "info";
indent: number;
}
const TOKEN_RE = /\(([a-z]+)(?::([^)]*))?\)|areaid([a-z0-9_]+)/gi;
/** Render a single guide line (with embedded markup) into HTML + metadata. */
export function renderLine(raw: string, areas: Record<string, Area>): RenderedLine {
let line = raw;
// Classify the line for styling.
let kind: RenderedLine["kind"] = "normal";
let indent = 0;
const lower = line.toLowerCase();
if (lower.startsWith("(hint)")) {
kind = "hint";
line = line.slice("(hint)".length);
// leading underscores after (hint) indicate indentation
const m = line.match(/^_+/);
if (m) {
indent = 1;
line = line.slice(m[0].length);
}
} else if (lower.startsWith("optional:")) {
kind = "optional";
} else if (lower.includes("info:")) {
kind = "info";
}
// Pull out the trailing " ;; area name" annotation.
let areaName: string | null = null;
const sc = line.indexOf(";;");
if (sc >= 0) {
areaName = line.slice(sc + 2).trim();
line = line.slice(0, sc).trim();
}
let out = "";
let color = "inherit";
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(line)) !== null) {
out += plain(line.slice(last, m.index), color);
last = m.index + m[0].length;
const kindTok = m[1]?.toLowerCase();
const val = m[2] ?? "";
const areaId = m[3];
if (areaId !== undefined) {
const name = areaName || areas[areaId]?.name || areaId.replace(/_/g, " ");
out += `<span class="area">${escapeHtml(name)}</span>`;
areaName = null; // consume once
} else if (kindTok === "color") {
color = resolveColor(val.trim());
} else if (kindTok === "img") {
const name = val.trim();
const label = name.replace(/_/g, " ");
if (ICONS.has(name.toLowerCase())) {
out += `<img class="icon" src="/icons/leveling/${encodeURIComponent(
name.toLowerCase()
)}.png" alt="${escapeHtml(label)}" title="${escapeHtml(label)}" />`;
} else {
out += `<span class="chip img" data-img="${escapeHtml(name)}">${escapeHtml(
label
)}</span>`;
}
} else if (kindTok === "quest") {
out += `<span class="chip quest">${escapeHtml(val.replace(/_/g, " "))}</span>`;
} else if (kindTok === "emph") {
// ignored: treated as a plain emphasis marker
}
// (hint) handled at line level above
}
out += plain(line.slice(last), color);
// Any leftover area name annotation with no areaid token: append it.
if (areaName) {
out += ` <span class="area">${escapeHtml(areaName)}</span>`;
}
return { html: out, kind, indent };
}

View File

@ -0,0 +1,6 @@
<script lang="ts">
import "../app.css";
let { children } = $props();
</script>
{@render children()}

5
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

17
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,17 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import Settings from "$lib/Settings.svelte";
import Overlay from "$lib/Overlay.svelte";
import Layouts from "$lib/Layouts.svelte";
// All windows load the same SPA entry; pick the view by window label.
const label = getCurrentWindow().label;
</script>
{#if label === "overlay"}
<Overlay />
{:else if label === "layout"}
<Layouts />
{:else}
<Settings />
{/if}