Compare commits
3 Commits
db77314a88
...
f20935a85b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20935a85b | ||
|
|
96c3f489a3 | ||
|
|
449be7766e |
@@ -491,7 +491,6 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
|||||||
|
|
||||||
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
|
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
|
||||||
|
|
||||||
|
|
||||||
## 7. Critical performance budget
|
## 7. Critical performance budget
|
||||||
|
|
||||||
| Rank | Function | Target | Watched on | If we miss it |
|
| Rank | Function | Target | Watched on | If we miss it |
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const popup = ref({ left: 0, top: 0 })
|
|||||||
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
|
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
|
||||||
// back open while the author is still typing the word they just dismissed.
|
// back open while the author is still typing the word they just dismissed.
|
||||||
let justEscaped = false
|
let justEscaped = false
|
||||||
|
// Whether the author has moved the selection since the popup (re)opened. Tab
|
||||||
|
// always accepts; Enter only accepts once they've engaged with the menu — an
|
||||||
|
// untouched popup lets Enter do its normal job (a newline) rather than hijack it.
|
||||||
|
let engaged = false
|
||||||
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
|
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
|
||||||
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
||||||
|
|
||||||
@@ -58,6 +62,7 @@ function refresh() {
|
|||||||
if (!el) return
|
if (!el) return
|
||||||
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
||||||
selected.value = 0
|
selected.value = 0
|
||||||
|
engaged = false
|
||||||
if (completion.value) position()
|
if (completion.value) position()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +104,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const move = (delta: number) => {
|
const move = (delta: number) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
engaged = true
|
||||||
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
||||||
}
|
}
|
||||||
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
||||||
@@ -113,11 +119,20 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
move(-1)
|
move(-1)
|
||||||
break
|
break
|
||||||
case "Enter":
|
|
||||||
case "Tab":
|
case "Tab":
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
accept(selected.value)
|
accept(selected.value)
|
||||||
break
|
break
|
||||||
|
case "Enter":
|
||||||
|
// Only steal Enter once the author has navigated the menu; otherwise let
|
||||||
|
// it insert a newline (onInput → refresh reopens the popup if warranted).
|
||||||
|
if (engaged) {
|
||||||
|
e.preventDefault()
|
||||||
|
accept(selected.value)
|
||||||
|
} else {
|
||||||
|
completion.value = null
|
||||||
|
}
|
||||||
|
break
|
||||||
case "Escape":
|
case "Escape":
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
completion.value = null
|
completion.value = null
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("completion context", () => {
|
describe("completion context", () => {
|
||||||
it("offers plan keys and block headers on an empty top-level line", () => {
|
it("offers plan keys on an empty top-level line", () => {
|
||||||
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
|
// Headers wait for a blank line above; at the very top there's nothing above.
|
||||||
|
expect(labelsAt("|")).toEqual(["title", "start", "end"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("offers feature keys inside a [[feature]] block", () => {
|
it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
|
||||||
expect(labelsAt("[[feature]]\n|")).toEqual([
|
expect(labelsAt("[[feature]]\n|")).toEqual([
|
||||||
"name",
|
"name",
|
||||||
"start",
|
"start",
|
||||||
@@ -28,14 +29,27 @@ describe("completion context", () => {
|
|||||||
"status",
|
"status",
|
||||||
"learning",
|
"learning",
|
||||||
"note",
|
"note",
|
||||||
"[[feature]]",
|
|
||||||
"[[milestone]]",
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("offers milestone keys inside a [[milestone]] block", () => {
|
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
|
||||||
expect(labelsAt("[[milestone]]\n|")).toEqual([
|
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
|
||||||
"name",
|
})
|
||||||
|
|
||||||
|
it("keeps offering remaining keys on a single new line, still without headers", () => {
|
||||||
|
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("volunteers block headers only after a blank line (two new lines)", () => {
|
||||||
|
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
|
||||||
|
// Directly under the last property: nothing left to add ⇒ no popup.
|
||||||
|
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
|
||||||
|
// A blank line signals a new block is wanted ⇒ offer the headers.
|
||||||
|
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("volunteers headers after a blank line even with keys still open", () => {
|
||||||
|
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
|
||||||
"week",
|
"week",
|
||||||
"requires",
|
"requires",
|
||||||
"[[feature]]",
|
"[[feature]]",
|
||||||
|
|||||||
@@ -108,7 +108,16 @@ export function getCompletions(
|
|||||||
const keys =
|
const keys =
|
||||||
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
||||||
const taken = presentKeys(source, lineStart, block)
|
const taken = presentKeys(source, lineStart, block)
|
||||||
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
|
const remaining = keys.filter((k) => !taken.has(k.label))
|
||||||
|
// Volunteer a new block header only once there's a blank line above: a new
|
||||||
|
// [[…]] wants a gap before it, so offering one right under the last property
|
||||||
|
// (or straight after the header line) pops the menu up too eagerly. Typing a
|
||||||
|
// bare `[` still summons headers anywhere (handled above). With all keys set
|
||||||
|
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
|
||||||
|
const items = filter(
|
||||||
|
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
|
||||||
|
word,
|
||||||
|
)
|
||||||
return result(caret - word.length, caret, items)
|
return result(caret - word.length, caret, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +128,14 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
|
|||||||
return items.length ? { from, to, items } : null
|
return items.length ? { from, to, items } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is the line directly above the caret's line blank (empty or whitespace only)?
|
||||||
|
* Gates header suggestions to the "two new lines" rule — see the key branch. */
|
||||||
|
function blankLineAbove(source: string, lineStart: number): boolean {
|
||||||
|
if (lineStart === 0) return false // top of the document — nothing above
|
||||||
|
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
|
||||||
|
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
|
||||||
|
}
|
||||||
|
|
||||||
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
||||||
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
||||||
function todayYmd(): string {
|
function todayYmd(): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user