diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9ad1b..9eb58cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — the keyboard shortcut table is public (2026-09-20) + +A host building its own toolbar or menu could not read the gestures the editor acts on, so it had to write +"Ctrl+B" again somewhere the editor cannot see — and the two drift the day a binding changes. + +- `RichEditorShortcuts.All` lists every shortcut; `Display(id)` gives the hint text ("Ctrl+B"), `Gesture(id)` + the primary `KeyGesture` for a menu item. +- The types are now public as `RichEditorShortcutId` (enum) and `RichEditorShortcut` (record), renamed from + the internal `ShortcutId`/`ShortcutSpec` so they carry the same prefix as the rest of the public surface. + New commands are appended, so the enum's numeric values keep their meaning. +- `All` hands out a read-only view: the editor's key handler matches against the same table. +- Matching a key event to a command stays internal — a host can match against `All` itself, and how the + editor routes keys stays free to change. + ### Added — row and column commands a host can call (2026-09-20) The row/column edits were reachable only from the right-click menu, so a host that builds its own toolbar — diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs index b4612f8..e44731c 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs @@ -218,7 +218,7 @@ private void ShowContextMenu(Point point) if (!hasSelection && (NestedTableBorderAtPoint(point) ?? TableLeftOrTopBorderAtPoint(point) ?? ContextMenuTargetTable(point)) is { } roTable) hasSelection = SelectWholeTableForCopy(roTable); - var roItems = new List { Mi(Loc("Copy"), CopySelectionToClipboard, hasSelection, RichEditorIcon.Copy, RichEditorShortcuts.Gesture(ShortcutId.Copy)), Mi(Loc("SelectAll"), SelectAll, icon: RichEditorIcon.SelectAll, gesture: RichEditorShortcuts.Gesture(ShortcutId.SelectAll)) }; + var roItems = new List { Mi(Loc("Copy"), CopySelectionToClipboard, hasSelection, RichEditorIcon.Copy, RichEditorShortcuts.Gesture(RichEditorShortcutId.Copy)), Mi(Loc("SelectAll"), SelectAll, icon: RichEditorIcon.SelectAll, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.SelectAll)) }; var roMenu = NewContextMenu(); roMenu.ItemsSource = roItems; _openContextMenu = roMenu; @@ -377,9 +377,9 @@ private void AddClipboardItems(List items, bool hasSelection, bool canC CopySelectionToClipboard(); if (!RemoveTableHeldWhole()) DeleteSelection(); // the menu's cut: a table held whole goes whole InvalidateVisual(); - }, hasSelection || canCopy, RichEditorIcon.Cut, RichEditorShortcuts.Gesture(ShortcutId.Cut))); - items.Add(Mi(Loc("Copy"), CopySelectionToClipboard, hasSelection || canCopy, RichEditorIcon.Copy, RichEditorShortcuts.Gesture(ShortcutId.Copy))); - items.Add(Mi(Loc("Paste"), () => { _ = PasteFromClipboardAsync(); }, icon: RichEditorIcon.Paste, gesture: RichEditorShortcuts.Gesture(ShortcutId.Paste))); + }, hasSelection || canCopy, RichEditorIcon.Cut, RichEditorShortcuts.Gesture(RichEditorShortcutId.Cut))); + items.Add(Mi(Loc("Copy"), CopySelectionToClipboard, hasSelection || canCopy, RichEditorIcon.Copy, RichEditorShortcuts.Gesture(RichEditorShortcutId.Copy))); + items.Add(Mi(Loc("Paste"), () => { _ = PasteFromClipboardAsync(); }, icon: RichEditorIcon.Paste, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.Paste))); items.Add(Mi(Loc("Delete"), () => { if (Document != null) PushUndo(); @@ -394,13 +394,13 @@ private void AddClipboardItems(List items, bool hasSelection, bool canC // text, as Ctrl+B does — greying them out without a selection hid a command that works (converged with the // WinUI port, 2026-09-13). The same for ClearFormatting. private MenuItem CharacterFormatSub(CaretFormat fmt) => Sub(Loc("CharacterFormat"), - CheckItem(Loc("Bold"), fmt.Bold, ToggleBold, true, RichEditorShortcuts.Gesture(ShortcutId.Bold)), - CheckItem(Loc("Italic"), fmt.Italic, ToggleItalic, true, RichEditorShortcuts.Gesture(ShortcutId.Italic)), - CheckItem(Loc("Underline"), fmt.Underline, ToggleUnderline, true, RichEditorShortcuts.Gesture(ShortcutId.Underline)), - CheckItem(Loc("Strikethrough"), fmt.Strike, ToggleStrikethrough, true, RichEditorShortcuts.Gesture(ShortcutId.Strikethrough)), + CheckItem(Loc("Bold"), fmt.Bold, ToggleBold, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Bold)), + CheckItem(Loc("Italic"), fmt.Italic, ToggleItalic, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Italic)), + CheckItem(Loc("Underline"), fmt.Underline, ToggleUnderline, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Underline)), + CheckItem(Loc("Strikethrough"), fmt.Strike, ToggleStrikethrough, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Strikethrough)), new Separator(), - Mi(Loc("FontSizeIncrease"), IncreaseFontSize, true, RichEditorIcon.FontSizeIncrease, RichEditorShortcuts.Gesture(ShortcutId.FontLarger)), - Mi(Loc("FontSizeDecrease"), DecreaseFontSize, true, RichEditorIcon.FontSizeDecrease, RichEditorShortcuts.Gesture(ShortcutId.FontSmaller)), + Mi(Loc("FontSizeIncrease"), IncreaseFontSize, true, RichEditorIcon.FontSizeIncrease, RichEditorShortcuts.Gesture(RichEditorShortcutId.FontLarger)), + Mi(Loc("FontSizeDecrease"), DecreaseFontSize, true, RichEditorIcon.FontSizeDecrease, RichEditorShortcuts.Gesture(RichEditorShortcutId.FontSmaller)), new Separator(), Mi(Loc("ClearFormatting"), ClearFormatting, true, RichEditorIcon.ClearFormatting)); @@ -410,13 +410,13 @@ private MenuItem BuildParagraphFormatSub(CaretFormat fmt) { var children = new List { - RadioItem(Loc("AlignLeft"), "ctxAlign", fmt.Align == TextAlignment.Left, () => SetTextAlignment(TextAlignment.Left), RichEditorShortcuts.Gesture(ShortcutId.AlignLeft)), - RadioItem(Loc("AlignCenter"), "ctxAlign", fmt.Align == TextAlignment.Center, () => SetTextAlignment(TextAlignment.Center), RichEditorShortcuts.Gesture(ShortcutId.AlignCenter)), - RadioItem(Loc("AlignRight"), "ctxAlign", fmt.Align == TextAlignment.Right, () => SetTextAlignment(TextAlignment.Right), RichEditorShortcuts.Gesture(ShortcutId.AlignRight)), - RadioItem(Loc("AlignJustify"), "ctxAlign", fmt.Align == TextAlignment.Justify, () => SetTextAlignment(TextAlignment.Justify), RichEditorShortcuts.Gesture(ShortcutId.AlignJustify)), + RadioItem(Loc("AlignLeft"), "ctxAlign", fmt.Align == TextAlignment.Left, () => SetTextAlignment(TextAlignment.Left), RichEditorShortcuts.Gesture(RichEditorShortcutId.AlignLeft)), + RadioItem(Loc("AlignCenter"), "ctxAlign", fmt.Align == TextAlignment.Center, () => SetTextAlignment(TextAlignment.Center), RichEditorShortcuts.Gesture(RichEditorShortcutId.AlignCenter)), + RadioItem(Loc("AlignRight"), "ctxAlign", fmt.Align == TextAlignment.Right, () => SetTextAlignment(TextAlignment.Right), RichEditorShortcuts.Gesture(RichEditorShortcutId.AlignRight)), + RadioItem(Loc("AlignJustify"), "ctxAlign", fmt.Align == TextAlignment.Justify, () => SetTextAlignment(TextAlignment.Justify), RichEditorShortcuts.Gesture(RichEditorShortcutId.AlignJustify)), new Separator(), - Mi(Loc("IndentIncrease"), () => Indent(20), true, RichEditorIcon.IndentIncrease, RichEditorShortcuts.Gesture(ShortcutId.IndentIncrease)), - Mi(Loc("IndentDecrease"), () => Indent(-20), true, RichEditorIcon.IndentDecrease, RichEditorShortcuts.Gesture(ShortcutId.IndentDecrease)), + Mi(Loc("IndentIncrease"), () => Indent(20), true, RichEditorIcon.IndentIncrease, RichEditorShortcuts.Gesture(RichEditorShortcutId.IndentIncrease)), + Mi(Loc("IndentDecrease"), () => Indent(-20), true, RichEditorIcon.IndentDecrease, RichEditorShortcuts.Gesture(RichEditorShortcutId.IndentDecrease)), }; // Margin (top-level paragraphs only — cell paragraphs lay out inside the cell). if (_caretPosition.Paragraph is { } mp && Document != null && Document.Blocks.IndexOf(mp) >= 0) @@ -429,13 +429,13 @@ private MenuItem BuildParagraphFormatSub(CaretFormat fmt) // ── 목록 (list) — promoted to top level; bullet/number/quote states are checked to reflect the caret. private MenuItem ListSub(CaretFormat fmt) => Sub(Loc("List"), - CheckItem(Loc("BulletList"), fmt.List == ListKind.Bullet, ToggleBullet, gesture: RichEditorShortcuts.Gesture(ShortcutId.BulletList)), + CheckItem(Loc("BulletList"), fmt.List == ListKind.Bullet, ToggleBullet, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.BulletList)), Sub(Loc("BulletStyle"), Mi("•", () => SetListStyle(ListMarkerStyle.Disc)), Mi("◦", () => SetListStyle(ListMarkerStyle.Circle)), Mi("▪", () => SetListStyle(ListMarkerStyle.Square)), Mi("–", () => SetListStyle(ListMarkerStyle.Dash))), - CheckItem(Loc("NumberedList"), fmt.List == ListKind.Ordered, ToggleNumbering, gesture: RichEditorShortcuts.Gesture(ShortcutId.NumberedList)), + CheckItem(Loc("NumberedList"), fmt.List == ListKind.Ordered, ToggleNumbering, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.NumberedList)), Sub(Loc("NumberStyle"), Mi("1.", () => SetListStyle(ListMarkerStyle.Decimal)), Mi("1)", () => SetListStyle(ListMarkerStyle.DecimalParen)), @@ -457,7 +457,7 @@ private MenuItem HeadingSub(CaretFormat fmt) => Sub(Loc("Heading"), private MenuItem HeadingRadio(string header, int level, int current) { // Heading1..6 are consecutive enum values; level 0 = body text. - var sc = level == 0 ? ShortcutId.BodyText : (ShortcutId)((int)ShortcutId.Heading1 + level - 1); + var sc = level == 0 ? RichEditorShortcutId.BodyText : (RichEditorShortcutId)((int)RichEditorShortcutId.Heading1 + level - 1); return RadioItem(header, "ctxHeading", current == level, () => SetHeading(level), RichEditorShortcuts.Gesture(sc)); } @@ -493,9 +493,9 @@ private void BuildTextMenu(List items, bool hasSelection, Run? link, Ta // Slim (default): just the quick character toggles, checked to reflect the caret. The rich // formatting groups live on the toolbar; opt in with ShowFormattingMenu for a toolbar-less host. // Always enabled — see CharacterFormatSub. - items.Add(CheckItem(Loc("Bold"), fmt.Bold, ToggleBold, true, RichEditorShortcuts.Gesture(ShortcutId.Bold))); - items.Add(CheckItem(Loc("Italic"), fmt.Italic, ToggleItalic, true, RichEditorShortcuts.Gesture(ShortcutId.Italic))); - items.Add(CheckItem(Loc("Underline"), fmt.Underline, ToggleUnderline, true, RichEditorShortcuts.Gesture(ShortcutId.Underline))); + items.Add(CheckItem(Loc("Bold"), fmt.Bold, ToggleBold, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Bold))); + items.Add(CheckItem(Loc("Italic"), fmt.Italic, ToggleItalic, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Italic))); + items.Add(CheckItem(Loc("Underline"), fmt.Underline, ToggleUnderline, true, RichEditorShortcuts.Gesture(RichEditorShortcutId.Underline))); } items.Add(new Separator()); @@ -509,12 +509,12 @@ private void BuildTextMenu(List items, bool hasSelection, Run? link, Ta { // Enabled without a selection too: the link goes on the caret's word (SetHyperlink). items.Add(Mi(Loc("InsertLink"), () => { _ = EditHyperlinkAsync(null, null); }, true, RichEditorIcon.InsertLink, - RichEditorShortcuts.Gesture(ShortcutId.InsertLink))); + RichEditorShortcuts.Gesture(RichEditorShortcutId.InsertLink))); } items.Add(new Separator()); - items.Add(Mi(Loc("SelectAll"), SelectAll, icon: RichEditorIcon.SelectAll, gesture: RichEditorShortcuts.Gesture(ShortcutId.SelectAll))); - items.Add(Mi(Loc("Undo"), DoUndo, CanUndo, RichEditorIcon.Undo, RichEditorShortcuts.Gesture(ShortcutId.Undo))); - items.Add(Mi(Loc("Redo"), DoRedo, CanRedo, RichEditorIcon.Redo, RichEditorShortcuts.Gesture(ShortcutId.Redo))); + items.Add(Mi(Loc("SelectAll"), SelectAll, icon: RichEditorIcon.SelectAll, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.SelectAll))); + items.Add(Mi(Loc("Undo"), DoUndo, CanUndo, RichEditorIcon.Undo, RichEditorShortcuts.Gesture(RichEditorShortcutId.Undo))); + items.Add(Mi(Loc("Redo"), DoRedo, CanRedo, RichEditorIcon.Redo, RichEditorShortcuts.Gesture(RichEditorShortcutId.Redo))); // Block-insert items appear only when the corresponding feature flag is enabled (N3.5). Inside a // cell, image/divider/table all insert into the cell (P4-2a/P4-2b nested tables). if (AllowTables || AllowImages) items.Add(new Separator()); @@ -535,7 +535,7 @@ private void BuildTextMenu(List items, bool hasSelection, Run? link, Ta private void BuildImageMenu(List items, ImageBlock img) { // Edit - items.Add(Mi(Loc("Copy"), () => { _ = CopyImageToClipboardAsync(img.RawBytes, img.RawBytes == null ? img.Image : null, inline: false, img.Width, img.Height); }, HasPicture(img), RichEditorIcon.Copy, RichEditorShortcuts.Gesture(ShortcutId.Copy))); + items.Add(Mi(Loc("Copy"), () => { _ = CopyImageToClipboardAsync(img.RawBytes, img.RawBytes == null ? img.Image : null, inline: false, img.Width, img.Height); }, HasPicture(img), RichEditorIcon.Copy, RichEditorShortcuts.Gesture(RichEditorShortcutId.Copy))); // A viewer gets the copy and nothing else. Everything below MUTATES the image — resize, promote // to a character, margins, replace, save, delete — so a read-only editor must not offer any of // it. Copy is the whole point of reaching this menu in a viewer: it copies the IMAGE, which the @@ -585,7 +585,7 @@ private void BuildLinkMenu(List items, bool hasSelection, Run link) private void BuildInlineImageMenu(List items, Paragraph p, InlineImage img) { // Edit - items.Add(Mi(Loc("Copy"), () => { _ = CopyImageToClipboardAsync(img.RawBytes, img.RawBytes == null ? img.Image : null, inline: true, img.Width, img.Height); }, HasPicture(img), RichEditorIcon.Copy, RichEditorShortcuts.Gesture(ShortcutId.Copy))); + items.Add(Mi(Loc("Copy"), () => { _ = CopyImageToClipboardAsync(img.RawBytes, img.RawBytes == null ? img.Image : null, inline: true, img.Width, img.Height); }, HasPicture(img), RichEditorIcon.Copy, RichEditorShortcuts.Gesture(RichEditorShortcutId.Copy))); if (IsReadOnly) return; // see BuildImageMenu: everything below mutates the image // 개체 모양 (object shape): size preset + 글자처럼 취급. items.Add(new Separator()); @@ -654,7 +654,7 @@ private MenuItem SelectCellItem(Paragraph? cell) if (loc is not { } lc) return; var (ar, ac) = lc.tb.AnchorOf(lc.r, lc.c); SelectCellAsBlock(lc.tb, lc.tb.Cells[ar][ac]); - }, loc != null, gesture: RichEditorShortcuts.Gesture(ShortcutId.SelectCell)); + }, loc != null, gesture: RichEditorShortcuts.Gesture(RichEditorShortcutId.SelectCell)); } // 셀 배경: the toolbar's palette as a swatch grid inside the submenu (as the table-size picker is), plus "none". diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs index c4d4347..0dfde2d 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs @@ -865,42 +865,42 @@ protected override void OnTextInput(TextInputEventArgs e) // Runs a matched command shortcut from the central table. Copy/Cut/Paste/SelectAll/Undo are // intercepted earlier in OnKeyDown (they inspect object selection / plain-paste), so they don't reach // here; everything routed through this method is editing and is gated by IsReadOnly. - private void RunShortcut(ShortcutId id) + private void RunShortcut(RichEditorShortcutId id) { // Find reads, so it works in a viewer too; Find + Replace edits. - if (id == ShortcutId.Find) { if (AllowFindReplace) RaiseFindRequested(false); return; } + if (id == RichEditorShortcutId.Find) { if (AllowFindReplace) RaiseFindRequested(false); return; } if (IsReadOnly) return; switch (id) { - case ShortcutId.FindReplace: if (AllowFindReplace) RaiseFindRequested(true); break; - case ShortcutId.Redo: DoRedo(); break; - case ShortcutId.Bold: ToggleBold(); break; - case ShortcutId.Italic: ToggleItalic(); break; - case ShortcutId.Underline: ToggleUnderline(); break; - case ShortcutId.Strikethrough: ToggleStrikethrough(); break; - case ShortcutId.FontLarger: IncreaseFontSize(); break; - case ShortcutId.FontSmaller: DecreaseFontSize(); break; - case ShortcutId.IndentIncrease: Indent(20); break; - case ShortcutId.IndentDecrease: Indent(-20); break; - case ShortcutId.AlignLeft: SetTextAlignment(Avalonia.Media.TextAlignment.Left); break; - case ShortcutId.AlignCenter: SetTextAlignment(Avalonia.Media.TextAlignment.Center); break; - case ShortcutId.AlignRight: SetTextAlignment(Avalonia.Media.TextAlignment.Right); break; - case ShortcutId.AlignJustify: SetTextAlignment(Avalonia.Media.TextAlignment.Justify); break; - case ShortcutId.Heading1: SetHeading(1); break; - case ShortcutId.Heading2: SetHeading(2); break; - case ShortcutId.Heading3: SetHeading(3); break; - case ShortcutId.Heading4: SetHeading(4); break; - case ShortcutId.Heading5: SetHeading(5); break; - case ShortcutId.Heading6: SetHeading(6); break; - case ShortcutId.BodyText: SetHeading(0); break; - case ShortcutId.BulletList: ToggleBullet(); break; - case ShortcutId.NumberedList: ToggleNumbering(); break; - case ShortcutId.LineSpacingSingle: SetLineSpacing(1.0); break; - case ShortcutId.LineSpacingOneHalf: SetLineSpacing(1.5); break; - case ShortcutId.LineSpacingDouble: SetLineSpacing(2.0); break; + case RichEditorShortcutId.FindReplace: if (AllowFindReplace) RaiseFindRequested(true); break; + case RichEditorShortcutId.Redo: DoRedo(); break; + case RichEditorShortcutId.Bold: ToggleBold(); break; + case RichEditorShortcutId.Italic: ToggleItalic(); break; + case RichEditorShortcutId.Underline: ToggleUnderline(); break; + case RichEditorShortcutId.Strikethrough: ToggleStrikethrough(); break; + case RichEditorShortcutId.FontLarger: IncreaseFontSize(); break; + case RichEditorShortcutId.FontSmaller: DecreaseFontSize(); break; + case RichEditorShortcutId.IndentIncrease: Indent(20); break; + case RichEditorShortcutId.IndentDecrease: Indent(-20); break; + case RichEditorShortcutId.AlignLeft: SetTextAlignment(Avalonia.Media.TextAlignment.Left); break; + case RichEditorShortcutId.AlignCenter: SetTextAlignment(Avalonia.Media.TextAlignment.Center); break; + case RichEditorShortcutId.AlignRight: SetTextAlignment(Avalonia.Media.TextAlignment.Right); break; + case RichEditorShortcutId.AlignJustify: SetTextAlignment(Avalonia.Media.TextAlignment.Justify); break; + case RichEditorShortcutId.Heading1: SetHeading(1); break; + case RichEditorShortcutId.Heading2: SetHeading(2); break; + case RichEditorShortcutId.Heading3: SetHeading(3); break; + case RichEditorShortcutId.Heading4: SetHeading(4); break; + case RichEditorShortcutId.Heading5: SetHeading(5); break; + case RichEditorShortcutId.Heading6: SetHeading(6); break; + case RichEditorShortcutId.BodyText: SetHeading(0); break; + case RichEditorShortcutId.BulletList: ToggleBullet(); break; + case RichEditorShortcutId.NumberedList: ToggleNumbering(); break; + case RichEditorShortcutId.LineSpacingSingle: SetLineSpacing(1.0); break; + case RichEditorShortcutId.LineSpacingOneHalf: SetLineSpacing(1.5); break; + case RichEditorShortcutId.LineSpacingDouble: SetLineSpacing(2.0); break; // The dialog opens on the link the caret is in (its address prefilled), else on "https://"; OK applies to // the selection or the caret's word, as the menu's Insert Link does. - case ShortcutId.InsertLink: _ = EditHyperlinkAsync(CaretLinkUri(), null); break; + case RichEditorShortcutId.InsertLink: _ = EditHyperlinkAsync(CaretLinkUri(), null); break; } } diff --git a/src/AvaloniaRichEditor/Controls/RichEditorShortcuts.cs b/src/AvaloniaRichEditor/Controls/RichEditorShortcuts.cs index 4ec9263..117f9f9 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorShortcuts.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorShortcuts.cs @@ -3,88 +3,162 @@ namespace AvaloniaRichEditor.Controls; -/// Identifies a command that has a keyboard shortcut. Used as the key that ties the shortcut -/// table, the editor's key handler, the context-menu hints, and the toolbar tooltips together. -internal enum ShortcutId +/// Identifies a command that has a keyboard shortcut. The key that ties the shortcut table, the +/// editor's key handler, the context-menu hints and the toolbar tooltips together. +/// Public since 1.3.0, so a host building its own toolbar or menu can label a command with the +/// gesture the editor actually acts on. New commands are appended, so the numeric values keep their +/// meaning (as does). +public enum RichEditorShortcutId { - Cut, Copy, Paste, PastePlain, SelectAll, Undo, Redo, - Bold, Italic, Underline, Strikethrough, FontLarger, FontSmaller, - IndentIncrease, IndentDecrease, - AlignLeft, AlignCenter, AlignRight, AlignJustify, - Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, BodyText, - BulletList, NumberedList, LineSpacingSingle, LineSpacingOneHalf, LineSpacingDouble, + /// Cut the selection to the clipboard. + Cut, + /// Copy the selection to the clipboard. + Copy, + /// Paste, keeping whatever formatting the clipboard carries. + Paste, + /// Paste the clipboard's plain text only. + PastePlain, + /// Select the cell's contents, then the table, then the document (staged). + SelectAll, + /// Undo the last edit. + Undo, + /// Redo the last undone edit. + Redo, + /// Bold toggle. + Bold, + /// Italic toggle. + Italic, + /// Underline toggle. + Underline, + /// Strikethrough toggle. + Strikethrough, + /// Next size up the standard ladder. + FontLarger, + /// Next size down the standard ladder. + FontSmaller, + /// Indent the paragraph one step. + IndentIncrease, + /// Outdent the paragraph one step. + IndentDecrease, + /// Align the paragraph left. + AlignLeft, + /// Centre the paragraph. + AlignCenter, + /// Align the paragraph right. + AlignRight, + /// Justify the paragraph. + AlignJustify, + /// Make the paragraph a level 1 heading. + Heading1, + /// Make the paragraph a level 2 heading. + Heading2, + /// Make the paragraph a level 3 heading. + Heading3, + /// Make the paragraph a level 4 heading. + Heading4, + /// Make the paragraph a level 5 heading. + Heading5, + /// Make the paragraph a level 6 heading. + Heading6, + /// Make the paragraph body text (heading level 0). + BodyText, + /// Bulleted list toggle. + BulletList, + /// Numbered list toggle. + NumberedList, + /// Line spacing 100% of the font size. + LineSpacingSingle, + /// Line spacing 150% of the font size. + LineSpacingOneHalf, + /// Line spacing 200% of the font size. + LineSpacingDouble, + /// Select the caret's cell as a block (HWP's cell block key). SelectCell, + /// Open the link dialog for the selection or the caret's word. InsertLink, - Find, FindReplace, + /// Find. + Find, + /// Find and replace. + FindReplace, } -internal readonly record struct ShortcutSpec(ShortcutId Id, bool Ctrl, bool Shift, bool Alt, Key Key, string Display); +/// One shortcut in the table: the command, the modifiers it needs (each must match exactly), the +/// key, and the hint text to show for it (e.g. "Ctrl+B"). +public readonly record struct RichEditorShortcut(RichEditorShortcutId Id, bool Ctrl, bool Shift, bool Alt, Key Key, string Display); /// The single source of truth for command keyboard shortcuts (Word-standard scheme). The editor's -/// OnKeyDown matches events against ; the context menu and toolbar read -/// for their hint text — so behavior and the shown shortcut never drift. -internal static class RichEditorShortcuts +/// OnKeyDown matches events against this table; the context menu and toolbar read +/// for their hint text — so behavior and the shown shortcut never drift. +/// A host that builds its own toolbar or menu reads it the same way, instead of writing "Ctrl+B" +/// again somewhere the editor cannot see. +public static class RichEditorShortcuts { - public static readonly ShortcutSpec[] All = + // Public callers get the list through All, which hands out no reference to this array: a host that + // could reorder or blank an entry would be editing what the editor's own key handler matches against. + private static readonly RichEditorShortcut[] Table = { - new(ShortcutId.Cut, true, false, false, Key.X, "Ctrl+X"), - new(ShortcutId.Copy, true, false, false, Key.C, "Ctrl+C"), - new(ShortcutId.Paste, true, false, false, Key.V, "Ctrl+V"), - new(ShortcutId.PastePlain, true, true, false, Key.V, "Ctrl+Shift+V"), - new(ShortcutId.SelectAll, true, false, false, Key.A, "Ctrl+A"), - new(ShortcutId.Undo, true, false, false, Key.Z, "Ctrl+Z"), - new(ShortcutId.Redo, true, false, false, Key.Y, "Ctrl+Y"), - new(ShortcutId.Redo, true, true, false, Key.Z, "Ctrl+Shift+Z"), // alias (display keeps Ctrl+Y) - new(ShortcutId.Bold, true, false, false, Key.B, "Ctrl+B"), - new(ShortcutId.Italic, true, false, false, Key.I, "Ctrl+I"), - new(ShortcutId.Underline, true, false, false, Key.U, "Ctrl+U"), - new(ShortcutId.Strikethrough, true, true, false, Key.X, "Ctrl+Shift+X"), - new(ShortcutId.FontLarger, true, true, false, Key.OemPeriod, "Ctrl+Shift+."), - new(ShortcutId.FontSmaller, true, true, false, Key.OemComma, "Ctrl+Shift+,"), - new(ShortcutId.IndentIncrease,true, false, false, Key.M, "Ctrl+M"), - new(ShortcutId.IndentDecrease,true, true, false, Key.M, "Ctrl+Shift+M"), - new(ShortcutId.AlignLeft, true, false, false, Key.L, "Ctrl+L"), - new(ShortcutId.AlignCenter, true, false, false, Key.E, "Ctrl+E"), - new(ShortcutId.AlignRight, true, false, false, Key.R, "Ctrl+R"), - new(ShortcutId.AlignJustify, true, false, false, Key.J, "Ctrl+J"), - new(ShortcutId.Heading1, true, false, true, Key.D1, "Ctrl+Alt+1"), - new(ShortcutId.Heading2, true, false, true, Key.D2, "Ctrl+Alt+2"), - new(ShortcutId.Heading3, true, false, true, Key.D3, "Ctrl+Alt+3"), - new(ShortcutId.Heading4, true, false, true, Key.D4, "Ctrl+Alt+4"), - new(ShortcutId.Heading5, true, false, true, Key.D5, "Ctrl+Alt+5"), - new(ShortcutId.Heading6, true, false, true, Key.D6, "Ctrl+Alt+6"), - new(ShortcutId.BodyText, true, true, false, Key.N, "Ctrl+Shift+N"), - new(ShortcutId.BulletList, true, true, false, Key.L, "Ctrl+Shift+L"), - new(ShortcutId.NumberedList, true, true, false, Key.D7, "Ctrl+Shift+7"), // Docs convention; Word has no standard binding (as the WinUI port) - new(ShortcutId.LineSpacingSingle, true, false, false, Key.D1, "Ctrl+1"), - new(ShortcutId.LineSpacingOneHalf, true, false, false, Key.D5, "Ctrl+5"), - new(ShortcutId.LineSpacingDouble, true, false, false, Key.D2, "Ctrl+2"), + new(RichEditorShortcutId.Cut, true, false, false, Key.X, "Ctrl+X"), + new(RichEditorShortcutId.Copy, true, false, false, Key.C, "Ctrl+C"), + new(RichEditorShortcutId.Paste, true, false, false, Key.V, "Ctrl+V"), + new(RichEditorShortcutId.PastePlain, true, true, false, Key.V, "Ctrl+Shift+V"), + new(RichEditorShortcutId.SelectAll, true, false, false, Key.A, "Ctrl+A"), + new(RichEditorShortcutId.Undo, true, false, false, Key.Z, "Ctrl+Z"), + new(RichEditorShortcutId.Redo, true, false, false, Key.Y, "Ctrl+Y"), + new(RichEditorShortcutId.Redo, true, true, false, Key.Z, "Ctrl+Shift+Z"), // alias (display keeps Ctrl+Y) + new(RichEditorShortcutId.Bold, true, false, false, Key.B, "Ctrl+B"), + new(RichEditorShortcutId.Italic, true, false, false, Key.I, "Ctrl+I"), + new(RichEditorShortcutId.Underline, true, false, false, Key.U, "Ctrl+U"), + new(RichEditorShortcutId.Strikethrough, true, true, false, Key.X, "Ctrl+Shift+X"), + new(RichEditorShortcutId.FontLarger, true, true, false, Key.OemPeriod, "Ctrl+Shift+."), + new(RichEditorShortcutId.FontSmaller, true, true, false, Key.OemComma, "Ctrl+Shift+,"), + new(RichEditorShortcutId.IndentIncrease,true, false, false, Key.M, "Ctrl+M"), + new(RichEditorShortcutId.IndentDecrease,true, true, false, Key.M, "Ctrl+Shift+M"), + new(RichEditorShortcutId.AlignLeft, true, false, false, Key.L, "Ctrl+L"), + new(RichEditorShortcutId.AlignCenter, true, false, false, Key.E, "Ctrl+E"), + new(RichEditorShortcutId.AlignRight, true, false, false, Key.R, "Ctrl+R"), + new(RichEditorShortcutId.AlignJustify, true, false, false, Key.J, "Ctrl+J"), + new(RichEditorShortcutId.Heading1, true, false, true, Key.D1, "Ctrl+Alt+1"), + new(RichEditorShortcutId.Heading2, true, false, true, Key.D2, "Ctrl+Alt+2"), + new(RichEditorShortcutId.Heading3, true, false, true, Key.D3, "Ctrl+Alt+3"), + new(RichEditorShortcutId.Heading4, true, false, true, Key.D4, "Ctrl+Alt+4"), + new(RichEditorShortcutId.Heading5, true, false, true, Key.D5, "Ctrl+Alt+5"), + new(RichEditorShortcutId.Heading6, true, false, true, Key.D6, "Ctrl+Alt+6"), + new(RichEditorShortcutId.BodyText, true, true, false, Key.N, "Ctrl+Shift+N"), + new(RichEditorShortcutId.BulletList, true, true, false, Key.L, "Ctrl+Shift+L"), + new(RichEditorShortcutId.NumberedList, true, true, false, Key.D7, "Ctrl+Shift+7"), // Docs convention; Word has no standard binding (as the WinUI port) + new(RichEditorShortcutId.LineSpacingSingle, true, false, false, Key.D1, "Ctrl+1"), + new(RichEditorShortcutId.LineSpacingOneHalf, true, false, false, Key.D5, "Ctrl+5"), + new(RichEditorShortcutId.LineSpacingDouble, true, false, false, Key.D2, "Ctrl+2"), // Word's Insert Hyperlink (from the WinUI port, where it has been bound since the hyperlink audit). - new(ShortcutId.InsertLink, true, false, false, Key.K, "Ctrl+K"), - new(ShortcutId.Find, true, false, false, Key.F, "Ctrl+F"), - new(ShortcutId.FindReplace, true, false, false, Key.H, "Ctrl+H"), + new(RichEditorShortcutId.InsertLink, true, false, false, Key.K, "Ctrl+K"), + new(RichEditorShortcutId.Find, true, false, false, Key.F, "Ctrl+F"), + new(RichEditorShortcutId.FindReplace, true, false, false, Key.H, "Ctrl+H"), // Not Ctrl-modified, so TryMatch (reached only with Ctrl) never runs it: OnKeyDown routes F5 through // TryCellBlockKey. Listed for the menu hint (HWP's cell block key; the WinUI port lists it the same). - new(ShortcutId.SelectCell, false, false, false, Key.F5, "F5"), + new(RichEditorShortcutId.SelectCell, false, false, false, Key.F5, "F5"), }; - private static readonly Dictionary DisplayMap = BuildDisplayMap(); + /// Every shortcut, in the order the key handler matches them. A command can appear more than + /// once (an alias, such as Ctrl+Shift+Z for redo); the first entry is its primary one. + public static IReadOnlyList All { get; } = System.Array.AsReadOnly(Table); - private static Dictionary BuildDisplayMap() + private static readonly Dictionary DisplayMap = BuildDisplayMap(); + + private static Dictionary BuildDisplayMap() { - var d = new Dictionary(); - foreach (var s in All) d.TryAdd(s.Id, s.Display); // keep the first (primary) display per id + var d = new Dictionary(); + foreach (var s in Table) d.TryAdd(s.Id, s.Display); // keep the first (primary) display per id return d; } /// The shortcut hint text for a command (e.g. "Ctrl+B"), or "" if none. - public static string Display(ShortcutId id) => DisplayMap.TryGetValue(id, out var s) ? s : ""; + public static string Display(RichEditorShortcutId id) => DisplayMap.TryGetValue(id, out var s) ? s : ""; /// The primary shortcut as an Avalonia (for a menu item's display-only /// InputGesture), or null if the command has no shortcut. - public static KeyGesture? Gesture(ShortcutId id) + public static KeyGesture? Gesture(RichEditorShortcutId id) { - foreach (var s in All) + foreach (var s in Table) if (s.Id == id) { var mods = KeyModifiers.None; @@ -97,9 +171,11 @@ private static Dictionary BuildDisplayMap() } /// Matches a key event to a command. Modifiers must match exactly. - public static bool TryMatch(bool ctrl, bool shift, bool alt, Key key, out ShortcutId id) + // Internal on purpose: a host that wants this can match against All itself, and the editor keeps the + // freedom to change how it routes keys (F5, for one, is in the table only as a menu hint). + internal static bool TryMatch(bool ctrl, bool shift, bool alt, Key key, out RichEditorShortcutId id) { - foreach (var s in All) + foreach (var s in Table) if (s.Ctrl == ctrl && s.Shift == shift && s.Alt == alt && s.Key == key) { id = s.Id; return true; } id = default; return false; diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs index 04256dd..ab06805 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs @@ -278,7 +278,7 @@ ComboBox Combo(string tip, double minWidth = 0) cb.DropDownClosed += (_, _) => Target?.Focus(); return cb; } - Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Display(ShortcutId.Find) + ")", + Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Display(RichEditorShortcutId.Find) + ")", () => Target?.RaiseFindRequested(false), RichEditorIcon.Find); Control Div() => new Border diff --git a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt index 5c2663d..e381710 100644 --- a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt +++ b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt @@ -1,4 +1,56 @@ #nullable enable +AvaloniaRichEditor.Controls.RichEditorShortcut +AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.get -> bool +AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.Ctrl.get -> bool +AvaloniaRichEditor.Controls.RichEditorShortcut.Ctrl.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.Display.get -> string! +AvaloniaRichEditor.Controls.RichEditorShortcut.Display.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.Id.get -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcut.Id.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.Key.get -> Avalonia.Input.Key +AvaloniaRichEditor.Controls.RichEditorShortcut.Key.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.RichEditorShortcut() -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.RichEditorShortcut(AvaloniaRichEditor.Controls.RichEditorShortcutId Id, bool Ctrl, bool Shift, bool Alt, Avalonia.Input.Key Key, string! Display) -> void +AvaloniaRichEditor.Controls.RichEditorShortcut.Shift.get -> bool +AvaloniaRichEditor.Controls.RichEditorShortcut.Shift.init -> void +AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.AlignCenter = 16 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.AlignJustify = 18 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.AlignLeft = 15 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.AlignRight = 17 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.BodyText = 25 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Bold = 7 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.BulletList = 26 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Copy = 1 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Cut = 0 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Find = 33 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.FindReplace = 34 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.FontLarger = 11 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.FontSmaller = 12 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading1 = 19 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading2 = 20 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading3 = 21 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading4 = 22 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading5 = 23 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Heading6 = 24 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.IndentDecrease = 14 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.IndentIncrease = 13 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.InsertLink = 32 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Italic = 8 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.LineSpacingDouble = 30 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.LineSpacingOneHalf = 29 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.LineSpacingSingle = 28 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.NumberedList = 27 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Paste = 2 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.PastePlain = 3 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Redo = 6 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.SelectAll = 4 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.SelectCell = 31 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Strikethrough = 10 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Underline = 9 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcutId.Undo = 5 -> AvaloniaRichEditor.Controls.RichEditorShortcutId +AvaloniaRichEditor.Controls.RichEditorShortcuts AvaloniaRichEditor.Documents.ImageBlock.AltText.get -> string? AvaloniaRichEditor.Documents.ImageBlock.AltText.set -> void AvaloniaRichEditor.Documents.InlineImage.AltText.get -> string? @@ -20,6 +72,9 @@ AvaloniaRichEditor.Controls.RichEditorView.HideFindBar() -> void AvaloniaRichEditor.Controls.RichEditorView.ShowBuiltInFindBar.get -> bool AvaloniaRichEditor.Controls.RichEditorView.ShowBuiltInFindBar.set -> void AvaloniaRichEditor.Controls.RichEditorView.ShowFindBar(bool withReplace) -> void +static AvaloniaRichEditor.Controls.RichEditorShortcuts.All.get -> System.Collections.Generic.IReadOnlyList! +static AvaloniaRichEditor.Controls.RichEditorShortcuts.Display(AvaloniaRichEditor.Controls.RichEditorShortcutId id) -> string! +static AvaloniaRichEditor.Controls.RichEditorShortcuts.Gesture(AvaloniaRichEditor.Controls.RichEditorShortcutId id) -> Avalonia.Input.KeyGesture? static readonly AvaloniaRichEditor.Controls.RichEditorView.ShowBuiltInFindBarProperty -> Avalonia.StyledProperty! AvaloniaRichEditor.Controls.RichEditorIcon.Find = 47 -> AvaloniaRichEditor.Controls.RichEditorIcon AvaloniaRichEditor.Controls.RichEditor.InsertTableRow(AvaloniaRichEditor.Documents.TableBlock! table, int at) -> bool diff --git a/tests/AvaloniaRichEditor.Tests/PortAuditBackportTests.cs b/tests/AvaloniaRichEditor.Tests/PortAuditBackportTests.cs index 8eeb42d..5d2ab55 100644 --- a/tests/AvaloniaRichEditor.Tests/PortAuditBackportTests.cs +++ b/tests/AvaloniaRichEditor.Tests/PortAuditBackportTests.cs @@ -164,8 +164,8 @@ public void DrawnInACell_TheTableFitsTheCell() public void CtrlK_IsInTheShortcutTable() { Assert.True(RichEditorShortcuts.TryMatch(true, false, false, Key.K, out var id)); - Assert.Equal(ShortcutId.InsertLink, id); - Assert.Equal("Ctrl+K", RichEditorShortcuts.Display(ShortcutId.InsertLink)); + Assert.Equal(RichEditorShortcutId.InsertLink, id); + Assert.Equal("Ctrl+K", RichEditorShortcuts.Display(RichEditorShortcutId.InsertLink)); } // End to end: the key opens the link dialog on the link the caret is in, and OK re-links that word. diff --git a/tests/AvaloniaRichEditor.Tests/PublicShortcutTableTests.cs b/tests/AvaloniaRichEditor.Tests/PublicShortcutTableTests.cs new file mode 100644 index 0000000..3b2d4ae --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/PublicShortcutTableTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// The shortcut table is public since 1.3.0 (backlog item): a host building its own toolbar or menu could +// not read the gestures the editor acts on, so it had to write "Ctrl+B" again somewhere the editor cannot +// see — and the two drift the day a binding changes. +// +// What is worth holding here is what a host relies on and the editor could break silently: the table hands +// out no way to edit itself, every command in it has a hint, the hint agrees with the modifiers and key of +// the entry it came from, Gesture agrees with the same entry, and pressing what the table advertises does +// what it says. +public class PublicShortcutTableTests +{ + private static void Press(RichEditor ed, Key key, KeyModifiers mods) + => ed.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, KeyModifiers = mods }); + + private static RichEditorShortcut Primary(RichEditorShortcutId id) + => RichEditorShortcuts.All.First(s => s.Id == id); + + private static KeyModifiers Modifiers(RichEditorShortcut s) + { + var m = KeyModifiers.None; + if (s.Ctrl) m |= KeyModifiers.Control; + if (s.Shift) m |= KeyModifiers.Shift; + if (s.Alt) m |= KeyModifiers.Alt; + return m; + } + + // Spelled out here rather than read from the product, so this is an independent oracle for Display. + private static string KeyName(Key k) => k switch + { + Key.OemPeriod => ".", + Key.OemComma => ",", + >= Key.D0 and <= Key.D9 => ((int)(k - Key.D0)).ToString(), + _ => k.ToString(), + }; + + [Fact] + public void TheTableHandsOutNoWayToEditIt() + { + var all = RichEditorShortcuts.All; + + // A host that could write here would be editing what the editor's own key handler matches against. + Assert.IsNotType(all); + if (all is IList writable) + { + Assert.True(writable.IsReadOnly); + Assert.Throws(() => writable[0] = default); + } + } + + [Fact] + public void EveryCommandHasAHint() + { + foreach (RichEditorShortcutId id in Enum.GetValues()) + Assert.False(string.IsNullOrEmpty(RichEditorShortcuts.Display(id)), $"{id} has no hint"); + } + + // The hint is what a host prints next to its own button; the modifiers and key are what the editor + // acts on. A hint that says something else is the exact drift this table exists to prevent. + [Fact] + public void EveryHintSpellsOutItsOwnModifiersAndKey() + { + foreach (var s in RichEditorShortcuts.All) + { + string expected = (s.Ctrl ? "Ctrl+" : "") + (s.Shift ? "Shift+" : "") + (s.Alt ? "Alt+" : "") + KeyName(s.Key); + // An alias entry keeps the primary command's hint (Ctrl+Shift+Z shows "Ctrl+Y"), so it is the + // primary entry of each command that has to spell itself out. + if (ReferenceEquals(Primary(s.Id).Display, s.Display) && Primary(s.Id).Key == s.Key) + Assert.Equal(expected, s.Display); + } + } + + [Fact] + public void GestureMatchesThePrimaryEntry() + { + foreach (RichEditorShortcutId id in Enum.GetValues()) + { + var s = Primary(id); + var g = RichEditorShortcuts.Gesture(id); + Assert.NotNull(g); + Assert.Equal(s.Key, g!.Key); + Assert.Equal(Modifiers(s), g.KeyModifiers); + } + } + + // The tie to behaviour: press exactly what the table advertises and the command runs. Without this the + // rest only proves the table is self-consistent — it could be self-consistent and wrong. + [AvaloniaTheory] + [InlineData(RichEditorShortcutId.Bold)] + [InlineData(RichEditorShortcutId.Italic)] + [InlineData(RichEditorShortcutId.AlignCenter)] + [InlineData(RichEditorShortcutId.Heading1)] + public void PressingWhatTheTableAdvertises_RunsTheCommand(RichEditorShortcutId id) + { + var p = TestHelpers.Para(new Run { Text = "text" }); + var ed = new RichEditor { Document = TestHelpers.Doc(p) }; + ed.FocusDocumentEnd(); + Press(ed, Key.A, KeyModifiers.Control); // select it all + + var s = Primary(id); + Press(ed, s.Key, Modifiers(s)); + + switch (id) + { + case RichEditorShortcutId.Bold: + Assert.Equal(FontWeight.Bold, p.Inlines.OfType().First().FontWeight); + break; + case RichEditorShortcutId.Italic: + Assert.Equal(FontStyle.Italic, p.Inlines.OfType().First().FontStyle); + break; + case RichEditorShortcutId.AlignCenter: + Assert.Equal(TextAlignment.Center, p.TextAlignment); + break; + case RichEditorShortcutId.Heading1: + Assert.Equal(1, p.HeadingLevel); + break; + } + } +}