Compare commits

..

5 Commits

Author SHA1 Message Date
Julien Calixte
fbfbbdc0be test(completion): cover bracket-header block separation
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m7s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-10 14:30:30 +01:00
Julien Calixte
b17de475ff fix(completion): separate a new block from the one above on accept
Accepting a bracketed [[…]] header typed directly under a block welded
the two together. When no blank line sits above, the insert now carries
a leading newline (never at the top of the document).
2026-07-10 14:30:30 +01:00
Julien Calixte
f7e856b082 test(editor): cover Enter-accepts and Escape newline semantics 2026-07-10 14:30:24 +01:00
Julien Calixte
8c14ae9c42 feat(editor): always accept completions on Enter
Enter now accepts the highlighted item like Tab whenever the popup is
open; dismiss with Escape first to insert a plain newline. Drops the
`engaged` gate that only accepted after navigating the menu.
2026-07-10 14:30:24 +01:00
Julien Calixte
f535c9ede5 feat(grid): widen the feature name column so longer names show 2026-07-10 14:30:13 +01:00
5 changed files with 44 additions and 25 deletions

View File

@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
} }
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math. // Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16 const NAME_W = 16 * 16
const WK = 3.5 * 16 const WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
<style scoped> <style scoped>
.macroplan { .macroplan {
--name-w: 9rem; --name-w: 16rem;
--wk: 3.5rem; --wk: 3.5rem;
} }
.plan-grid { .plan-grid {

View File

@@ -54,20 +54,21 @@ describe("PlanEditor completion keys", () => {
expect(wrapper.find(".completion").exists()).toBe(false) expect(wrapper.find(".completion").exists()).toBe(false)
}) })
it("Enter does not accept an untouched popup — it closes instead", async () => { it("Enter accepts the selected item without navigating first", async () => {
const wrapper = mountEditor() const wrapper = mountEditor()
await type(wrapper, "t") await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Enter" }) await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
// No accept happened: the last emit is still the raw typed value. expect(lastEmit(wrapper)).toBe("title = ")
expect(lastEmit(wrapper)).toBe("t")
expect(wrapper.find(".completion").exists()).toBe(false) expect(wrapper.find(".completion").exists()).toBe(false)
}) })
it("Enter accepts once the author has navigated the popup", async () => { it("Escape dismisses the popup so the next Enter is a newline, not an accept", async () => {
const wrapper = mountEditor() const wrapper = mountEditor()
await type(wrapper, "t") await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "ArrowDown" }) await wrapper.find("textarea").trigger("keydown", { key: "Escape" })
expect(wrapper.find(".completion").exists()).toBe(false)
await wrapper.find("textarea").trigger("keydown", { key: "Enter" }) await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
expect(lastEmit(wrapper)).toBe("title = ") // Popup is gone, so Enter accepts nothing — the value stays as typed.
expect(lastEmit(wrapper)).toBe("t")
}) })
}) })

View File

@@ -19,10 +19,6 @@ 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 }
@@ -64,7 +60,6 @@ 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()
} }
@@ -125,7 +120,6 @@ 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).
@@ -140,20 +134,13 @@ function onKeydown(e: KeyboardEvent) {
case "ArrowUp": case "ArrowUp":
move(-1) move(-1)
break break
case "Enter":
case "Tab": case "Tab":
// Both accept the highlighted item whenever the popup is open. To insert a
// newline instead, dismiss the popup with Escape first, then press Enter.
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

View File

@@ -157,3 +157,25 @@ describe("completion replace range", () => {
expect(ctx.items[0].insert).toBe("[[feature]]") expect(ctx.items[0].insert).toBe("[[feature]]")
}) })
}) })
describe("bracket header block separation", () => {
/** The inserts offered at the `|` marker. */
const insertsAt = (marked: string) => {
const caret = marked.indexOf("|")
const source = marked.slice(0, caret) + marked.slice(caret + 1)
return getCompletions(source, caret)!.items.map((i) => i.insert)
}
it("prepends a blank line when a bare [ glues onto the block above", () => {
// No blank line above ⇒ accepting must not weld the new block to the old one.
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
})
it("adds no leading newline when a blank line already separates the block", () => {
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
})
it("adds no leading newline at the very top of the document", () => {
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
})
})

View File

@@ -103,7 +103,7 @@ export function getCompletions(
if (key) { if (key) {
const [, indent, brackets, word] = key const [, indent, brackets, word] = key
if (brackets) { if (brackets) {
return result(lineStart + indent.length, caret, filter(HEADERS, word)) return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
} }
const keys = const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
@@ -128,6 +128,15 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : null return items.length ? { from, to, items } : null
} }
/** Header completions for a bare `[`. When the block above isn't already set off
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
* never glues the new block onto the previous one. (At the top of the document
* there's nothing to separate, so no gap is added.) */
function headerItems(source: string, lineStart: number): Completion[] {
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
}
/** Is the line directly above the caret's line blank (empty or whitespace only)? /** 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. */ * Gates header suggestions to the "two new lines" rule — see the key branch. */
function blankLineAbove(source: string, lineStart: number): boolean { function blankLineAbove(source: string, lineStart: number): boolean {