Initial commit
This commit is contained in:
37
src/App.tsx
Normal file
37
src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from './store/appStore';
|
||||
import AppShell from './components/layout/AppShell';
|
||||
import ConfigScreen from './components/config/ConfigScreen';
|
||||
|
||||
export default function App() {
|
||||
const { isConfigured, setIsConfigured, setUoRoot, setServuoScripts, setCenterMode } =
|
||||
useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const uo = await invoke<string | null>('get_config', { key: 'uo_root' });
|
||||
const scripts = await invoke<string | null>('get_config', { key: 'seruo_scripts' });
|
||||
|
||||
if (uo && scripts) {
|
||||
setUoRoot(uo);
|
||||
setServuoScripts(scripts);
|
||||
setIsConfigured(true);
|
||||
} else {
|
||||
setCenterMode('config');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load config:', e);
|
||||
setCenterMode('config');
|
||||
}
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
if (!isConfigured) {
|
||||
return <ConfigScreen />;
|
||||
}
|
||||
|
||||
return <AppShell />;
|
||||
}
|
||||
63
src/components/asset/ItemPreview.module.css
Normal file
63
src/components/asset/ItemPreview.module.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.artCanvas {
|
||||
image-rendering: pixelated;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff8080;
|
||||
}
|
||||
|
||||
.noArt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
90
src/components/asset/ItemPreview.tsx
Normal file
90
src/components/asset/ItemPreview.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { TileInfo } from '../../types/assets';
|
||||
import styles from './ItemPreview.module.css';
|
||||
|
||||
interface ArtImageResult {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tile: TileInfo;
|
||||
}
|
||||
|
||||
export default function ItemPreview({ tile }: Props) {
|
||||
const { uoRoot } = useAppStore();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// null = ok, 'none' = no art (show placeholder), string = real error
|
||||
const [artState, setArtState] = useState<null | 'none' | string>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uoRoot) return;
|
||||
|
||||
async function loadArt() {
|
||||
setLoading(true);
|
||||
setArtState(null);
|
||||
try {
|
||||
const result = await invoke<ArtImageResult>('get_tile_art', {
|
||||
itemId: tile.id,
|
||||
uoRoot,
|
||||
});
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = result.width;
|
||||
canvas.height = result.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.createImageData(result.width, result.height);
|
||||
imageData.data.set(result.pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
} catch {
|
||||
// Any failure (no art data, unsupported format, buffer error, etc.)
|
||||
// → show placeholder silently. The art source may just not have this item.
|
||||
setArtState('none');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadArt();
|
||||
}, [tile.id, uoRoot]);
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<span className={`${styles.name} font-cinzel`}>{tile.name || '(unnamed)'}</span>
|
||||
<span className={styles.id}>#{tile.id}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.canvas}>
|
||||
{loading && <div className={styles.loading}>Loading art…</div>}
|
||||
{artState === 'none' && (
|
||||
<div className={styles.noArt}>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="4" width="40" height="40" rx="4"
|
||||
stroke="var(--border-accent)" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<path d="M16 32 L24 16 L32 32 Z"
|
||||
stroke="var(--text-muted)" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
<span>No art data</span>
|
||||
</div>
|
||||
)}
|
||||
{artState && artState !== 'none' && (
|
||||
<div className={styles.error}>{artState}</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.artCanvas}
|
||||
style={{ display: loading || artState ? 'none' : 'block' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/asset/StaticBrowser.module.css
Normal file
58
src/components/asset/StaticBrowser.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
padding: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--accent-gold);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
}
|
||||
107
src/components/asset/StaticBrowser.tsx
Normal file
107
src/components/asset/StaticBrowser.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { TileInfo } from '../../types/assets';
|
||||
import styles from './StaticBrowser.module.css';
|
||||
|
||||
interface TileRow {
|
||||
id: number;
|
||||
name: string;
|
||||
flags: number;
|
||||
weight: number;
|
||||
quality: number;
|
||||
height: number;
|
||||
hue: number;
|
||||
anim_id: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
}
|
||||
|
||||
export default function StaticBrowser({ search }: Props) {
|
||||
const { setSelectedTile, setCenterMode } = useAppStore();
|
||||
const [tiles, setTiles] = useState<TileRow[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const loadTiles = useCallback(async (newOffset: number, q: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await invoke<TileRow[]>('list_static_tiles', {
|
||||
offset: newOffset,
|
||||
limit: PAGE_SIZE,
|
||||
search: q || null,
|
||||
});
|
||||
if (newOffset === 0) {
|
||||
setTiles(rows);
|
||||
} else {
|
||||
setTiles((prev) => [...prev, ...rows]);
|
||||
}
|
||||
setHasMore(rows.length === PAGE_SIZE);
|
||||
} catch {
|
||||
// Index not yet built — show empty state
|
||||
setTiles([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
loadTiles(0, search);
|
||||
}, [search, loadTiles]);
|
||||
|
||||
function selectTile(row: TileRow) {
|
||||
const tile: TileInfo = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
flags: row.flags,
|
||||
weight: row.weight,
|
||||
quality: row.quality,
|
||||
height: row.height,
|
||||
hue: row.hue,
|
||||
animId: row.anim_id,
|
||||
};
|
||||
setSelectedTile(tile);
|
||||
setCenterMode('asset_static');
|
||||
}
|
||||
|
||||
if (!loading && tiles.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
No tiles indexed yet.
|
||||
<br />
|
||||
Click [Index] in the toolbar after setting up your paths.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{tiles.map((t) => (
|
||||
<button key={t.id} className={styles.row} onClick={() => selectTile(t)}>
|
||||
<span className={styles.id}>{t.id}</span>
|
||||
<span className={styles.name}>{t.name || '(unnamed)'}</span>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
className={styles.loadMore}
|
||||
onClick={() => {
|
||||
const next = offset + PAGE_SIZE;
|
||||
setOffset(next);
|
||||
loadTiles(next, search);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/config/ConfigScreen.module.css
Normal file
131
src/components/config/ConfigScreen.module.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-base);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-accent);
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pathInput {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: var(--accent-gold);
|
||||
border-color: var(--accent-gold);
|
||||
color: var(--bg-base);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: var(--accent-gold-bright);
|
||||
border-color: var(--accent-gold-bright);
|
||||
color: var(--bg-base);
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.validationList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.validationItem {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.validationFile {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.validationMsg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--accent-red);
|
||||
border: 1px solid #b04040;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
color: #ffd0d0;
|
||||
}
|
||||
132
src/components/config/ConfigScreen.tsx
Normal file
132
src/components/config/ConfigScreen.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import styles from './ConfigScreen.module.css';
|
||||
|
||||
interface ValidationResult {
|
||||
file: string;
|
||||
status: 'ok' | 'warn' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
export default function ConfigScreen({ onDone }: Props) {
|
||||
const { uoRoot, servuoScripts, setUoRoot, setServuoScripts, setIsConfigured } = useAppStore();
|
||||
const [uoResults, setUoResults] = useState<ValidationResult[]>([]);
|
||||
const [scriptResults, setScriptResults] = useState<ValidationResult[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function pickUoRoot() {
|
||||
const dir = await open({ directory: true, multiple: false, title: 'Select UO Client Folder' });
|
||||
if (!dir || Array.isArray(dir)) return;
|
||||
setUoRoot(dir);
|
||||
const results = await invoke<ValidationResult[]>('validate_uo_root', { path: dir });
|
||||
setUoResults(results);
|
||||
}
|
||||
|
||||
async function pickScriptsPath() {
|
||||
const dir = await open({ directory: true, multiple: false, title: 'Select ServUO Scripts Folder' });
|
||||
if (!dir || Array.isArray(dir)) return;
|
||||
setServuoScripts(dir);
|
||||
const results = await invoke<ValidationResult[]>('validate_scripts_path', { path: dir });
|
||||
setScriptResults(results);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke('set_config', { key: 'uo_root', value: uoRoot });
|
||||
await invoke('set_config', { key: 'seruo_scripts', value: servuoScripts });
|
||||
setIsConfigured(true);
|
||||
onDone?.();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSave =
|
||||
uoRoot &&
|
||||
servuoScripts &&
|
||||
!uoResults.some((r) => r.status === 'error') &&
|
||||
!scriptResults.some((r) => r.status === 'error');
|
||||
|
||||
return (
|
||||
<div className={styles.screen}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={`${styles.title} font-cinzel`}>Configuration</h1>
|
||||
<p className={styles.subtitle}>Set paths to your UO client and ServUO Scripts folder.</p>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>UO Client Root</label>
|
||||
<div className={styles.row}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={uoRoot}
|
||||
placeholder="Not set"
|
||||
className={styles.pathInput}
|
||||
/>
|
||||
<button className={styles.btn} onClick={pickUoRoot}>Browse…</button>
|
||||
</div>
|
||||
{uoResults.length > 0 && <ValidationList results={uoResults} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>ServUO Scripts Path</label>
|
||||
<div className={styles.row}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={servuoScripts}
|
||||
placeholder="Not set"
|
||||
className={styles.pathInput}
|
||||
/>
|
||||
<button className={styles.btn} onClick={pickScriptsPath}>Browse…</button>
|
||||
</div>
|
||||
{scriptResults.length > 0 && <ValidationList results={scriptResults} />}
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{onDone && (
|
||||
<button className={`${styles.btn} ${styles.btnSecondary}`} onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationList({ results }: { results: ValidationResult[] }) {
|
||||
return (
|
||||
<ul className={styles.validationList}>
|
||||
{results.map((r, i) => (
|
||||
<li key={i} className={styles.validationItem} data-status={r.status}>
|
||||
<span className={styles.validationIcon}>
|
||||
{r.status === 'ok' ? '✅' : r.status === 'warn' ? '⚠️' : '❌'}
|
||||
</span>
|
||||
<span className={styles.validationFile}>{r.file}</span>
|
||||
{r.message && <span className={styles.validationMsg}>{r.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
38
src/components/flow/FakeDataInputs.module.css
Normal file
38
src/components/flow/FakeDataInputs.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.panel {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 10px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
43
src/components/flow/FakeDataInputs.tsx
Normal file
43
src/components/flow/FakeDataInputs.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { FlowNode } from '../../types/scripts';
|
||||
import styles from './FakeDataInputs.module.css';
|
||||
|
||||
function collectFakeKeys(node: FlowNode, keys: Set<string>) {
|
||||
if (node.fakeInputKey) keys.add(node.fakeInputKey);
|
||||
for (const child of node.children) collectFakeKeys(child, keys);
|
||||
}
|
||||
|
||||
export default function FakeDataInputs() {
|
||||
const { flowRoot, fakeData, setFakeData } = useAppStore();
|
||||
|
||||
const keys = useMemo(() => {
|
||||
if (!flowRoot) return [];
|
||||
const set = new Set<string>();
|
||||
collectFakeKeys(flowRoot, set);
|
||||
return Array.from(set).sort();
|
||||
}, [flowRoot]);
|
||||
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={`${styles.title} font-cinzel`}>Fake Data</div>
|
||||
<div className={styles.hint}>
|
||||
Set values to highlight condition branches
|
||||
</div>
|
||||
{keys.map((key) => (
|
||||
<div key={key} className={styles.row}>
|
||||
<label className={styles.label}>{key}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={fakeData[key] ?? ''}
|
||||
placeholder="value…"
|
||||
onChange={(e) => setFakeData(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/flow/FlowViewer.module.css
Normal file
99
src/components/flow/FlowViewer.module.css
Normal file
@@ -0,0 +1,99 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.methodLabel {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.04em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
padding: 4px 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolBtn:hover {
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
font-family: 'Cinzel', serif;
|
||||
color: #cc6666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retryBtn {
|
||||
padding: 6px 16px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-accent);
|
||||
color: var(--accent-gold);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
284
src/components/flow/FlowViewer.tsx
Normal file
284
src/components/flow/FlowViewer.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { FlowNode } from '../../types/scripts';
|
||||
import styles from './FlowViewer.module.css';
|
||||
|
||||
// ── Layout constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_W = 220;
|
||||
const NODE_H = 44;
|
||||
const H_GAP = 30; // horizontal gap between sibling subtrees
|
||||
const V_GAP = 60; // vertical gap between levels
|
||||
|
||||
// ── Layout engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface LayoutNode {
|
||||
node: FlowNode;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number; // subtree width
|
||||
}
|
||||
|
||||
function layoutTree(
|
||||
node: FlowNode,
|
||||
depth: number,
|
||||
xOffset: number
|
||||
): { positioned: LayoutNode[]; subtreeWidth: number } {
|
||||
if (node.children.length === 0) {
|
||||
return {
|
||||
positioned: [{ node, x: xOffset, y: depth * (NODE_H + V_GAP), width: NODE_W }],
|
||||
subtreeWidth: NODE_W,
|
||||
};
|
||||
}
|
||||
|
||||
let childX = xOffset;
|
||||
const allPositioned: LayoutNode[] = [];
|
||||
let totalWidth = 0;
|
||||
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i];
|
||||
const result = layoutTree(child, depth + 1, childX);
|
||||
allPositioned.push(...result.positioned);
|
||||
childX += result.subtreeWidth + (i < node.children.length - 1 ? H_GAP : 0);
|
||||
totalWidth += result.subtreeWidth + (i < node.children.length - 1 ? H_GAP : 0);
|
||||
}
|
||||
|
||||
// Center this node over its children
|
||||
const subtreeWidth = Math.max(totalWidth, NODE_W);
|
||||
const nodeX = xOffset + (subtreeWidth - NODE_W) / 2;
|
||||
|
||||
allPositioned.push({
|
||||
node,
|
||||
x: nodeX,
|
||||
y: depth * (NODE_H + V_GAP),
|
||||
width: subtreeWidth,
|
||||
});
|
||||
|
||||
return { positioned: allPositioned, subtreeWidth };
|
||||
}
|
||||
|
||||
function buildEdges(positioned: LayoutNode[]): Array<{ x1: number; y1: number; x2: number; y2: number }> {
|
||||
const posMap = new Map<string, LayoutNode>();
|
||||
for (const p of positioned) posMap.set(p.node.id, p);
|
||||
|
||||
const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
||||
|
||||
function walk(node: FlowNode) {
|
||||
const parent = posMap.get(node.id);
|
||||
if (!parent) return;
|
||||
for (const child of node.children) {
|
||||
const c = posMap.get(child.id);
|
||||
if (c) {
|
||||
edges.push({
|
||||
x1: parent.x + NODE_W / 2,
|
||||
y1: parent.y + NODE_H,
|
||||
x2: c.x + NODE_W / 2,
|
||||
y2: c.y,
|
||||
});
|
||||
}
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
for (const p of positioned) walk(p.node);
|
||||
return edges;
|
||||
}
|
||||
|
||||
// ── Node colors by type ───────────────────────────────────────────────────────
|
||||
|
||||
function nodeStyle(type: FlowNode['type']): { fill: string; stroke: string; text: string } {
|
||||
switch (type) {
|
||||
case 'gump_send':
|
||||
return { fill: '#1a1a2e', stroke: '#8888ff', text: '#aaaaff' };
|
||||
case 'condition':
|
||||
return { fill: '#1e1a10', stroke: '#c8a84b', text: '#e8c86b' };
|
||||
case 'branch_true':
|
||||
return { fill: '#0d1a0d', stroke: '#3a6b3a', text: '#88cc88' };
|
||||
case 'branch_false':
|
||||
return { fill: '#1a0d0d', stroke: '#6b3a3a', text: '#cc8888' };
|
||||
case 'branch_case':
|
||||
return { fill: '#0d1520', stroke: '#3a5a7a', text: '#8aaabb' };
|
||||
case 'return':
|
||||
return { fill: '#1a1010', stroke: '#8b3a3a', text: '#cc6666' };
|
||||
case 'property_access':
|
||||
return { fill: '#141418', stroke: '#4a4060', text: '#8a7ab0' };
|
||||
default:
|
||||
return { fill: '#161410', stroke: '#3a3020', text: '#d4c49a' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FlowViewer() {
|
||||
const { selectedMethod, selectedClass, flowRoot, setFlowRoot, fakeData } = useAppStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 20, y: 20 });
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const traceMethod = useCallback(async () => {
|
||||
if (!selectedClass || !selectedMethod) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const node = await invoke<FlowNode>('trace_method', {
|
||||
className: selectedClass.name,
|
||||
methodName: selectedMethod.name,
|
||||
});
|
||||
setFlowRoot(node);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setFlowRoot(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedClass?.name, selectedMethod?.name]);
|
||||
|
||||
// Auto-trace when selection changes
|
||||
useEffect(() => {
|
||||
if (selectedClass && selectedMethod) {
|
||||
traceMethod();
|
||||
}
|
||||
}, [selectedClass?.name, selectedMethod?.name]);
|
||||
|
||||
// SVG pan & zoom
|
||||
function onWheel(e: React.WheelEvent) {
|
||||
e.preventDefault();
|
||||
setScale((s) => Math.min(2, Math.max(0.3, s - e.deltaY * 0.001)));
|
||||
}
|
||||
|
||||
function onMouseDown(e: React.MouseEvent) {
|
||||
isPanning.current = true;
|
||||
panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
|
||||
function onMouseMove(e: React.MouseEvent) {
|
||||
if (!isPanning.current) return;
|
||||
setPan({ x: e.clientX - panStart.current.x, y: e.clientY - panStart.current.y });
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isPanning.current = false;
|
||||
}
|
||||
|
||||
if (!selectedClass || !selectedMethod) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
Select a method from the left panel to trace its call chain.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.empty}>Tracing {selectedMethod.name}…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.errorWrapper}>
|
||||
<div className={styles.errorTitle}>Trace failed</div>
|
||||
<div className={styles.errorMsg}>{error}</div>
|
||||
<button className={styles.retryBtn} onClick={traceMethod}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!flowRoot) return null;
|
||||
|
||||
const { positioned } = layoutTree(flowRoot, 0, 0);
|
||||
const edges = buildEdges(positioned);
|
||||
|
||||
const maxX = Math.max(...positioned.map((p) => p.x + NODE_W)) + 40;
|
||||
const maxY = Math.max(...positioned.map((p) => p.y + NODE_H)) + 40;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<span className={`${styles.methodLabel} font-cinzel`}>
|
||||
{selectedClass.name}.{selectedMethod.name}
|
||||
</span>
|
||||
<button className={styles.toolBtn} onClick={traceMethod}>↺ Retrace</button>
|
||||
<button className={styles.toolBtn} onClick={() => { setScale(1); setPan({ x: 20, y: 20 }); }}>
|
||||
Reset View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SVG canvas */}
|
||||
<div
|
||||
className={styles.canvas}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={maxX}
|
||||
height={maxY}
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, transformOrigin: '0 0' }}
|
||||
>
|
||||
{/* Edges */}
|
||||
{edges.map((e, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${e.x1},${e.y1} C${e.x1},${(e.y1 + e.y2) / 2} ${e.x2},${(e.y1 + e.y2) / 2} ${e.x2},${e.y2}`}
|
||||
stroke="var(--border-accent)"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
opacity={0.6}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Nodes */}
|
||||
{positioned.map(({ node, x, y }) => {
|
||||
const s = nodeStyle(node.type);
|
||||
const isFakeActive =
|
||||
node.fakeInputKey && fakeData[node.fakeInputKey] !== undefined;
|
||||
|
||||
return (
|
||||
<g key={node.id} transform={`translate(${x},${y})`}>
|
||||
<rect
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={4}
|
||||
fill={s.fill}
|
||||
stroke={isFakeActive ? '#e8c86b' : s.stroke}
|
||||
strokeWidth={isFakeActive ? 2 : 1}
|
||||
/>
|
||||
<foreignObject x={4} y={4} width={NODE_W - 8} height={NODE_H - 8}>
|
||||
<div
|
||||
style={{
|
||||
color: s.text,
|
||||
fontSize: '11px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
lineHeight: 1.3,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '2px 4px',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
title={node.label}
|
||||
>
|
||||
<span style={{ opacity: 0.6, fontSize: '9px' }}>{node.type} </span>
|
||||
{node.label}
|
||||
{node.resolvedGump && (
|
||||
<div style={{ fontSize: '9px', opacity: 0.7, marginTop: 2 }}>
|
||||
→ {node.resolvedGump}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/gump/GumpBrowser.module.css
Normal file
150
src/components/gump/GumpBrowser.module.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subTabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subTab {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.subTab:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.subTab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.subTabActive {
|
||||
color: var(--accent-gold);
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 2px solid var(--accent-gold);
|
||||
}
|
||||
|
||||
.tagBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: 'Cinzel', serif;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-accent);
|
||||
}
|
||||
|
||||
.tagActive {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-gold);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 10px;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
/* Art gump items: id + name side by side */
|
||||
.item > .itemId + .itemName {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.itemId {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent-gold);
|
||||
flex-shrink: 0;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-family: 'EB Garamond', serif;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemPath {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 16px;
|
||||
color: var(--accent-red);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
180
src/components/gump/GumpBrowser.tsx
Normal file
180
src/components/gump/GumpBrowser.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore, type GumpEntry, type ScriptGumpEntry } from '../../store/appStore';
|
||||
import styles from './GumpBrowser.module.css';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
}
|
||||
|
||||
type GumpTab = 'art' | 'scripts';
|
||||
|
||||
export default function GumpBrowser({ search }: Props) {
|
||||
const { setSelectedGump, setCenterMode, setSelectedScriptGump } = useAppStore();
|
||||
const [gumpTab, setGumpTab] = useState<GumpTab>('art');
|
||||
|
||||
// Art gumps (from gumps.xml)
|
||||
const [artEntries, setArtEntries] = useState<GumpEntry[]>([]);
|
||||
const [artLoading, setArtLoading] = useState(true);
|
||||
const [artError, setArtError] = useState<string | null>(null);
|
||||
const [artActiveTag, setArtActiveTag] = useState<string>('all');
|
||||
|
||||
// Script gumps (from script_gumps.xml)
|
||||
const [scriptEntries, setScriptEntries] = useState<ScriptGumpEntry[]>([]);
|
||||
const [scriptLoading, setScriptLoading] = useState(true);
|
||||
const [scriptError, setScriptError] = useState<string | null>(null);
|
||||
const [scriptActiveTag, setScriptActiveTag] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
invoke<GumpEntry[]>('list_gumps')
|
||||
.then((data) => { setArtEntries(data); setArtLoading(false); })
|
||||
.catch((e) => { setArtError(String(e)); setArtLoading(false); });
|
||||
|
||||
invoke<ScriptGumpEntry[]>('list_script_gumps')
|
||||
.then((data) => { setScriptEntries(data); setScriptLoading(false); })
|
||||
.catch((e) => { setScriptError(String(e)); setScriptLoading(false); });
|
||||
}, []);
|
||||
|
||||
// ── Art tab ────────────────────────────────────────────────────────────────
|
||||
const artTopTags = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const e of artEntries) for (const t of e.tags) s.add(t);
|
||||
return Array.from(s).sort();
|
||||
}, [artEntries]);
|
||||
|
||||
const artFiltered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return artEntries.filter((e) => {
|
||||
const nameMatch = !q || e.name.toLowerCase().includes(q) || String(e.id).includes(q)
|
||||
|| ('0x' + e.id.toString(16)).includes(q.replace('0x', ''));
|
||||
const tagMatch = artActiveTag === 'all' || e.tags.includes(artActiveTag);
|
||||
return nameMatch && tagMatch;
|
||||
});
|
||||
}, [artEntries, search, artActiveTag]);
|
||||
|
||||
function selectArtGump(entry: GumpEntry) {
|
||||
setSelectedGump(entry);
|
||||
setCenterMode('gump_image');
|
||||
}
|
||||
|
||||
// ── Script tab ─────────────────────────────────────────────────────────────
|
||||
const scriptTopTags = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const e of scriptEntries) for (const t of e.tags) s.add(t);
|
||||
return Array.from(s).sort();
|
||||
}, [scriptEntries]);
|
||||
|
||||
const scriptFiltered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return scriptEntries.filter((e) => {
|
||||
const nameMatch = !q || e.class_name.toLowerCase().includes(q)
|
||||
|| e.file_path.toLowerCase().includes(q);
|
||||
const tagMatch = scriptActiveTag === 'all' || e.tags.includes(scriptActiveTag);
|
||||
return nameMatch && tagMatch;
|
||||
});
|
||||
}, [scriptEntries, search, scriptActiveTag]);
|
||||
|
||||
function selectScriptGump(entry: ScriptGumpEntry) {
|
||||
setSelectedScriptGump(entry);
|
||||
setCenterMode('gump_render');
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={styles.browser}>
|
||||
{/* Sub-tabs */}
|
||||
<div className={styles.subTabs}>
|
||||
<button
|
||||
className={`${styles.subTab} ${gumpTab === 'art' ? styles.subTabActive : ''}`}
|
||||
onClick={() => setGumpTab('art')}
|
||||
>
|
||||
Art ({artEntries.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.subTab} ${gumpTab === 'scripts' ? styles.subTabActive : ''}`}
|
||||
onClick={() => setGumpTab('scripts')}
|
||||
>
|
||||
Scripts ({scriptEntries.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Art tab */}
|
||||
{gumpTab === 'art' && (
|
||||
<>
|
||||
{artLoading && <div className={styles.status}>Loading art gumps…</div>}
|
||||
{artError && <div className={styles.error}>{artError}</div>}
|
||||
{!artLoading && !artError && (
|
||||
<>
|
||||
<div className={styles.tagBar}>
|
||||
<button
|
||||
className={`${styles.tag} ${artActiveTag === 'all' ? styles.tagActive : ''}`}
|
||||
onClick={() => setArtActiveTag('all')}
|
||||
>All</button>
|
||||
{artTopTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`${styles.tag} ${artActiveTag === t ? styles.tagActive : ''}`}
|
||||
onClick={() => setArtActiveTag(t)}
|
||||
>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.count}>{artFiltered.length} gumps</div>
|
||||
<div className={styles.list}>
|
||||
{artFiltered.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
className={styles.item}
|
||||
onClick={() => selectArtGump(entry)}
|
||||
>
|
||||
<span className={styles.itemId}>
|
||||
0x{entry.id.toString(16).toUpperCase().padStart(4, '0')}
|
||||
</span>
|
||||
<span className={styles.itemName}>{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scripts tab */}
|
||||
{gumpTab === 'scripts' && (
|
||||
<>
|
||||
{scriptLoading && <div className={styles.status}>Loading script gumps…</div>}
|
||||
{scriptError && <div className={styles.error}>{scriptError}</div>}
|
||||
{!scriptLoading && !scriptError && (
|
||||
<>
|
||||
<div className={styles.tagBar}>
|
||||
<button
|
||||
className={`${styles.tag} ${scriptActiveTag === 'all' ? styles.tagActive : ''}`}
|
||||
onClick={() => setScriptActiveTag('all')}
|
||||
>All</button>
|
||||
{scriptTopTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`${styles.tag} ${scriptActiveTag === t ? styles.tagActive : ''}`}
|
||||
onClick={() => setScriptActiveTag(t)}
|
||||
>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.count}>{scriptFiltered.length} classes</div>
|
||||
<div className={styles.list}>
|
||||
{scriptFiltered.map((entry) => (
|
||||
<button
|
||||
key={entry.class_name}
|
||||
className={styles.item}
|
||||
onClick={() => selectScriptGump(entry)}
|
||||
>
|
||||
<span className={styles.itemName}>{entry.class_name}</span>
|
||||
<span className={styles.itemPath}>{entry.file_path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/gump/GumpPreview.module.css
Normal file
84
src/components/gump/GumpPreview.module.css
Normal file
@@ -0,0 +1,84 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-accent);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.id {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.canvasWrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-height: 80px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.noArt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
|
||||
.artCanvas {
|
||||
image-rendering: pixelated;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
103
src/components/gump/GumpPreview.tsx
Normal file
103
src/components/gump/GumpPreview.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import type { GumpEntry } from '../../store/appStore';
|
||||
import styles from './GumpPreview.module.css';
|
||||
|
||||
interface ArtImageResult {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gump: GumpEntry;
|
||||
}
|
||||
|
||||
export default function GumpPreview({ gump }: Props) {
|
||||
const { uoRoot } = useAppStore();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [artState, setArtState] = useState<null | 'none' | string>(null);
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setArtState(null);
|
||||
setDims(null);
|
||||
if (!uoRoot) return;
|
||||
|
||||
async function loadArt() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await invoke<ArtImageResult>('get_gump_art', {
|
||||
gumpId: gump.id,
|
||||
uoRoot,
|
||||
});
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = result.width;
|
||||
canvas.height = result.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.createImageData(result.width, result.height);
|
||||
imageData.data.set(result.pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
setDims({ w: result.width, h: result.height });
|
||||
} catch {
|
||||
setArtState('none');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadArt();
|
||||
}, [gump.id, uoRoot]);
|
||||
|
||||
const hexId = `0x${gump.id.toString(16).toUpperCase().padStart(4, '0')}`;
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<span className={`${styles.name} font-cinzel`}>{gump.name}</span>
|
||||
<span className={styles.id}>{hexId}</span>
|
||||
</div>
|
||||
|
||||
{gump.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{gump.tags.map((t) => (
|
||||
<span key={t} className={styles.tag}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.canvasWrap}>
|
||||
{loading && <div className={styles.loading}>Loading…</div>}
|
||||
{artState === 'none' && (
|
||||
<div className={styles.noArt}>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="4" y="4" width="40" height="40" rx="4"
|
||||
stroke="var(--border-accent)" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<path d="M16 32 L24 16 L32 32 Z"
|
||||
stroke="var(--text-muted)" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
<span>No gump art</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={styles.artCanvas}
|
||||
style={{ display: loading || artState ? 'none' : 'block' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dims && (
|
||||
<div className={styles.meta}>
|
||||
{dims.w} × {dims.h}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/gump/ScriptGumpDetail.module.css
Normal file
96
src/components/gump/ScriptGumpDetail.module.css
Normal file
@@ -0,0 +1,96 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-accent);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.className {
|
||||
display: block;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
color: var(--accent-gold-bright);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.friendlyName {
|
||||
display: block;
|
||||
font-family: 'EB Garamond', serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filepath {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-accent);
|
||||
color: var(--accent-gold);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 10px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-family: 'EB Garamond', serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
52
src/components/gump/ScriptGumpDetail.tsx
Normal file
52
src/components/gump/ScriptGumpDetail.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import styles from './ScriptGumpDetail.module.css';
|
||||
|
||||
export default function ScriptGumpDetail() {
|
||||
const { selectedScriptGump } = useAppStore();
|
||||
|
||||
if (!selectedScriptGump) {
|
||||
return <div className={styles.empty}>No script gump selected</div>;
|
||||
}
|
||||
|
||||
const { class_name, file_path, tags } = selectedScriptGump;
|
||||
|
||||
// Derive a human-readable title from the class name
|
||||
// e.g. "RunebookGump" → "Runebook Gump"
|
||||
const friendlyName = class_name.replace(/([A-Z])/g, ' $1').trim();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.className}>{class_name}</span>
|
||||
<span className={styles.friendlyName}>{friendlyName}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.label}>Source File</div>
|
||||
<div className={styles.filepath}>{file_path}</div>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.label}>Tags</div>
|
||||
<div className={styles.tags}>
|
||||
{tags.map((t) => (
|
||||
<span key={t} className={styles.tag}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.label}>Notes</div>
|
||||
<div className={styles.notes}>
|
||||
This is a ServUO C# Gump class found in the Scripts directory.
|
||||
Select it in the Scripts tab to trace its method call chain or
|
||||
inspect its class details.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/layout/AppShell.module.css
Normal file
54
src/components/layout/AppShell.module.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 42px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerBtn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
font-family: 'Cinzel', serif;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.headerBtn:hover {
|
||||
border-color: var(--border-accent);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.headerBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
83
src/components/layout/AppShell.tsx
Normal file
83
src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import LeftPanel from './LeftPanel';
|
||||
import CenterPanel from './CenterPanel';
|
||||
import RightPanel from './RightPanel';
|
||||
import ConfigScreen from '../config/ConfigScreen';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
export default function AppShell() {
|
||||
const { centerMode, setCenterMode, uoRoot, servuoScripts } = useAppStore();
|
||||
const [indexingAssets, setIndexingAssets] = useState(false);
|
||||
const [indexingScripts, setIndexingScripts] = useState(false);
|
||||
|
||||
async function handleIndexAssets() {
|
||||
if (!uoRoot) return;
|
||||
setIndexingAssets(true);
|
||||
try {
|
||||
const count = await invoke<number>('index_assets', { uoRoot });
|
||||
alert(`Indexed ${count.toLocaleString()} tiles.`);
|
||||
} catch (e) {
|
||||
alert(`Asset index failed: ${e}`);
|
||||
} finally {
|
||||
setIndexingAssets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIndexScripts() {
|
||||
if (!servuoScripts) {
|
||||
alert('Set the ServUO Scripts path in Config first.');
|
||||
return;
|
||||
}
|
||||
setIndexingScripts(true);
|
||||
try {
|
||||
const count = await invoke<number>('index_scripts', { scriptsPath: servuoScripts });
|
||||
alert(`Indexed ${count.toLocaleString()} classes.`);
|
||||
} catch (e) {
|
||||
alert(`Script index failed: ${e}`);
|
||||
} finally {
|
||||
setIndexingScripts(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<span className={`${styles.title} font-cinzel`}>Artificer's Scrollwork</span>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.headerBtn}
|
||||
onClick={handleIndexAssets}
|
||||
disabled={indexingAssets}
|
||||
>
|
||||
{indexingAssets ? 'Indexing…' : '[Assets]'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.headerBtn}
|
||||
onClick={handleIndexScripts}
|
||||
disabled={indexingScripts}
|
||||
>
|
||||
{indexingScripts ? 'Indexing…' : '[Scripts]'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.headerBtn}
|
||||
onClick={() => setCenterMode('config')}
|
||||
>
|
||||
[Config]
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{centerMode === 'config' ? (
|
||||
<ConfigScreen onDone={() => setCenterMode('empty')} />
|
||||
) : (
|
||||
<div className={styles.panels}>
|
||||
<LeftPanel />
|
||||
<CenterPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/layout/CenterPanel.module.css
Normal file
23
src/components/layout/CenterPanel.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
35
src/components/layout/CenterPanel.tsx
Normal file
35
src/components/layout/CenterPanel.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import ItemPreview from '../asset/ItemPreview';
|
||||
import ClassDetail from '../script/ClassDetail';
|
||||
import FlowViewer from '../flow/FlowViewer';
|
||||
import GumpPreview from '../gump/GumpPreview';
|
||||
import ScriptGumpDetail from '../gump/ScriptGumpDetail';
|
||||
import styles from './CenterPanel.module.css';
|
||||
|
||||
export default function CenterPanel() {
|
||||
const { centerMode, selectedTile, selectedGump, selectedScriptGump } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{centerMode === 'empty' && (
|
||||
<div className={styles.empty}>
|
||||
<span className="font-cinzel">Select an item from the left panel</span>
|
||||
</div>
|
||||
)}
|
||||
{centerMode === 'asset_static' && selectedTile && (
|
||||
<ItemPreview tile={selectedTile} />
|
||||
)}
|
||||
{centerMode === 'script_class' && <ClassDetail />}
|
||||
{centerMode === 'flow_method' && <FlowViewer />}
|
||||
{centerMode === 'gump_render' && selectedScriptGump && (
|
||||
<ScriptGumpDetail />
|
||||
)}
|
||||
{centerMode === 'gump_render' && !selectedScriptGump && (
|
||||
<div className={styles.placeholder}>Gump Renderer — Phase 4</div>
|
||||
)}
|
||||
{centerMode === 'gump_image' && selectedGump && (
|
||||
<GumpPreview gump={selectedGump} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/layout/LeftPanel.module.css
Normal file
64
src/components/layout/LeftPanel.module.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-family: 'Cinzel', serif;
|
||||
letter-spacing: 0.04em;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tab:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent-gold);
|
||||
border-bottom: 2px solid var(--accent-gold);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
53
src/components/layout/LeftPanel.tsx
Normal file
53
src/components/layout/LeftPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore, type LeftPanelTab } from '../../store/appStore';
|
||||
import StaticBrowser from '../asset/StaticBrowser';
|
||||
import ScriptTree from '../script/ScriptTree';
|
||||
import GumpBrowser from '../gump/GumpBrowser';
|
||||
import styles from './LeftPanel.module.css';
|
||||
|
||||
const TABS: { id: LeftPanelTab; label: string }[] = [
|
||||
{ id: 'scripts', label: 'Scripts' },
|
||||
{ id: 'statics', label: 'Statics' },
|
||||
{ id: 'mobiles', label: 'Mobiles' },
|
||||
{ id: 'gumps', label: 'Gumps' },
|
||||
];
|
||||
|
||||
export default function LeftPanel() {
|
||||
const { activeTab, setActiveTab } = useAppStore();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.tabs}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`${styles.tab} ${activeTab === t.id ? styles.active : ''}`}
|
||||
onClick={() => { setActiveTab(t.id); setSearch(''); }}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{activeTab === 'statics' && <StaticBrowser search={search} />}
|
||||
{activeTab === 'scripts' && <ScriptTree search={search} />}
|
||||
{activeTab === 'mobiles' && (
|
||||
<div className={styles.placeholder}>Mobile browser — Phase 1 (coming)</div>
|
||||
)}
|
||||
{activeTab === 'gumps' && <GumpBrowser search={search} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/layout/RightPanel.module.css
Normal file
58
src/components/layout/RightPanel.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-panel);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.propList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.propRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.propLabel {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.propValue {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
72
src/components/layout/RightPanel.tsx
Normal file
72
src/components/layout/RightPanel.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import FakeDataInputs from '../flow/FakeDataInputs';
|
||||
import styles from './RightPanel.module.css';
|
||||
|
||||
export default function RightPanel() {
|
||||
const { selectedTile, selectedMethod, centerMode } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
|
||||
{/* Asset properties */}
|
||||
{centerMode === 'asset_static' && selectedTile && (
|
||||
<div className={styles.section}>
|
||||
<div className={`${styles.sectionTitle} font-cinzel`}>Properties</div>
|
||||
<div className={styles.propList}>
|
||||
<PropRow label="ID" value={selectedTile.id.toString()} />
|
||||
<PropRow label="Name" value={selectedTile.name || '(unnamed)'} />
|
||||
<PropRow label="Flags" value={`0x${selectedTile.flags.toString(16).toUpperCase()}`} />
|
||||
<PropRow label="Weight" value={selectedTile.weight.toString()} />
|
||||
<PropRow label="Height" value={selectedTile.height.toString()} />
|
||||
<PropRow label="Quality" value={selectedTile.quality.toString()} />
|
||||
<PropRow label="Hue" value={selectedTile.hue.toString()} />
|
||||
<PropRow label="Anim ID" value={selectedTile.animId.toString()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Method detail */}
|
||||
{centerMode === 'flow_method' && selectedMethod && (
|
||||
<div className={styles.section}>
|
||||
<div className={`${styles.sectionTitle} font-cinzel`}>Method</div>
|
||||
<div className={styles.propList}>
|
||||
<PropRow label="Name" value={selectedMethod.name} />
|
||||
<PropRow label="Returns" value={selectedMethod.returnType} />
|
||||
<PropRow label="Override" value={selectedMethod.isOverride ? 'yes' : 'no'} />
|
||||
<PropRow label="Virtual" value={selectedMethod.isVirtual ? 'yes' : 'no'} />
|
||||
{selectedMethod.callsGump && (
|
||||
<PropRow label="Opens Gump" value={selectedMethod.gumpClass ?? '(dynamic)'} />
|
||||
)}
|
||||
</div>
|
||||
{selectedMethod.parameters.length > 0 && (
|
||||
<>
|
||||
<div className={styles.subTitle}>Parameters</div>
|
||||
{selectedMethod.parameters.map((p, i) => (
|
||||
<PropRow key={i} label={p.name} value={p.type} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fake data inputs for flow conditions */}
|
||||
{centerMode === 'flow_method' && <FakeDataInputs />}
|
||||
|
||||
{/* Default empty state */}
|
||||
{centerMode === 'empty' && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.empty}>Nothing selected</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={styles.propRow}>
|
||||
<span className={styles.propLabel}>{label}</span>
|
||||
<span className={styles.propValue}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/script/ClassDetail.module.css
Normal file
161
src/components/script/ClassDetail.module.css
Normal file
@@ -0,0 +1,161 @@
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.className {
|
||||
font-size: 18px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
font-family: 'Cinzel', serif;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #445;
|
||||
color: #8888ff;
|
||||
background: #1a1a2e;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tagMobile {
|
||||
border-color: #344;
|
||||
color: #88cc88;
|
||||
background: #1a2e1a;
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
border-color: #443;
|
||||
color: var(--accent-gold);
|
||||
background: #1e1a10;
|
||||
}
|
||||
|
||||
.filePath {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 10px;
|
||||
color: var(--accent-gold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chainItem {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.methodRow {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.methodRow:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.hookRow {
|
||||
border-left: 2px solid var(--accent-gold);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.methodSig {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.returnType {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.methodName {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.params {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.modBadge {
|
||||
font-size: 10px;
|
||||
font-family: 'Cinzel', serif;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.gumpBadge {
|
||||
border-color: #445;
|
||||
color: #8888ff;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
124
src/components/script/ClassDetail.tsx
Normal file
124
src/components/script/ClassDetail.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { useScripts } from '../../hooks/useScripts';
|
||||
import type { ClassDetail as ClassDetailType, MethodSummary } from '../../types/scripts';
|
||||
import { WELL_KNOWN_HOOKS } from '../../types/scripts';
|
||||
import styles from './ClassDetail.module.css';
|
||||
|
||||
export default function ClassDetail() {
|
||||
const { selectedClass, setSelectedMethod, setCenterMode } = useAppStore();
|
||||
const { getClassDetail } = useScripts();
|
||||
const [detail, setDetail] = useState<ClassDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClass) return;
|
||||
setLoading(true);
|
||||
setDetail(null);
|
||||
getClassDetail(selectedClass.name).then((d) => {
|
||||
setDetail(d);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [selectedClass?.name]);
|
||||
|
||||
if (!selectedClass) return null;
|
||||
|
||||
const cls = detail?.class ?? selectedClass;
|
||||
const methods = detail?.methods ?? [];
|
||||
|
||||
// Split well-known hooks from other methods
|
||||
const hookNames = new Set(WELL_KNOWN_HOOKS.map((h) => h.method));
|
||||
const hooks = methods.filter((m) => hookNames.has(m.name));
|
||||
const others = methods.filter((m) => !hookNames.has(m.name));
|
||||
|
||||
function selectMethod(method: MethodSummary) {
|
||||
setSelectedMethod(method);
|
||||
setCenterMode('flow_method');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<span className={`${styles.className} font-cinzel`}>{cls.name}</span>
|
||||
{cls.isGump && <span className={styles.tag}>Gump</span>}
|
||||
{cls.isMobile && <span className={`${styles.tag} ${styles.tagMobile}`}>Mobile</span>}
|
||||
{cls.isItem && <span className={`${styles.tag} ${styles.tagItem}`}>Item</span>}
|
||||
</div>
|
||||
<div className={styles.filePath}>{cls.filePath}</div>
|
||||
</div>
|
||||
|
||||
{/* Inheritance */}
|
||||
<section className={styles.section}>
|
||||
<div className={`${styles.sectionTitle} font-cinzel`}>Inheritance</div>
|
||||
<div className={styles.chain}>
|
||||
{cls.baseClass ? (
|
||||
<span className={styles.chainItem}>{cls.name}</span>
|
||||
) : null}
|
||||
{cls.baseClass && <span className={styles.arrow}>→</span>}
|
||||
<span className={styles.chainItem}>{cls.baseClass ?? cls.name}</span>
|
||||
</div>
|
||||
{cls.interfaces.length > 0 && (
|
||||
<div className={styles.interfaces}>
|
||||
implements: {cls.interfaces.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{loading && <div className={styles.loading}>Loading methods…</div>}
|
||||
|
||||
{/* Well-known hooks */}
|
||||
{hooks.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<div className={`${styles.sectionTitle} font-cinzel`}>Hooks</div>
|
||||
{hooks.map((m) => (
|
||||
<MethodRow key={m.id} method={m} onSelect={selectMethod} isHook />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Other methods */}
|
||||
{others.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<div className={`${styles.sectionTitle} font-cinzel`}>
|
||||
Methods ({others.length})
|
||||
</div>
|
||||
{others.map((m) => (
|
||||
<MethodRow key={m.id} method={m} onSelect={selectMethod} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MethodRow({
|
||||
method,
|
||||
onSelect,
|
||||
isHook,
|
||||
}: {
|
||||
method: MethodSummary;
|
||||
onSelect: (m: MethodSummary) => void;
|
||||
isHook?: boolean;
|
||||
}) {
|
||||
const paramStr = method.parameters
|
||||
.map((p) => `${p.type} ${p.name}`)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<button className={`${styles.methodRow} ${isHook ? styles.hookRow : ''}`} onClick={() => onSelect(method)}>
|
||||
<div className={styles.methodSig}>
|
||||
<span className={styles.returnType}>{method.returnType}</span>
|
||||
<span className={styles.methodName}>{method.name}</span>
|
||||
<span className={styles.params}>({paramStr})</span>
|
||||
{method.isOverride && <span className={styles.modBadge}>override</span>}
|
||||
{method.callsGump && (
|
||||
<span className={`${styles.modBadge} ${styles.gumpBadge}`}>
|
||||
→ {method.gumpClass ?? 'Gump'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
129
src/components/script/ScriptTree.module.css
Normal file
129
src/components/script/ScriptTree.module.css
Normal file
@@ -0,0 +1,129 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: 'Cinzel', serif;
|
||||
letter-spacing: 0.04em;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.nsHeader:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nsChevron {
|
||||
font-size: 10px;
|
||||
color: var(--accent-gold);
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.nsName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nsCount {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.nsClasses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px 5px 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.classRow:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.className {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.classNs {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
font-family: 'Cinzel', serif;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badgeGump {
|
||||
color: #8888ff;
|
||||
border-color: #445;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.badgeMobile {
|
||||
color: #88cc88;
|
||||
border-color: #344;
|
||||
background: #1a2e1a;
|
||||
}
|
||||
|
||||
.badgeItem {
|
||||
color: var(--accent-gold);
|
||||
border-color: #443;
|
||||
background: #1e1a10;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 16px;
|
||||
color: #ff8080;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
147
src/components/script/ScriptTree.tsx
Normal file
147
src/components/script/ScriptTree.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppStore } from '../../store/appStore';
|
||||
import { useScripts } from '../../hooks/useScripts';
|
||||
import type { ClassSummary, NamespaceTree } from '../../types/scripts';
|
||||
import styles from './ScriptTree.module.css';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
}
|
||||
|
||||
export default function ScriptTree({ search }: Props) {
|
||||
const { setSelectedClass, setCenterMode } = useAppStore();
|
||||
const { tree, loading, error, loadTree, search: searchScripts } = useScripts();
|
||||
const [searchResults, setSearchResults] = useState<ClassSummary[] | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
const results = await searchScripts(search);
|
||||
setSearchResults(results);
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
function toggleNamespace(ns: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(ns) ? next.delete(ns) : next.add(ns);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectClass(cls: ClassSummary) {
|
||||
setSelectedClass(cls);
|
||||
setCenterMode('script_class');
|
||||
}
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
return <div className={styles.empty}>Loading scripts…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return <div className={styles.error}>{error}</div>;
|
||||
}
|
||||
if (!loading && tree.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
No scripts indexed yet.
|
||||
<br />
|
||||
Set your Scripts path in Config and click [Index Scripts].
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Search mode
|
||||
if (searchResults !== null) {
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{searchResults.length === 0 && (
|
||||
<div className={styles.empty}>No results for "{search}"</div>
|
||||
)}
|
||||
{searchResults.map((cls) => (
|
||||
<ClassRow key={cls.name} cls={cls} onSelect={selectClass} showNamespace />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{tree.map((ns) => (
|
||||
<NamespaceGroup
|
||||
key={ns.namespace}
|
||||
ns={ns}
|
||||
expanded={expanded.has(ns.namespace)}
|
||||
onToggle={() => toggleNamespace(ns.namespace)}
|
||||
onSelect={selectClass}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NamespaceGroup({
|
||||
ns,
|
||||
expanded,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
ns: NamespaceTree;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onSelect: (cls: ClassSummary) => void;
|
||||
}) {
|
||||
const shortNs = ns.namespace.split('.').pop() ?? ns.namespace;
|
||||
|
||||
return (
|
||||
<div className={styles.namespace}>
|
||||
<button className={styles.nsHeader} onClick={onToggle}>
|
||||
<span className={styles.nsChevron}>{expanded ? '▾' : '▸'}</span>
|
||||
<span className={styles.nsName}>{shortNs}</span>
|
||||
<span className={styles.nsCount}>{ns.classes.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className={styles.nsClasses}>
|
||||
{ns.classes.map((cls) => (
|
||||
<ClassRow key={cls.name} cls={cls} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassRow({
|
||||
cls,
|
||||
onSelect,
|
||||
showNamespace,
|
||||
}: {
|
||||
cls: ClassSummary;
|
||||
onSelect: (cls: ClassSummary) => void;
|
||||
showNamespace?: boolean;
|
||||
}) {
|
||||
const badge = cls.isGump ? 'G' : cls.isMobile ? 'M' : cls.isItem ? 'I' : null;
|
||||
const badgeClass = cls.isGump
|
||||
? styles.badgeGump
|
||||
: cls.isMobile
|
||||
? styles.badgeMobile
|
||||
: styles.badgeItem;
|
||||
|
||||
return (
|
||||
<button className={styles.classRow} onClick={() => onSelect(cls)}>
|
||||
{badge && <span className={`${styles.badge} ${badgeClass}`}>{badge}</span>}
|
||||
<span className={styles.className}>{cls.name}</span>
|
||||
{showNamespace && (
|
||||
<span className={styles.classNs}>{cls.namespace.split('.').pop()}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
40
src/hooks/useScripts.ts
Normal file
40
src/hooks/useScripts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { ClassSummary, ClassDetail, NamespaceTree } from '../types/scripts';
|
||||
|
||||
export function useScripts() {
|
||||
const [tree, setTree] = useState<NamespaceTree[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<NamespaceTree[]>('get_script_tree');
|
||||
setTree(result);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const search = useCallback(async (query: string): Promise<ClassSummary[]> => {
|
||||
try {
|
||||
return await invoke<ClassSummary[]>('search_scripts', { query });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getClassDetail = useCallback(async (className: string): Promise<ClassDetail | null> => {
|
||||
try {
|
||||
return await invoke<ClassDetail>('get_class_detail', { className });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tree, loading, error, loadTree, search, getClassDetail };
|
||||
}
|
||||
84
src/index.css
Normal file
84
src/index.css
Normal file
@@ -0,0 +1,84 @@
|
||||
:root {
|
||||
--bg-base: #0e0d0b;
|
||||
--bg-panel: #161410;
|
||||
--bg-elevated: #1e1b16;
|
||||
--bg-hover: #272318;
|
||||
--border: #3a3020;
|
||||
--border-accent: #6b5a2e;
|
||||
--text-primary: #d4c49a;
|
||||
--text-secondary: #8a7a5a;
|
||||
--text-muted: #4a4030;
|
||||
--accent-gold: #c8a84b;
|
||||
--accent-gold-bright: #e8c86b;
|
||||
--accent-red: #8b3a3a;
|
||||
--accent-green: #3a6b3a;
|
||||
--scrollbar-thumb: #3a3020;
|
||||
|
||||
font-family: 'EB Garamond', Georgia, serif;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-base);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-accent);
|
||||
}
|
||||
|
||||
/* Typography helpers */
|
||||
.font-cinzel {
|
||||
font-family: 'Cinzel', serif;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--border-accent);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
121
src/store/appStore.ts
Normal file
121
src/store/appStore.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TileInfo } from '../types/assets';
|
||||
import type { ClassSummary, MethodSummary, FlowNode } from '../types/scripts';
|
||||
import type { GumpDrawList } from '../types/gump';
|
||||
|
||||
export type LeftPanelTab = 'scripts' | 'statics' | 'mobiles' | 'gumps';
|
||||
|
||||
export type CenterMode =
|
||||
| 'empty'
|
||||
| 'config'
|
||||
| 'asset_static'
|
||||
| 'asset_mobile'
|
||||
| 'script_class'
|
||||
| 'flow_method'
|
||||
| 'gump_render'
|
||||
| 'gump_image';
|
||||
|
||||
export interface GumpEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ScriptGumpEntry {
|
||||
class_name: string;
|
||||
file_path: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Config
|
||||
uoRoot: string;
|
||||
servuoScripts: string;
|
||||
assetFormat: 'mul' | 'uop';
|
||||
isConfigured: boolean;
|
||||
setUoRoot: (p: string) => void;
|
||||
setServuoScripts: (p: string) => void;
|
||||
setAssetFormat: (f: 'mul' | 'uop') => void;
|
||||
setIsConfigured: (v: boolean) => void;
|
||||
|
||||
// Navigation
|
||||
activeTab: LeftPanelTab;
|
||||
centerMode: CenterMode;
|
||||
setActiveTab: (tab: LeftPanelTab) => void;
|
||||
setCenterMode: (mode: CenterMode) => void;
|
||||
|
||||
// Asset browser
|
||||
selectedTile: TileInfo | null;
|
||||
setSelectedTile: (tile: TileInfo | null) => void;
|
||||
|
||||
// Script browser
|
||||
selectedClass: ClassSummary | null;
|
||||
setSelectedClass: (c: ClassSummary | null) => void;
|
||||
|
||||
// Method / flow
|
||||
selectedMethod: MethodSummary | null;
|
||||
setSelectedMethod: (m: MethodSummary | null) => void;
|
||||
|
||||
// Flow tree
|
||||
flowRoot: FlowNode | null;
|
||||
setFlowRoot: (node: FlowNode | null) => void;
|
||||
|
||||
// Gump
|
||||
gumpDrawList: GumpDrawList | null;
|
||||
setGumpDrawList: (g: GumpDrawList | null) => void;
|
||||
|
||||
// Gump browser (Phase 4)
|
||||
selectedGump: GumpEntry | null;
|
||||
setSelectedGump: (g: GumpEntry | null) => void;
|
||||
|
||||
// Script gump browser
|
||||
selectedScriptGump: ScriptGumpEntry | null;
|
||||
setSelectedScriptGump: (g: ScriptGumpEntry | null) => void;
|
||||
|
||||
// Fake data for dynamic gumps / flow conditions
|
||||
fakeData: Record<string, string>;
|
||||
setFakeData: (key: string, value: string) => void;
|
||||
clearFakeData: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
uoRoot: '',
|
||||
servuoScripts: '',
|
||||
assetFormat: 'mul',
|
||||
isConfigured: false,
|
||||
setUoRoot: (p) => set({ uoRoot: p }),
|
||||
setServuoScripts: (p) => set({ servuoScripts: p }),
|
||||
setAssetFormat: (f) => set({ assetFormat: f }),
|
||||
setIsConfigured: (v) => set({ isConfigured: v }),
|
||||
|
||||
activeTab: 'statics',
|
||||
centerMode: 'empty',
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setCenterMode: (mode) => set({ centerMode: mode }),
|
||||
|
||||
selectedTile: null,
|
||||
setSelectedTile: (tile) => set({ selectedTile: tile }),
|
||||
|
||||
selectedClass: null,
|
||||
setSelectedClass: (c) => set({ selectedClass: c, selectedMethod: null, flowRoot: null }),
|
||||
|
||||
selectedMethod: null,
|
||||
setSelectedMethod: (m) => set({ selectedMethod: m }),
|
||||
|
||||
flowRoot: null,
|
||||
setFlowRoot: (node) => set({ flowRoot: node }),
|
||||
|
||||
gumpDrawList: null,
|
||||
setGumpDrawList: (g) => set({ gumpDrawList: g }),
|
||||
|
||||
selectedGump: null,
|
||||
setSelectedGump: (g) => set({ selectedGump: g }),
|
||||
|
||||
selectedScriptGump: null,
|
||||
setSelectedScriptGump: (g) => set({ selectedScriptGump: g }),
|
||||
|
||||
fakeData: {},
|
||||
setFakeData: (key, value) =>
|
||||
set((s) => ({ fakeData: { ...s.fakeData, [key]: value } })),
|
||||
clearFakeData: () => set({ fakeData: {} }),
|
||||
}));
|
||||
24
src/types/assets.ts
Normal file
24
src/types/assets.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface TileInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
flags: number;
|
||||
weight: number;
|
||||
quality: number;
|
||||
height: number;
|
||||
hue: number;
|
||||
animId: number;
|
||||
artData?: ImageData;
|
||||
}
|
||||
|
||||
export interface MobileInfo {
|
||||
bodyId: number;
|
||||
name: string;
|
||||
flags: number;
|
||||
frames: ImageData[];
|
||||
}
|
||||
|
||||
export interface HueInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
colors: number[];
|
||||
}
|
||||
1
src/types/flow.ts
Normal file
1
src/types/flow.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { FlowNode } from './scripts';
|
||||
22
src/types/gump.ts
Normal file
22
src/types/gump.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type DrawCall =
|
||||
| { type: 'background'; x: number; y: number; w: number; h: number; gumpId: number }
|
||||
| { type: 'image'; x: number; y: number; gumpId: number; hue?: number }
|
||||
| { type: 'label'; x: number; y: number; hue: number; text: string }
|
||||
| { type: 'button'; x: number; y: number; normalId: number; pressedId: number; buttonId: number }
|
||||
| { type: 'html'; x: number; y: number; w: number; h: number; text: string; hasBackground: boolean; hasScrollbar: boolean }
|
||||
| { type: 'item'; x: number; y: number; itemId: number; hue?: number }
|
||||
| { type: 'alpha_region'; x: number; y: number; w: number; h: number }
|
||||
| { type: 'tiled_image'; x: number; y: number; w: number; h: number; gumpId: number }
|
||||
| { type: 'checkbox'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; switchId: number }
|
||||
| { type: 'radio'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; returnValue: number }
|
||||
| { type: 'text_entry'; x: number; y: number; w: number; h: number; hue: number; entryId: number; initialText: string };
|
||||
|
||||
export interface GumpDrawList {
|
||||
className: string;
|
||||
filePath: string;
|
||||
isDynamic: boolean;
|
||||
dynamicInputKeys: string[];
|
||||
drawCalls: DrawCall[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
69
src/types/scripts.ts
Normal file
69
src/types/scripts.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export interface ParameterInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PropertyInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
hasGetter: boolean;
|
||||
hasSetter: boolean;
|
||||
}
|
||||
|
||||
export interface MethodSummary {
|
||||
id: number;
|
||||
className: string;
|
||||
name: string;
|
||||
returnType: string;
|
||||
parameters: ParameterInfo[];
|
||||
isOverride: boolean;
|
||||
isVirtual: boolean;
|
||||
callsGump: boolean;
|
||||
gumpClass: string | null;
|
||||
}
|
||||
|
||||
export interface ClassSummary {
|
||||
name: string;
|
||||
namespace: string;
|
||||
filePath: string;
|
||||
baseClass: string | null;
|
||||
interfaces: string[];
|
||||
isGump: boolean;
|
||||
isMobile: boolean;
|
||||
isItem: boolean;
|
||||
}
|
||||
|
||||
export interface ClassDetail {
|
||||
class: ClassSummary;
|
||||
methods: MethodSummary[];
|
||||
}
|
||||
|
||||
export interface NamespaceTree {
|
||||
namespace: string;
|
||||
classes: ClassSummary[];
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
type: 'method_call' | 'condition' | 'branch_true' | 'branch_false' | 'branch_case'
|
||||
| 'gump_send' | 'return' | 'property_access';
|
||||
label: string;
|
||||
children: FlowNode[];
|
||||
fakeInputKey?: string;
|
||||
resolvedGump?: string;
|
||||
assetRef?: number;
|
||||
}
|
||||
|
||||
// Well-known ServUO hooks surfaced as quick-launch entry points
|
||||
export const WELL_KNOWN_HOOKS: { method: string; label: string }[] = [
|
||||
{ method: 'OnDoubleClick', label: 'OnDoubleClick' },
|
||||
{ method: 'OnSingleClick', label: 'OnSingleClick' },
|
||||
{ method: 'OnDeath', label: 'OnDeath' },
|
||||
{ method: 'OnDamage', label: 'OnDamage' },
|
||||
{ method: 'OnDelete', label: 'OnDelete' },
|
||||
{ method: 'OnMoveOver', label: 'OnMoveOver' },
|
||||
{ method: 'OnGainedLevel', label: 'OnGainedLevel' },
|
||||
{ method: 'OnSkillUse', label: 'OnSkillUse' },
|
||||
{ method: 'OnHit', label: 'OnHit' },
|
||||
{ method: 'OnMiss', label: 'OnMiss' },
|
||||
];
|
||||
6
src/vite-env.d.ts
vendored
Normal file
6
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
Reference in New Issue
Block a user