Compare commits
5 Commits
616a4c4301
...
fbfbbdc0be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfbbdc0be | ||
|
|
b17de475ff | ||
|
|
f7e856b082 | ||
|
|
8c14ae9c42 | ||
|
|
f535c9ede5 |
@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
|
||||
}
|
||||
|
||||
// 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 BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||
|
||||
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
|
||||
|
||||
<style scoped>
|
||||
.macroplan {
|
||||
--name-w: 9rem;
|
||||
--name-w: 16rem;
|
||||
--wk: 3.5rem;
|
||||
}
|
||||
.plan-grid {
|
||||
|
||||
@@ -54,20 +54,21 @@ describe("PlanEditor completion keys", () => {
|
||||
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()
|
||||
await type(wrapper, "t")
|
||||
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
|
||||
// No accept happened: the last emit is still the raw typed value.
|
||||
expect(lastEmit(wrapper)).toBe("t")
|
||||
expect(lastEmit(wrapper)).toBe("title = ")
|
||||
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()
|
||||
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" })
|
||||
expect(lastEmit(wrapper)).toBe("title = ")
|
||||
// Popup is gone, so Enter accepts nothing — the value stays as typed.
|
||||
expect(lastEmit(wrapper)).toBe("t")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
// back open while the author is still typing the word they just dismissed.
|
||||
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.
|
||||
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
||||
|
||||
@@ -64,7 +60,6 @@ function refresh() {
|
||||
if (!el) return
|
||||
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
||||
selected.value = 0
|
||||
engaged = false
|
||||
if (completion.value) position()
|
||||
}
|
||||
|
||||
@@ -125,7 +120,6 @@ function onKeydown(e: KeyboardEvent) {
|
||||
if (!ctx) return
|
||||
const move = (delta: number) => {
|
||||
e.preventDefault()
|
||||
engaged = true
|
||||
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
||||
}
|
||||
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
||||
@@ -140,19 +134,12 @@ function onKeydown(e: KeyboardEvent) {
|
||||
case "ArrowUp":
|
||||
move(-1)
|
||||
break
|
||||
case "Tab":
|
||||
e.preventDefault()
|
||||
accept(selected.value)
|
||||
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) {
|
||||
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()
|
||||
accept(selected.value)
|
||||
} else {
|
||||
completion.value = null
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
|
||||
@@ -157,3 +157,25 @@ describe("completion replace range", () => {
|
||||
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]]"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +103,7 @@ export function getCompletions(
|
||||
if (key) {
|
||||
const [, indent, brackets, word] = key
|
||||
if (brackets) {
|
||||
return result(lineStart + indent.length, caret, filter(HEADERS, word))
|
||||
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
|
||||
}
|
||||
const 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
|
||||
}
|
||||
|
||||
/** 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)?
|
||||
* Gates header suggestions to the "two new lines" rule — see the key branch. */
|
||||
function blankLineAbove(source: string, lineStart: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user