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.
|
// 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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]]"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user