@frotzinc/zcode-ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ <script setup lang="ts">
2
+ import type { StoryFile } from '@frotzinc/zcode'
3
+ import type { MachineView } from '../utils/live'
4
+
5
+ /**
6
+ * The object tree, as an ARIA tree (WAI-ARIA APG "tree view"): one tab stop;
7
+ * Up/Down move, Right opens or goes to the first child, Left closes or goes to
8
+ * the parent, Home/End, Enter/Space opens the object, and typing a letter
9
+ * jumps to the next object whose name starts with it. Rendered flat, with
10
+ * aria-level, so only open branches are in the DOM.
11
+ */
12
+ const props = defineProps<{ story: StoryFile; selected?: number; live?: MachineView }>()
13
+
14
+ const objects = computed(() => props.story.objects.objects)
15
+ const count = computed(() => objects.value.length)
16
+ const get = (n: number) => (n >= 1 && n <= count.value ? objects.value[n - 1] : undefined)
17
+
18
+ /** Children of each object, by following child then sibling; a cycle (a broken file) stops the walk. */
19
+ const children = computed(() => {
20
+ const out = new Map<number, number[]>()
21
+ for (const o of objects.value) {
22
+ const list: number[] = []
23
+ const seen = new Set<number>()
24
+ for (let c = o.child; get(c) && !seen.has(c); c = get(c)!.sibling) {
25
+ seen.add(c)
26
+ list.push(c)
27
+ }
28
+ out.set(o.number, list)
29
+ }
30
+ return out
31
+ })
32
+ const roots = computed(() => objects.value.filter(o => !get(o.parent)).map(o => o.number))
33
+
34
+ const open = ref(new Set<number>())
35
+ // Rooms (the roots' children are their contents) start open; so does the path to the selected object.
36
+ watch(roots, r => (open.value = new Set(r.filter(n => (children.value.get(n)?.length ?? 0) > 0))), { immediate: true })
37
+ watch(() => props.selected, (n) => {
38
+ const next = new Set(open.value)
39
+ const seen = new Set<number>()
40
+ for (let p = get(n ?? 0)?.parent ?? 0; get(p) && !seen.has(p); p = get(p)!.parent) {
41
+ seen.add(p)
42
+ next.add(p)
43
+ }
44
+ open.value = next
45
+ }, { immediate: true })
46
+
47
+ interface Row { key: string; n: number; level: number; setSize: number; posInSet: number; parent: number; empty?: boolean }
48
+ /**
49
+ * The visible rows, depth first, without recursion (a deep tree can't overflow the stack). An object
50
+ * shows at most once: in a broken file one could be reached twice, or in a loop.
51
+ */
52
+ const rows = computed<Row[]>(() => {
53
+ const out: Row[] = []
54
+ // A level's objects are claimed when the level is reached, so its set size and positions count
55
+ // only what is shown there (a broken file can list an object twice, or loop).
56
+ const claimed = new Set<number>()
57
+ const claim = (list: number[]) =>
58
+ list.filter((n) => {
59
+ if (claimed.has(n)) return false
60
+ claimed.add(n)
61
+ return true
62
+ })
63
+ const stack: { list: number[]; i: number; level: number; parent: number }[] = [{ list: claim(roots.value), i: 0, level: 1, parent: 0 }]
64
+ while (stack.length) {
65
+ const top = stack.at(-1)!
66
+ if (top.i >= top.list.length) {
67
+ stack.pop()
68
+ continue
69
+ }
70
+ const n = top.list[top.i++]!
71
+ const row: Row = { key: `${top.parent}-${n}`, n, level: top.level, setSize: top.list.length, posInSet: top.i, parent: top.parent }
72
+ out.push(row)
73
+ if (open.value.has(n)) {
74
+ const kids = claim(children.value.get(n) ?? [])
75
+ if (kids.length) stack.push({ list: kids, i: 0, level: top.level + 1, parent: n })
76
+ else row.empty = true // open, but every child is shown elsewhere: not expandable here
77
+ }
78
+ }
79
+ return out
80
+ })
81
+
82
+ /** The tab stop follows a node, not a row index, so opening or closing a branch above it keeps it. */
83
+ const focusedKey = ref('')
84
+ const focused = computed(() => Math.max(0, rows.value.findIndex(r => r.key === focusedKey.value)))
85
+ watch(() => props.selected, (n) => {
86
+ const r = rows.value.find(x => x.n === n)
87
+ if (r) focusedKey.value = r.key
88
+ }, { immediate: true, flush: 'post' })
89
+
90
+ const tree = useTemplateRef<HTMLElement>('tree')
91
+ function focusRow(i: number) {
92
+ const r = rows.value[Math.max(0, Math.min(rows.value.length - 1, i))]
93
+ if (!r) return
94
+ focusedKey.value = r.key
95
+ nextTick(() => tree.value?.querySelector<HTMLElement>(`[data-row="${focused.value}"]`)?.focus())
96
+ }
97
+ function toggle(n: number) {
98
+ const next = new Set(open.value)
99
+ if (next.has(n)) next.delete(n)
100
+ else next.add(n)
101
+ open.value = next
102
+ }
103
+ const hasChildren = (n: number) => (children.value.get(n)?.length ?? 0) > 0
104
+ const name = (n: number) => objectName(props.story, n)
105
+ const go = (n: number) => (location.hash = `object/${n}`)
106
+
107
+ function onKey(e: KeyboardEvent) {
108
+ const r = rows.value[focused.value]
109
+ if (!r) return
110
+ switch (e.key) {
111
+ case 'ArrowDown': focusRow(focused.value + 1); break
112
+ case 'ArrowUp': focusRow(focused.value - 1); break
113
+ case 'Home': focusRow(0); break
114
+ case 'End': focusRow(rows.value.length - 1); break
115
+ case 'ArrowRight':
116
+ if (!hasChildren(r.n)) return
117
+ if (!open.value.has(r.n)) toggle(r.n)
118
+ else focusRow(focused.value + 1)
119
+ break
120
+ case 'ArrowLeft':
121
+ if (open.value.has(r.n) && hasChildren(r.n)) toggle(r.n)
122
+ else if (r.parent) focusRow(rows.value.findIndex(x => x.n === r.parent))
123
+ else return
124
+ break
125
+ case 'Enter':
126
+ case ' ':
127
+ go(r.n)
128
+ break
129
+ default: {
130
+ // Type-ahead: the next object (after this one) whose name starts with the letter.
131
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return
132
+ const k = e.key.toLowerCase()
133
+ const len = rows.value.length
134
+ for (let j = 1; j <= len; j++) {
135
+ const i = (focused.value + j) % len
136
+ if (name(rows.value[i]!.n).toLowerCase().startsWith(k)) {
137
+ focusRow(i)
138
+ break
139
+ }
140
+ }
141
+ }
142
+ }
143
+ e.preventDefault()
144
+ }
145
+ </script>
146
+
147
+ <template>
148
+ <ul ref="tree" role="tree" aria-label="Object tree" class="flex flex-col py-1 text-sm" @keydown="onKey">
149
+ <li
150
+ v-for="(r, i) in rows"
151
+ :key="r.key"
152
+ role="treeitem"
153
+ :data-row="i"
154
+ :aria-level="r.level"
155
+ :aria-setsize="r.setSize"
156
+ :aria-posinset="r.posInSet"
157
+ :aria-expanded="hasChildren(r.n) && !r.empty ? open.has(r.n) : undefined"
158
+ :aria-selected="r.n === selected"
159
+ :tabindex="i === focused ? 0 : -1"
160
+ class="flex cursor-pointer items-center gap-1 rounded py-1 pr-2 outline-offset-[-2px] hover:bg-elevated"
161
+ :class="r.n === selected ? 'bg-elevated font-semibold text-highlighted' : ''"
162
+ :style="{ paddingLeft: `${(r.level - 1) * 1.1 + 0.25}rem` }"
163
+ @click="focusedKey = r.key; go(r.n)"
164
+ >
165
+ <span
166
+ class="inline-flex size-5 shrink-0 items-center justify-center text-muted"
167
+ aria-hidden="true"
168
+ @click.stop="focusedKey = r.key; hasChildren(r.n) && toggle(r.n)"
169
+ >
170
+ <UIcon v-if="hasChildren(r.n)" :name="open.has(r.n) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-4" />
171
+ </span>
172
+ <span class="truncate" :class="live?.changed.objects.has(r.n) ? 'font-semibold text-lamp' : ''">{{ name(r.n) }}</span>
173
+ <span v-if="live?.changed.objects.has(r.n)" class="rounded-full border border-default px-1.5 text-xs text-lamp">changed</span>
174
+ <span class="data ml-auto pl-2 text-xs text-muted">{{ r.n }}</span>
175
+ </li>
176
+ </ul>
177
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import type { StoryFile } from '@frotzinc/zcode'
3
+
4
+ /** A link to anything with a model Id, labelled as txd would name it. */
5
+ const props = defineProps<{ story: StoryFile; id: string; label?: string }>()
6
+ const text = computed(() => props.label ?? idLabel(props.story, props.id))
7
+ </script>
8
+
9
+ <template>
10
+ <a :href="idRoute(id)" class="text-lamp underline-offset-2 hover:underline">{{ text }}</a>
11
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import type { StoryFile } from '@frotzinc/zcode'
3
+
4
+ /** Both directions of the cross-reference index for one Id: what refers to it, and what it refers to. */
5
+ const props = defineProps<{ story: StoryFile; id: string; fromTitle?: string; toTitle?: string }>()
6
+ const entry = computed(() => props.story.xrefs[props.id])
7
+ const from = useLimit(() => entry.value?.from ?? [], 100)
8
+ const to = useLimit(() => entry.value?.to ?? [], 100)
9
+ watch(() => props.id, () => {
10
+ from.reset()
11
+ to.reset()
12
+ })
13
+ </script>
14
+
15
+ <template>
16
+ <div class="grid gap-6 md:grid-cols-2">
17
+ <section :aria-label="fromTitle ?? 'Referred to from'" class="flex flex-col gap-2">
18
+ <h2 class="font-display text-xl font-semibold text-highlighted">
19
+ {{ fromTitle ?? 'Referred to from' }} <span class="data text-base text-muted">{{ entry?.from.length ?? 0 }}</span>
20
+ </h2>
21
+ <p v-if="!entry?.from.length" class="text-sm text-muted">Nothing in the file refers to this.</p>
22
+ <ul v-else class="flex flex-col gap-1 text-sm">
23
+ <li v-for="(s, i) in from.shown.value" :key="i" class="flex gap-2">
24
+ <RefLink :story="story" :id="s.id" />
25
+ <span class="data text-muted">at {{ hex(s.address) }}</span>
26
+ </li>
27
+ </ul>
28
+ <UButton v-if="from.remaining.value" color="neutral" variant="link" class="w-fit px-0" @click="from.more">
29
+ Show more ({{ from.remaining.value }} left)
30
+ </UButton>
31
+ </section>
32
+ <section :aria-label="toTitle ?? 'Refers to'" class="flex flex-col gap-2">
33
+ <h2 class="font-display text-xl font-semibold text-highlighted">
34
+ {{ toTitle ?? 'Refers to' }} <span class="data text-base text-muted">{{ entry?.to.length ?? 0 }}</span>
35
+ </h2>
36
+ <p v-if="!entry?.to.length" class="text-sm text-muted">This refers to nothing the index tracks.</p>
37
+ <ul v-else class="flex flex-col gap-1 text-sm">
38
+ <li v-for="t in to.shown.value" :key="t"><RefLink :story="story" :id="t" /></li>
39
+ </ul>
40
+ <UButton v-if="to.remaining.value" color="neutral" variant="link" class="w-fit px-0" @click="to.more">
41
+ Show more ({{ to.remaining.value }} left)
42
+ </UButton>
43
+ </section>
44
+ </div>
45
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import type { StoryFile } from '@frotzinc/zcode'
3
+
4
+ /** ⌘K / Ctrl+K: jump to any routine, object, string, dictionary word or global, by name or address. */
5
+ const props = defineProps<{ story: StoryFile }>()
6
+ const open = defineModel<boolean>('open', { default: false })
7
+ const { goToId } = useHashRoute()
8
+ const hex4 = (n: number) => n.toString(16).padStart(4, '0')
9
+ const clip = (s: string) => (s.length > 70 ? `${s.slice(0, 70)}…` : s).replace(/\s+/g, ' ')
10
+
11
+ const groups = computed(() => {
12
+ const s = props.story
13
+ const pick = (id: string) => () => {
14
+ open.value = false
15
+ goToId(id)
16
+ }
17
+ return [
18
+ {
19
+ id: 'objects',
20
+ label: 'Objects',
21
+ items: s.objects.objects.map(o => ({ label: o.shortName.text || `(object ${o.number})`, suffix: `#${o.number}`, icon: 'i-lucide-box', onSelect: pick(o.id) })),
22
+ },
23
+ {
24
+ id: 'routines',
25
+ label: 'Routines',
26
+ // With names on, a routine is found by its source name, and by its R-number too.
27
+ items: s.code.routines.map((r, i) => {
28
+ const txd = `R${String(i + 1).padStart(4, '0')}`
29
+ const name = routineName(s, r.address)
30
+ return { label: name, ...(name !== txd ? { text: txd } : {}), suffix: hex4(r.address), icon: 'i-lucide-code', onSelect: pick(r.id) }
31
+ }),
32
+ },
33
+ {
34
+ id: 'dictionary',
35
+ label: 'Dictionary',
36
+ items: s.dictionary.entries.map(e => ({ label: e.word, suffix: hex4(e.address), icon: 'i-lucide-book-a', onSelect: pick(e.id) })),
37
+ },
38
+ {
39
+ id: 'globals',
40
+ label: 'Globals',
41
+ items: s.globals.map(g => ({ label: globalName(g.number), text: `G${g.number.toString(16).padStart(2, '0')}`, suffix: hex4(g.address), icon: 'i-lucide-variable', onSelect: pick(g.id) })),
42
+ },
43
+ {
44
+ id: 'strings',
45
+ label: 'Strings',
46
+ // Shown clipped, searched whole: `text` is a search key, not displayed.
47
+ items: s.strings.map(x => ({ label: clip(x.text), text: x.text, suffix: hex4(x.address), icon: 'i-lucide-quote', onSelect: pick(x.id) })),
48
+ },
49
+ ]
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <UModal v-model:open="open" title="Go to" description="Find a routine, object, string, dictionary word or global by name or address.">
55
+ <template #content>
56
+ <UCommandPalette :groups="groups" placeholder="Search by name or address…" class="h-96" :fuse="{ resultLimit: 40, fuseOptions: { keys: ['label', 'suffix', 'text'] } }" @update:open="open = $event" />
57
+ </template>
58
+ </UModal>
59
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ value: string | number; label: string; to?: string }>()
3
+ </script>
4
+
5
+ <template>
6
+ <component
7
+ :is="to ? 'a' : 'div'"
8
+ :href="to"
9
+ class="flex flex-col gap-1 rounded-xl border border-default bg-muted px-5 py-4 transition-colors"
10
+ :class="to ? 'hover:border-[var(--frotz-lamp)]' : ''"
11
+ >
12
+ <span class="data text-3xl font-semibold text-highlighted sm:text-4xl">{{ value }}</span>
13
+ <span class="text-sm text-muted">{{ label }}</span>
14
+ </component>
15
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ /** A view's title, what it shows, a way back up (for detail views), and "Copy as text". */
3
+ defineProps<{ title: string; eyebrow?: string; back?: { to: string; label: string }; copy?: () => string; copyLabel?: string }>()
4
+ </script>
5
+
6
+ <template>
7
+ <header class="flex flex-col gap-3">
8
+ <a v-if="back" :href="back.to" class="w-fit text-sm text-muted hover:underline">← {{ back.label }}</a>
9
+ <p v-if="eyebrow" class="data text-sm text-lamp">{{ eyebrow }}</p>
10
+ <div class="flex flex-wrap items-end justify-between gap-4">
11
+ <h1 class="font-display text-3xl font-bold tracking-tight text-highlighted sm:text-4xl">{{ title }}</h1>
12
+ <CopyText v-if="copy" :text="copy" :label="copyLabel" />
13
+ </div>
14
+ <p v-if="$slots.default" class="max-w-3xl text-muted"><slot /></p>
15
+ </header>
16
+ </template>
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The view is the URL hash, so every view can be bookmarked and shared: #overview,
3
+ * #routine/27f8, #object/47, #string/1352c, #dict/215d, #memory/objects.
4
+ * A model Id maps to a route one to one: its first ':' becomes '/'.
5
+ */
6
+ export interface HashRoute {
7
+ view: string
8
+ id?: string
9
+ }
10
+
11
+ const current = ref<HashRoute>({ view: 'overview' })
12
+
13
+ function read(): HashRoute {
14
+ let h: string
15
+ try {
16
+ h = decodeURIComponent(location.hash.replace(/^#/, ''))
17
+ } catch {
18
+ return { view: 'overview' } // a malformed escape (a stray '%'): not a route
19
+ }
20
+ if (!h) return { view: 'overview' }
21
+ const slash = h.indexOf('/')
22
+ return slash < 0 ? { view: h } : { view: h.slice(0, slash), id: h.slice(slash + 1) }
23
+ }
24
+
25
+ let listening = false
26
+
27
+ export function useHashRoute() {
28
+ if (import.meta.client && !listening) {
29
+ listening = true
30
+ current.value = read()
31
+ window.addEventListener('hashchange', () => (current.value = read()))
32
+ }
33
+ /** Go to a route: `go('routine', '27f8')`, or a model Id: `goToId('routine:27f8')`. */
34
+ const go = (view: string, id?: string) => {
35
+ location.hash = id ? `${view}/${id}` : view
36
+ }
37
+ const goToId = (id: string) => {
38
+ const i = id.indexOf(':')
39
+ go(id.slice(0, i), id.slice(i + 1))
40
+ }
41
+ /** Replace the current route without a history entry (replaceState fires no hashchange). */
42
+ const replace = (view: string) => {
43
+ history.replaceState(null, '', `#${view}`)
44
+ current.value = read()
45
+ }
46
+ return { route: current, go, goToId, replace }
47
+ }
@@ -0,0 +1,73 @@
1
+ import { MAX_DEBUG_FILE, type StoryFile, type Symbols } from '@frotzinc/zcode'
2
+
3
+ /**
4
+ * Names from Inform's debug file (gameinfo.dbg), over the story that's open.
5
+ * Dropped anywhere, or chosen on the Symbols view. A debug file written for
6
+ * another story is refused, not applied. `names` (the header's Names/Numbers
7
+ * switch) chooses whether views show them; the story itself never changes.
8
+ */
9
+ const symbols = shallowRef<Symbols | null>(null)
10
+ const symbolsFile = ref('')
11
+ /** The debug file's text as read (frotzlens hands it to frotztrace with the story). */
12
+ const symbolsText = shallowRef('')
13
+ const symbolsStatus = ref<'none' | 'loading' | 'ready' | 'failed'>('none')
14
+ const symbolsMessage = ref('')
15
+ /** The last file chosen was refused (whatever is loaded stays). */
16
+ const symbolsError = ref(false)
17
+ /** The loaded file says which story it was written for, and it's this one (else: names shown unchecked). */
18
+ const symbolsVerified = ref(false)
19
+ const names = ref(true)
20
+ let generation = 0
21
+
22
+ /** The symbols the views should use: loaded, for this story, and switched on. */
23
+ export const activeSymbols = computed<Symbols | undefined>(() => (names.value && symbols.value ? symbols.value : undefined))
24
+
25
+ /** A debug file by its look: Inform's are named *.dbg, and are XML. */
26
+ export const isDebugFile = (file: File) => /\.dbg$/i.test(file.name)
27
+
28
+ export function useSymbols() {
29
+ async function loadSymbols(file: File, story: StoryFile) {
30
+ const mine = ++generation
31
+ symbolsStatus.value = 'loading'
32
+ symbolsMessage.value = ''
33
+ symbolsError.value = false
34
+ try {
35
+ // Checked before reading: an oversized file is never loaded into memory.
36
+ if (file.size > MAX_DEBUG_FILE) throw new Error(`It is ${Math.round(file.size / 1048576)} MB, more than a debug file can be.`)
37
+ const [{ readSymbols, symbolsMatch }, text] = await Promise.all([import('@frotzinc/zcode'), file.text()])
38
+ if (mine !== generation) return
39
+ const s = readSymbols(text)
40
+ const usable = Object.keys(s.routines).length > 0 || Object.keys(s.objects).length > 0
41
+ if (!usable) throw new Error(s.problems[0] ?? 'It has no names in it.')
42
+ const match = symbolsMatch(story.bytes, s)
43
+ if (match === false) throw new Error(`${file.name} was written for another story file, so its names would be wrong here.`)
44
+ symbols.value = markRaw(s)
45
+ symbolsText.value = text
46
+ symbolsFile.value = file.name
47
+ symbolsStatus.value = 'ready'
48
+ symbolsVerified.value = match === true
49
+ names.value = true
50
+ // A file with no story prefix can't be checked: its names are shown, and that is said.
51
+ symbolsMessage.value = [match === undefined ? `${file.name} doesn't say which story it was written for, so its names can't be checked against this one.` : '', ...s.problems].filter(Boolean).join(' ')
52
+ } catch (e) {
53
+ if (mine !== generation) return
54
+ symbolsStatus.value = symbols.value ? 'ready' : 'failed'
55
+ symbolsError.value = true
56
+ symbolsMessage.value = `${file.name} couldn't be used: ${e instanceof Error ? e.message : String(e)}`
57
+ }
58
+ }
59
+
60
+ function clearSymbols() {
61
+ generation++
62
+ symbols.value = null
63
+ symbolsText.value = ''
64
+ symbolsFile.value = ''
65
+ symbolsStatus.value = 'none'
66
+ symbolsMessage.value = ''
67
+ symbolsError.value = false
68
+ symbolsVerified.value = false
69
+ names.value = true
70
+ }
71
+
72
+ return { symbols, symbolsFile, symbolsText, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
73
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A running machine, as a view sees it: where it is, and what changed since it last stopped. The
3
+ * Debugger's own type (@frotzinc/zmachine, since frotztrace Phase 3), re-exported here: every view
4
+ * takes it as an optional `live` prop; without one (frotzlens, a story file at rest) a view renders
5
+ * exactly as it always has.
6
+ */
7
+ export type { MachineView } from '@frotzinc/zmachine'
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Grid placement for the map: Trizbort's rule, as frotzsmith's map draws it
3
+ * (ported from frotzsmith's app/composables/map-graph.ts, by the same author,
4
+ * keyed by object number here instead of room name). The start room sits at
5
+ * (0, 0); each exit puts its room one step away in its compass direction,
6
+ * or the nearest free cell when that one is taken; up, down, in and out go
7
+ * to the nearest free cell. Rooms no exit reaches start a group of their own.
8
+ */
9
+ import type { Direction, RoomGraph } from '@frotzinc/zcode'
10
+
11
+ export interface PlacedRoom { number: number; name: string; col: number; row: number }
12
+ /** grid: drawn on its compass step; routine: an exit a routine decides (drawn dashed). */
13
+ export interface Connector { from: number; to: number; dir: Direction; grid: boolean; routine: boolean }
14
+ export interface MapLayout {
15
+ rooms: PlacedRoom[]
16
+ connectors: Connector[]
17
+ bounds: { minCol: number; maxCol: number; minRow: number; maxRow: number }
18
+ }
19
+
20
+ export const DELTA: Record<Direction, [number, number]> = {
21
+ n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0],
22
+ ne: [1, -1], nw: [-1, -1], se: [1, 1], sw: [-1, 1],
23
+ u: [0, 0], d: [0, 0], in: [0, 0], out: [0, 0],
24
+ }
25
+
26
+ /** Each exit that reaches a room, or for a routine exit each room it can return: what the map draws. */
27
+ export function mapEdges(g: RoomGraph): { from: number; to: number; dir: Direction; routine: boolean }[] {
28
+ const rooms = new Set(g.rooms.map(r => r.number))
29
+ return g.exits.flatMap(e =>
30
+ (e.to !== undefined ? [e.to] : (e.candidates ?? [])).filter(t => rooms.has(t)).map(to => ({ from: e.from, to, dir: e.dir, routine: e.kind === 'routine' })),
31
+ )
32
+ }
33
+
34
+ export function layoutMap(g: RoomGraph): MapLayout {
35
+ const edges = mapEdges(g)
36
+ const pos = new Map<number, [number, number]>()
37
+ const occupied = new Set<string>()
38
+ const key = (c: number, r: number) => `${c},${r}`
39
+ const place = (room: number, c: number, r: number) => {
40
+ pos.set(room, [c, r])
41
+ occupied.add(key(c, r))
42
+ }
43
+ // Rings outward until a free cell: there are only so many rooms, so one always turns up.
44
+ const nearestFree = (c: number, r: number): [number, number] => {
45
+ for (let ring = 0; ; ring++)
46
+ for (let dc = -ring; dc <= ring; dc++)
47
+ for (let dr = -ring; dr <= ring; dr++) {
48
+ if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue
49
+ if (!occupied.has(key(c + dc, r + dr))) return [c + dc, r + dr]
50
+ }
51
+ }
52
+ const compass = (e: { dir: Direction }) => DELTA[e.dir][0] !== 0 || DELTA[e.dir][1] !== 0
53
+ /**
54
+ * Place rooms from placed ones until nothing more can be. Compass exits go first, each to its step
55
+ * (or the nearest free cell); only when none is left does one up/down/in/out exit place its room,
56
+ * so a room any compass exit reaches is put by that, whatever order the exits come in.
57
+ */
58
+ const relax = () => {
59
+ for (;;) {
60
+ let changed = false
61
+ for (const e of edges) {
62
+ const from = pos.get(e.from)
63
+ if (!from || pos.has(e.to) || !compass(e)) continue
64
+ const [dc, dr] = DELTA[e.dir]
65
+ if (!occupied.has(key(from[0] + dc, from[1] + dr))) place(e.to, from[0] + dc, from[1] + dr)
66
+ else place(e.to, ...nearestFree(from[0] + dc, from[1] + dr))
67
+ changed = true
68
+ }
69
+ if (changed) continue
70
+ const e = edges.find(x => pos.has(x.from) && !pos.has(x.to))
71
+ if (!e) return
72
+ const from = pos.get(e.from)!
73
+ place(e.to, ...nearestFree(from[0], from[1]))
74
+ }
75
+ }
76
+ const first = g.start ?? g.rooms[0]?.number
77
+ if (first !== undefined) place(first, 0, 0)
78
+ relax()
79
+ for (const r of g.rooms) {
80
+ if (pos.has(r.number)) continue
81
+ place(r.number, ...nearestFree(0, 0))
82
+ relax()
83
+ }
84
+
85
+ const connectors = edges.map(e => {
86
+ const a = pos.get(e.from)!
87
+ const b = pos.get(e.to)!
88
+ const [dc, dr] = DELTA[e.dir]
89
+ return { ...e, grid: (dc !== 0 || dr !== 0) && b[0] === a[0] + dc && b[1] === a[1] + dr }
90
+ })
91
+ const placed = [...pos.values()]
92
+ const cols = placed.map(p => p[0])
93
+ const rows = placed.map(p => p[1])
94
+ const bounds = placed.length
95
+ ? { minCol: Math.min(...cols), maxCol: Math.max(...cols), minRow: Math.min(...rows), maxRow: Math.max(...rows) }
96
+ : { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 }
97
+ return { rooms: g.rooms.map(r => ({ ...r, col: pos.get(r.number)![0], row: pos.get(r.number)![1] })), connectors, bounds }
98
+ }
@@ -0,0 +1,10 @@
1
+ /** One entry in AppNav: a view, by its route key. */
2
+ export interface NavView {
3
+ key: string
4
+ label: string
5
+ icon: string
6
+ /** How many things the view lists, shown at the right. */
7
+ count?: number
8
+ /** A word shown at the right instead of a count. */
9
+ tag?: string
10
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Names for the things in a story, as txd and infodump print them (R0001,
3
+ * S001, an object's short name, a dictionary word), and the hash route for any
4
+ * model Id. Worked out once per story and cached.
5
+ */
6
+ import type { StoryFile } from '@frotzinc/zcode'
7
+ import { txdRoutineName, txdStringName } from '@frotzinc/zcode/format'
8
+ import { activeSymbols } from '../composables/useSymbols'
9
+
10
+ export const hex = (n: number, width = 4) => n.toString(16).padStart(width, '0')
11
+
12
+ /** The hash route for a model Id: `routine:27f8` → `#routine/27f8`. */
13
+ export const idRoute = (id: string) => `#${id.replace(':', '/')}`
14
+
15
+ interface Index {
16
+ routineNumber: Map<number, number>
17
+ stringNumber: Map<number, number>
18
+ stringByAddress: Map<number, { text: string }>
19
+ dictByAddress: Map<number, { word: string }>
20
+ }
21
+
22
+ const cache = new WeakMap<StoryFile, Index>()
23
+
24
+ export function storyIndex(story: StoryFile): Index {
25
+ let i = cache.get(story)
26
+ if (!i) {
27
+ i = {
28
+ routineNumber: new Map(story.code.routines.map((r, n) => [r.address, n + 1])),
29
+ stringNumber: new Map(story.strings.map((s, n) => [s.address, n + 1])),
30
+ stringByAddress: new Map(story.strings.map(s => [s.address, s])),
31
+ dictByAddress: new Map(story.dictionary.entries.map(e => [e.address, e])),
32
+ }
33
+ cache.set(story, i)
34
+ }
35
+ return i
36
+ }
37
+
38
+ /**
39
+ * A routine's name: its source name when a debug file is loaded and names are on, else txd's
40
+ * (R0001), from zcode, so a link reads exactly as the disassembly does.
41
+ */
42
+ export const routineName = (story: StoryFile, address: number) => activeSymbols.value?.routines[address]?.name ?? txdRoutineName(story, address)
43
+ export const stringName = txdStringName
44
+
45
+ /** A global: its source name with symbols, else txd's G00. */
46
+ export const globalName = (n: number) => activeSymbols.value?.globals[n] ?? `G${hex(n, 2)}`
47
+ /** An attribute, property or action number, with its source name when there is one. */
48
+ export const attributeName = (n: number) => activeSymbols.value?.attributes[n]
49
+ export const propertyName = (n: number) => activeSymbols.value?.properties[n]
50
+ export const actionName = (n: number) => activeSymbols.value?.actions[n]
51
+
52
+ export const objectName = (story: StoryFile, n: number) =>
53
+ story.objects.objects[n - 1]?.shortName.text || `object ${n}`
54
+
55
+ export const clip = (s: string, n = 60) => {
56
+ const one = s.replace(/\s+/g, ' ')
57
+ return one.length > n ? `${one.slice(0, n)}…` : one
58
+ }
59
+
60
+ /** A short, readable label for any model Id. */
61
+ export function idLabel(story: StoryFile, id: string): string {
62
+ const [kind, rest = ''] = id.split(':')
63
+ const n = parseInt(rest, kind === 'object' || kind === 'global' || kind === 'abbrev' ? 10 : 16)
64
+ switch (kind) {
65
+ case 'routine': return routineName(story, n)
66
+ case 'string': {
67
+ const s = storyIndex(story).stringByAddress.get(n)
68
+ return s ? `${stringName(story, n)} “${clip(s.text, 40)}”` : stringName(story, n)
69
+ }
70
+ case 'object': return `${objectName(story, n)} (#${n})`
71
+ case 'dict': {
72
+ const e = storyIndex(story).dictByAddress.get(n)
73
+ return e ? `“${e.word}”` : `word at ${hex(n)}`
74
+ }
75
+ case 'global': return globalName(n)
76
+ default: return id
77
+ }
78
+ }
79
+
80
+ /** Show a long list a page at a time: `shown` is the first `limit`, `more()` adds a page. */
81
+ export function useLimit<T>(list: () => T[], step = 200) {
82
+ const limit = ref(step)
83
+ const shown = computed(() => list().slice(0, limit.value))
84
+ const remaining = computed(() => Math.max(0, list().length - limit.value))
85
+ const more = () => (limit.value += step)
86
+ return { shown, remaining, more, reset: () => (limit.value = step) }
87
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @frotzinc/zcode-ui — the Z-machine story views shared by frotzlens and frotztrace, as a Nuxt layer.
3
+ *
4
+ * export default defineNuxtConfig({ extends: ['@frotzinc/nuxt-base', '@frotzinc/zcode-ui'] })
5
+ *
6
+ * Gives, auto-imported: the views (ObjectTree, GlobalsView, MapView, Disassembly, HexPane,
7
+ * SearchPalette, AppNav) and their parts (RefLink, Referrers, ViewHeader, CopyText, StatTile,
8
+ * NotFound); the composables useHashRoute and useSymbols; and the utils in app/utils (hex,
9
+ * routineName, idLabel, layoutMap, …). It adds no colors, fonts or CSS: the look is
10
+ * @frotzinc/nuxt-base's, so every frotz, Inc. app looks the same. The app lists @nuxt/ui in `modules`.
11
+ */
12
+ export default defineNuxtConfig({})