Improve how we display keybindings

Use shorter label version with ⇧, ⌘, ⌥, etc when displaying key bindings in the UI
Use dynamic key bindings in tooltips (instead of hardcoded strings), so that the new key binding is displayed if a default has been overridden.
This commit is contained in:
Jonatan Heyman 2025-08-21 19:16:31 +02:00
parent eea8aa3924
commit 28b0b006e5
7 changed files with 155 additions and 32 deletions

View File

@ -2,7 +2,13 @@
Here are the most notable changes in each release. For a more detailed list of changes, see the [Github Releases page](https://github.com/heyman/heynote/releases).
## 2.5.0 (not yet released)
## Next version (not yet released)
- When displaying key bindings show shorter version with ⇧, ⌘, ⌥, etc instead of Shift, Cmd, Alt, etc
- Fixed so that tooltips displays new key binding if the default have been overridden
## 2.5.0
### Tabs

View File

@ -2,6 +2,7 @@
import { mapState } from 'pinia'
import UpdateStatusItem from './UpdateStatusItem.vue'
import { LANGUAGES } from '../editor/languages.js'
import { getKeyBindingForCommand } from '../editor/keymap.js'
import { useHeynoteStore } from "../stores/heynote-store"
import { useSettingsStore } from "../stores/settings-store"
@ -40,6 +41,7 @@
...mapState(useSettingsStore, [
"spellcheckEnabled",
"alwaysOnTop",
"settings",
]),
languageName() {
@ -63,14 +65,6 @@
return `Format Block Content (Alt + Shift + F)`
},
changeNoteTitle() {
return `Change Note (${this.cmdKey} + P)`
},
changeLanguageTitle() {
return `Change language for current block (${this.cmdKey} + L)`
},
updatesEnabled() {
return !!window.heynote.autoUpdate
},
@ -81,6 +75,11 @@
event.preventDefault()
window.heynote.mainProcess.invoke('showSpellcheckingContextMenu')
},
getTooltip(text, command) {
const binding = getKeyBindingForCommand(command, this.settings.keymap, this.settings.keyBindings, this.settings.emacsMetaKey)
return !binding ? text : `${text} (${binding})`
}
},
}
</script>
@ -98,14 +97,14 @@
<div
@click.stop="$emit('openBufferSelector')"
class="status-block note clickable"
:title="changeNoteTitle"
:title="getTooltip('Change Note', 'openBufferSelector')"
>
{{ currentBufferName }}
</div>
<div
@click.stop="$emit('openLanguageSelector')"
class="status-block lang clickable"
:title="changeLanguageTitle"
:title="getTooltip('Change language for current block', 'openLanguageSelector')"
>
{{ languageName }}
<span v-if="currentLanguageAuto" class="auto">(auto)</span>
@ -114,7 +113,7 @@
v-if="supportsFormat"
@click.stop="$emit('formatCurrentBlock')"
class="status-block format clickable"
:title="formatBlockTitle"
:title="getTooltip('Format Block Content', 'formatBlockContent')"
>
<span class="icon icon-format"></span>
</div>
@ -123,7 +122,7 @@
@mousedown.prevent
@contextmenu="onSpellcheckingContextMenu"
:class="'status-block spellcheck clickable' + (this.spellcheckEnabled ? ' spellcheck-enabled' : '')"
title="Spellchecking"
:title="getTooltip('Spellchecking', 'toggleSpellcheck')"
>
<span class="icon icon-format"></span>
</div>
@ -133,7 +132,7 @@
@click.stop="$emit('toggleAlwaysOnTop')"
@mousedown.prevent
class="status-block pin clickable"
title="Pin"
:title="getTooltip('Pin', 'toggleAlwaysOnTop')"
>
<span class="icon icon-format" :class="{'pinned': alwaysOnTop}"></span>
</div>

View File

@ -1,5 +1,10 @@
<script>
import { mapState } from 'pinia'
import { HEYNOTE_COMMANDS } from '@/src/editor/commands'
import { getKeyBindingLabel } from '@/src/editor/keymap'
import { useSettingsStore } from '@/src/stores/settings-store'
export default {
props: [
@ -10,11 +15,10 @@
],
computed: {
...mapState(useSettingsStore, ["settings"]),
formattedKeys() {
return this.keys.replaceAll(
"Mod",
window.heynote.platform.isMac ? "⌘" : "Ctrl",
)
return getKeyBindingLabel(this.keys, this.settings.emacsMetaKey, "&nbsp;&nbsp;&nbsp;")
},
commandLabel() {
@ -38,9 +42,7 @@
{{ source }}
</td>
<td class="key">
<template v-if="keys">
{{ formattedKeys }}
</template>
<span v-if="keys" v-html="formattedKeys" />
</td>
<td class="command">
<span class="command-name">{{ commandLabel }}</span>

View File

@ -1,4 +1,9 @@
<script>
import { mapState } from 'pinia'
import { getKeyBindingLabel } from '@/src/editor/keymap'
import { useSettingsStore } from '@/src/stores/settings-store'
import { keyName, base } from "w3c-keyname"
export default {
@ -13,9 +18,15 @@
},
computed: {
...mapState(useSettingsStore, ["settings"]),
key() {
return this.keys.join(" ")
},
keyLabel() {
return getKeyBindingLabel(this.key, this.settings.emacsMetaKey, " ")
},
},
watch: {
@ -80,7 +91,7 @@
<template>
<input
type="text"
:value="key"
:value="keyLabel"
@keydown.prevent="onKeyDown"
class="keys"
readonly

View File

@ -84,9 +84,11 @@ const switchToLastTab = (editor) => () => {
}
const nextTab = (editor) => () => {
useHeynoteStore().nextTab()
return true
}
const previousTab = (editor) => () => {
useHeynoteStore().previousTab()
return true
}
export function toggleAlwaysOnTop(editor) {
@ -151,6 +153,7 @@ const HEYNOTE_COMMANDS = {
"switchToTab" + (i+1),
cmdLessContext(() => {
useHeynoteStore().switchToTabIndex(i)
return true
}, "Buffer", `Switch to tab ${i+1}`),
])),

View File

@ -1,8 +1,12 @@
import { keymap } from "@codemirror/view"
import { Prec } from "@codemirror/state"
import { keyName } from "w3c-keyname"
import { HEYNOTE_COMMANDS } from "./commands.js"
const cmd = (key, command, scope) => ({key, command, scope})
const cmdShift = (key, command, shiftCommand) => {
return [
@ -203,19 +207,108 @@ function keymapFromSpec(specs, editor) {
}
export function heynoteKeymap(editor, keymap, userKeymap) {
function getCombinedKeymapSpec(keymapName, userKeymap) {
return [
keymapFromSpec([
...userKeymap,
...keymap,
], editor),
...userKeymap,
...(keymapName === "emacs" ? [...EMACS_KEYMAP, ...DEFAULT_KEYMAP] : [...DEFAULT_NOT_EMACS_KEYMAP, ...DEFAULT_KEYMAP]),
]
}
export function getKeymapExtensions(editor, keymap, keyBindings) {
return heynoteKeymap(
editor,
keymap === "emacs" ? [...EMACS_KEYMAP, ...DEFAULT_KEYMAP] : [...DEFAULT_NOT_EMACS_KEYMAP, ...DEFAULT_KEYMAP],
keyBindings || [],
)
return [
keymapFromSpec(getCombinedKeymapSpec(keymap, keyBindings), editor)
]
}
/**
* @returns Human readable version of a key binding
*/
export function getKeyBindingLabel(binding, emacsMetaKey, separator=" ") {
emacsMetaKey = emacsMetaKey === "meta" ? "Meta" : (emacsMetaKey === "alt" ? "Alt" : emacsMetaKey)
const parts = binding.split(" ")
return parts.map((part) => {
return part.split("-").map((key) => {
switch(key) {
case "Mod":
return window.heynote.platform.isMac ? "⌘" : "Ctrl"
case "Alt":
return window.heynote.platform.isMac ? "⌥" : "Alt"
case "EmacsMeta":
return emacsMetaKey === "Meta" ? "Meta" : (emacsMetaKey === "Alt" ? "Alt" : emacsMetaKey)
case "Meta":
return window.heynote.platform.isMac ? "⌘" : "Meta"
case "Shift":
return "⇧"
case "Control":
return "Ctrl"
}
if (key.match(/^[a-z]$/)) {
return key.toUpperCase()
}
return key
}).join(parts.length === 1 ? " + " : "+")
}).join(separator)
}
function canonicalizeKey(keyString) {
const strokes = keyString.trim().split(/\s+/)
return strokes.map(stroke => _canonicalizeSingleStroke(stroke)).join(' ')
}
function _canonicalizeSingleStroke(strokeString) {
const parts = strokeString.split('-')
const key = parts.pop()
const modifiers = parts.map(mod => mod.toLowerCase())
const normalizedModifiers = modifiers.map(mod => {
switch (mod) {
case 'mod': return isMac ? 'meta' : 'ctrl'
case 'control': case 'ctrl': return 'ctrl'
case 'shift': return 'shift'
case 'alt': case 'option': return 'alt'
case 'meta': case 'cmd': case 'command': return 'meta'
default: return mod
}
})
const order = ['ctrl', 'alt', 'shift', 'meta']
const sortedModifiers = normalizedModifiers.sort((a, b) => {
return order.indexOf(a) - order.indexOf(b)
})
const uniqueModifiers = [...new Set(sortedModifiers)]
return uniqueModifiers.length > 0
? uniqueModifiers.join('-') + '-' + key.toLowerCase()
: key.toLowerCase()
}
/**
* Returns the first bound key for a command (in label format, i.e. Mod replaced with etc.)
*/
export function getKeyBindingForCommand(command, keymapName, userKeymap, emacsMetaKey) {
const capturingCommands = new Set([
"nothing",
"toggleAlwaysOnTop",
"openLanguageSelector", "openBufferSelector", "openCreateNewBuffer", "openMoveToBuffer", "openCommandPalette",
"closeCurrentTab", "reopenLastClosedTab", "nextTab", "previousTab",
"switchToTab1", "switchToTab2", "switchToTab3", "switchToTab4", "switchToTab5", "switchToTab6", "switchToTab7", "switchToTab8", "switchToTab9", "switchToLastTab"
])
const capturedKeys = new Set()
for (const binding of getCombinedKeymapSpec(keymapName, userKeymap)) {
const key = canonicalizeKey(binding.key)
if (binding.command === command && !capturedKeys.has(key)) {
return getKeyBindingLabel(binding.key, emacsMetaKey)
}
if (capturingCommands.has(binding.command)) {
capturedKeys.add(key)
}
}
}

View File

@ -29,6 +29,11 @@ test("add custom key binding", async ({page}) => {
await page.locator("css=.overlay .settings .dialog .bottom-bar .close").click()
await page.locator("body").press("Control+Shift+H")
await expect(page.locator("css=.language-selector .items > li.selected")).toBeVisible()
await page.locator("body").press("Escape")
await expect(page.locator("css=.status .status-block.lang")).toHaveAttribute(
"title",
"Change language for current block (Ctrl + ⇧ + H)"
)
})
test("delete custom key binding", async ({page}) => {
@ -70,4 +75,8 @@ test("disable default key binding", async ({page}) => {
await heynotePage.setSettings(settings)
await page.locator("body").press(langKey)
await expect(page.locator("css=.language-selector .items > li.selected")).toHaveCount(0)
await expect(page.locator("css=.status .status-block.lang")).toHaveAttribute(
"title",
"Change language for current block"
)
})