From aff124f602c883c0253f8f0b36d484e5cefde179 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:20:03 +0000 Subject: [PATCH 1/3] fix: pending style follows the caret; paste uses typing style; IME notified on undo/redo A format picked at a collapsed caret (e.g. Bold with nothing selected) kept applying wherever the user typed next, even after moving the caret. The FormattingEngine and FontFormattingEngine now record where the pending style was picked (PendingStylePosition) and DiscardPendingStyleIfMoved drops it once the selection is no longer that caret (compared by paragraph and character offset, so run-boundary positions match). The input controller discards stale pending styles after handled keys, pointer input and before typing, pasting or Enter (which still carries the style into the new paragraph); the toolbar and MintedEditorView's formatting state do the same, so moving the selection from code is covered too. Plain-text paste inserted TextStyle.Default. HandlePasteAsync now uses the same insertion style as typing (pending style, else the caret's style), and ClipboardOperations.PasteAsync inherits the caret's style. MintedEditorView.Undo()/Redo() (toolbar buttons and public API) did not tell the platform IME that the text and selection changed, unlike the Ctrl+Z path; they now call NotifyImeSelectionChanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 3 + docs/formatting.md | 4 +- .../Editing/ClipboardOperations.cs | 5 +- .../Formatting/FontFormattingEngine.cs | 35 ++- .../Formatting/FormattingEngine.cs | 49 +++- .../Input/EditorInputController.cs | 58 +++- .../Toolbar/ToolbarRenderer.cs | 5 +- src/MintedTextEditor.Maui/MintedEditorView.cs | 9 +- .../CharacterFormattingTests.cs | 6 +- .../PendingStyleTests.cs | 273 ++++++++++++++++++ 10 files changed, 422 insertions(+), 25 deletions(-) create mode 100644 tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 009ba26..778949d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Undo for character formatting**: bold, italic, underline, strikethrough, subscript, superscript and clear formatting (toolbar, Ctrl+B/I/U and commands), the font family and size dropdowns, and the text and highlight colour pickers now record one undo step each. Undo restores the previous runs with their exact styles and redo reapplies the change. Toggling with a collapsed caret still only sets the pending style and is not recorded. `ApplyStyleAction` gains a constructor that wraps any formatting operation, and its undo no longer resets the paragraph style or restores the wrong paragraph when a table lies inside the range. - **Removing a hyperlink**: `HyperlinkEngine.RemoveHyperlink` keeps the link text's formatting instead of turning it into one plain run, and `RemoveHyperlinkCommand` (toolbar and context menu) is now undoable through the new `RemoveHyperlinkAction`. - **Undo restoring copies**: undoing a delete, paragraph merge, formatting change or link removal puts back the original runs, images, links and line breaks instead of copies, so undoing an earlier image or link insertion afterwards still removes it. Line breaks no longer come back as `"\n"` text, and images and line breaks inside links are no longer dropped. +- **Pending style follows the caret**: a format picked with nothing selected (e.g. Bold at a bare caret) now applies only at the caret where it was picked. Moving the caret (arrow keys, Home/End, a click, or setting the selection from code) discards it, and the toolbar stops showing it; Enter still carries it into the new paragraph. New: `FormattingEngine`/`FontFormattingEngine.PendingStylePosition` and `DiscardPendingStyleIfMoved`. +- **Plain-text paste formatting**: pasted text gets the same style as typed text at the caret (the pending style, else the caret's formatting) instead of `TextStyle.Default`, both in `EditorInputController.HandlePasteAsync` and `ClipboardOperations.PasteAsync` (caret formatting only). +- **IME after undo/redo**: `MintedEditorView.Undo()` and `Redo()` (also the toolbar buttons) now tell the platform keyboard that the text and selection changed, so Android and iOS keyboards don't keep editing stale text. - **`InsertHyperlinkCommand.DisplayText`** is now used: when set, the link is inserted with that text at the caret, or replaces the selected text. Undoing a hyperlink insertion now restores the original text with its formatting (previously it came back unformatted), and redoing a link made from a selection no longer throws. A link inserted with the caret right after an image or another link now goes after it rather than before. Undoing a deletion or a link insertion keeps soft line breaks, images and links (with all their content) as the same objects instead of converting them to text or copies, so earlier undo steps such as an image insertion still work, and undoing one that spanned paragraphs refreshes the layout of every paragraph after them. - Changing the theme's `HyperlinkColor` now recolors links already laid out, instead of only links in paragraphs edited afterwards. - When the document gets shorter (e.g. smaller theme spacing), the editor no longer stays scrolled past its end. diff --git a/docs/formatting.md b/docs/formatting.md index 2f9aea2..d7a35ff 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -33,9 +33,9 @@ fmt.ToggleSuperscript(doc, range); fmt.ClearFormatting(doc, range); ``` -With an empty range (a collapsed caret), toggles set a pending style that is applied to the next text the user types. The pending style starts from the formatting at the caret, so toggling Bold inside bold text turns bold off for the next text and keeps the other formatting. +With an empty range (a collapsed caret), toggles set a pending style that is applied to the next text the user types. The pending style starts from the formatting at the caret, so toggling Bold inside bold text turns bold off for the next text and keeps the other formatting. The pending style applies only at the caret where it was picked (`PendingStylePosition`): moving the caret or selecting text discards it, while Enter carries it into the new paragraph. Hosts that move the selection themselves can call `DiscardPendingStyleIfMoved(doc, selection)` on `FormattingEngine` and `FontFormattingEngine`; the input controller, toolbar and `MintedEditorView` already do. -Without a pending style, typed text inherits the formatting of the character before the caret (or the character after it at the start of a paragraph; an empty paragraph uses `TextStyle.Default`). Pressing Enter carries that formatting into the new paragraph, so typing after Enter at the end of bold text stays bold. Typing or pasting at the start or end of a hyperlink with nothing selected adds plain text next to it rather than extending the link; text that replaces a selection inside a link (typing over it, or find and replace) stays in the link. `DocumentEditor.GetInsertionStyle(doc, position)` returns that inherited style, and `FormattingEngine.GetEffectiveCaretStyle(doc, position, editor.Formatting.PendingStyle, editor.FontFormatting.PendingStyle)` returns the style the next typed text will get. The toolbar's Bold, Italic, Underline, Strikethrough, Subscript and Superscript buttons show that style when nothing is selected. +Without a pending style, typed text inherits the formatting of the character before the caret (or the character after it at the start of a paragraph; an empty paragraph uses `TextStyle.Default`). Pressing Enter carries that formatting into the new paragraph, so typing after Enter at the end of bold text stays bold. Pasted plain text gets the same style as typed text. Typing or pasting at the start or end of a hyperlink with nothing selected adds plain text next to it rather than extending the link; text that replaces a selection inside a link (typing over it, or find and replace) stays in the link. `DocumentEditor.GetInsertionStyle(doc, position)` returns that inherited style, and `FormattingEngine.GetEffectiveCaretStyle(doc, position, editor.Formatting.PendingStyle, editor.FontFormatting.PendingStyle)` returns the style the next typed text will get. The toolbar's Bold, Italic, Underline, Strikethrough, Subscript and Superscript buttons show that style when nothing is selected. ### Setting Font Properties diff --git a/src/MintedTextEditor.Core/Editing/ClipboardOperations.cs b/src/MintedTextEditor.Core/Editing/ClipboardOperations.cs index 284a1a7..3e81bcf 100644 --- a/src/MintedTextEditor.Core/Editing/ClipboardOperations.cs +++ b/src/MintedTextEditor.Core/Editing/ClipboardOperations.cs @@ -29,7 +29,8 @@ public static async Task CutAsync(Document.Document document, /// /// Pastes plain text from the clipboard at . - /// Replaces if it is not empty. + /// Replaces if it is not empty. The text gets the style typing + /// would inherit there (see ). /// Returns the caret position after the paste, or null if the clipboard was empty. /// public static async Task PasteAsync( @@ -45,6 +46,6 @@ public static async Task CutAsync(Document.Document document, if (!selectionRange.IsEmpty) insertPos = DocumentEditor.DeleteRange(document, selectionRange); - return DocumentEditor.InsertText(document, insertPos, text); + return DocumentEditor.InsertText(document, insertPos, text, DocumentEditor.GetInsertionStyle(document, insertPos)); } } diff --git a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs index b3968e6..ad43abb 100644 --- a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs @@ -11,10 +11,38 @@ public class FontFormattingEngine { /// /// Style to apply on the next text insert when the caret is collapsed and font settings - /// are changed with no active selection. + /// are changed with no active selection. Discarded when the caret moves away + /// (see ). /// public TextStyle? PendingStyle { get; private set; } + /// The caret position where was chosen, or null when there is none. + public DocumentPosition? PendingStylePosition { get; private set; } + + /// + /// Discards unless is still the collapsed + /// caret where it was chosen, so a pending style applies only at that caret. + /// + /// The document. + /// The current selection. + public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection) + { + if (PendingStyle is not null && !FormattingEngine.IsCaretAt(doc, selection, PendingStylePosition)) + SetPendingStyle(null, null); + } + + /// Re-anchors the pending style at (an edit such as Enter carried it there). + internal void MovePendingStyleTo(DocumentPosition position) + { + if (PendingStyle is not null) PendingStylePosition = position; + } + + private void SetPendingStyle(TextStyle? style, DocumentPosition? position) + { + PendingStyle = style; + PendingStylePosition = style is null ? null : position; + } + // ── Apply API ───────────────────────────────────────────────────── /// Sets the font family of the text in . When is empty, updates instead. @@ -43,7 +71,7 @@ public void RemoveHighlightColor(Document.Document doc, TextRange range) public TextStyle? ConsumePendingStyle() { var style = PendingStyle; - PendingStyle = null; + SetPendingStyle(null, null); return style; } @@ -111,8 +139,9 @@ private void Apply(Document.Document doc, TextRange range, Func /// Style to apply on the next text insert when the caret is collapsed and the - /// user has toggled a format with no selection. Consumed after one use. + /// user has toggled a format with no selection. Consumed after one use, and discarded + /// when the caret moves away (see ). /// public TextStyle? PendingStyle { get; private set; } + /// The caret position where was chosen, or null when there is none. + public DocumentPosition? PendingStylePosition { get; private set; } + + /// + /// Discards unless is still the collapsed + /// caret where it was chosen, so a pending style applies only at that caret. + /// + /// The document. + /// The current selection. + public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection) + { + if (PendingStyle is not null && !IsCaretAt(doc, selection, PendingStylePosition)) + SetPendingStyle(null, null); + } + + /// Re-anchors the pending style at (an edit such as Enter carried it there). + internal void MovePendingStyleTo(DocumentPosition position) + { + if (PendingStyle is not null) PendingStylePosition = position; + } + + /// + /// Whether is a collapsed caret at the same spot as + /// (same paragraph and character offset, however the position is split across inlines). + /// + internal static bool IsCaretAt(Document.Document doc, TextRange selection, DocumentPosition? position) + { + if (position is not { } p || !selection.IsEmpty) return false; + var caret = selection.Start; + return caret.BlockIndex == p.BlockIndex && caret.CellRow == p.CellRow && caret.CellCol == p.CellCol + && DocumentEditor.GetAbsoluteCharOffset(doc, caret) == DocumentEditor.GetAbsoluteCharOffset(doc, p); + } + + private void SetPendingStyle(TextStyle? style, DocumentPosition? position) + { + PendingStyle = style; + PendingStylePosition = style is null ? null : position; + } + // ── Public toggle API ───────────────────────────────────────────── /// @@ -66,7 +106,7 @@ public void ClearFormatting(Document.Document doc, TextRange range) { if (range.IsEmpty) { - PendingStyle = TextStyle.Default; + SetPendingStyle(TextStyle.Default, range.Start); return; } DocumentEditor.ApplyTextStyle(doc, range, _ => TextStyle.Default); @@ -79,7 +119,7 @@ public void ClearFormatting(Document.Document doc, TextRange range) public TextStyle? ConsumePendingStyle() { var style = PendingStyle; - PendingStyle = null; + SetPendingStyle(null, null); return style; } @@ -122,8 +162,9 @@ private void Toggle( // No selection: update pending style so the next typed character picks it up. // Start from the style the caret would otherwise type with, so toggling bold off // inside bold text really turns it off and other formatting is kept. + DiscardPendingStyleIfMoved(doc, range); var baseStyle = PendingStyle ?? DocumentEditor.GetInsertionStyle(doc, range.Start); - PendingStyle = setter(baseStyle, !getter(baseStyle)); + SetPendingStyle(setter(baseStyle, !getter(baseStyle)), range.Start); return; } diff --git a/src/MintedTextEditor.Core/Input/EditorInputController.cs b/src/MintedTextEditor.Core/Input/EditorInputController.cs index 42a2daf..826a2c4 100644 --- a/src/MintedTextEditor.Core/Input/EditorInputController.cs +++ b/src/MintedTextEditor.Core/Input/EditorInputController.cs @@ -68,9 +68,16 @@ public EditorInputController(Caret caret, CaretRenderer caretRenderer) /// /// Handles a key-down event, dispatching to the appropriate navigation or edit action. - /// Returns true if the event was handled. + /// Returns true if the event was handled. A pending style is discarded when the key moved the caret. /// public bool HandleKeyDown(EditorKeyEventArgs e, Document.Document document, DocumentLayout layout, IDrawingContext context) + { + bool handled = HandleKeyDownCore(e, document, layout, context); + if (handled) DiscardStalePendingStyles(document); + return handled; + } + + private bool HandleKeyDownCore(EditorKeyEventArgs e, Document.Document document, DocumentLayout layout, IDrawingContext context) { if (!e.IsKeyDown) return false; @@ -272,6 +279,7 @@ public void HandleTextInput(string text, Document.Document document) private void InsertLiteralText(string text, Document.Document document) { SyncToVisibleCell(document); + DiscardStalePendingStyles(document); DocumentPosition insertPos = _caret.Position; bool replacesSelection = !_selection.IsEmpty; if (replacesSelection) @@ -280,16 +288,34 @@ private void InsertLiteralText(string text, Document.Document document) _selection.CollapseTo(insertPos); } - // Pending styles (picked with no selection) win; otherwise the text inherits the - // style of the character before the caret (or after it at a paragraph start). - var style = Formatting.FormattingEngine.GetEffectiveCaretStyle( + var style = ConsumeInsertionStyle(document, insertPos); + var newPos = InsertAndTrack(document, insertPos, text, style, replacesSelection); + _selection.CollapseTo(newPos); + _caret.MoveTo(newPos); + } + + /// + /// Returns the style for text typed or pasted at , consuming the pending + /// styles: they win when they were picked at this caret; otherwise the text inherits the style of + /// the character before the caret (or after it at a paragraph start). + /// + private TextStyle ConsumeInsertionStyle(Document.Document document, DocumentPosition insertPos) + => Formatting.FormattingEngine.GetEffectiveCaretStyle( document, insertPos, FormattingEngine?.ConsumePendingStyle(), FontFormattingEngine?.ConsumePendingStyle()); - var newPos = InsertAndTrack(document, insertPos, text, style, replacesSelection); - _selection.CollapseTo(newPos); - _caret.MoveTo(newPos); + + /// + /// Discards pending styles that were picked at a different caret than the current one + /// (or before a selection was made), so they apply only where they were chosen. + /// + private void DiscardStalePendingStyles(Document.Document document) + { + // Text goes in at the caret when nothing is selected. + var current = _selection.IsEmpty ? new TextRange(_caret.Position, _caret.Position) : _selection.Range; + FormattingEngine?.DiscardPendingStyleIfMoved(document, current); + FontFormattingEngine?.DiscardPendingStyleIfMoved(document, current); } /// @@ -402,7 +428,10 @@ public async Task HandleCutAsync(Document.Document document) _caret.MoveTo(pos); } - /// Pastes clipboard text at the caret, replacing any active selection. + /// + /// Pastes clipboard text at the caret, replacing any active selection. The text gets the style + /// typing would use there: the pending style, else the style inherited from the caret. + /// public async Task HandlePasteAsync(Document.Document document) { if (ClipboardProvider == null) return; @@ -410,6 +439,7 @@ public async Task HandlePasteAsync(Document.Document document) if (string.IsNullOrEmpty(pasteText)) return; SyncToVisibleCell(document); + DiscardStalePendingStyles(document); DocumentPosition insertPos = _caret.Position; bool replacesSelection = !_selection.IsEmpty; if (replacesSelection) @@ -418,7 +448,9 @@ public async Task HandlePasteAsync(Document.Document document) _selection.CollapseTo(insertPos); } - var newPos = InsertAndTrack(document, insertPos, pasteText, null, replacesSelection); + // Plain text gets the same style as typing at the caret. + var style = ConsumeInsertionStyle(document, insertPos); + var newPos = InsertAndTrack(document, insertPos, pasteText, style, replacesSelection); _selection.CollapseTo(newPos); _caret.MoveTo(newPos); } @@ -441,6 +473,7 @@ public void HandlePointerDown(EditorPointerEventArgs e, Document.Document docume var dragResult = DocumentHitTester.HitTest(e.X, e.Y, layout, document, context); _selection.ExtendTo(dragResult.Position); _caret.MoveTo(dragResult.Position); + DiscardStalePendingStyles(document); } return; } @@ -468,6 +501,8 @@ public void HandlePointerDown(EditorPointerEventArgs e, Document.Document docume _caret.MoveTo(result.Position); _isDragging = true; } + + DiscardStalePendingStyles(document); } // ── Editing helpers ─────────────────────────────────────────────── @@ -558,6 +593,7 @@ private void HandleDelete(Document.Document document) private void HandleEnter(Document.Document document) { SyncToVisibleCell(document); + DiscardStalePendingStyles(document); DocumentPosition insertPos = _caret.Position; if (!_selection.IsEmpty) { @@ -568,6 +604,10 @@ private void HandleEnter(Document.Document document) var newPos = PushOrExecute(new SplitBlockAction(document, insertPos)); _selection.CollapseTo(newPos); _caret.MoveTo(newPos); + + // A pending style picked before Enter applies to the text typed in the new paragraph. + FormattingEngine?.MovePendingStyleTo(newPos); + FontFormattingEngine?.MovePendingStyleTo(newPos); } // ── IME support ─────────────────────────────────────────────────── diff --git a/src/MintedTextEditor.Core/Toolbar/ToolbarRenderer.cs b/src/MintedTextEditor.Core/Toolbar/ToolbarRenderer.cs index 73fe76e..6a0805c 100644 --- a/src/MintedTextEditor.Core/Toolbar/ToolbarRenderer.cs +++ b/src/MintedTextEditor.Core/Toolbar/ToolbarRenderer.cs @@ -338,11 +338,14 @@ public ToolbarRenderer(ToolbarDefinition definition) /// toggle button's flag accordingly. /// With a selection, a format is active when it covers the whole selection; at a /// collapsed caret, it reflects the style the next typed text would get (the pending - /// style when one is set, otherwise the style inherited from the text at the caret). + /// style when one was picked at this caret, otherwise the style inherited from the text at the caret). + /// Pending styles picked at a different caret are discarded. /// public void UpdateToggleStates(EditorContext ctx) { var range = ctx.Selection.Range; + ctx.FormattingEngine.DiscardPendingStyleIfMoved(ctx.Document, range); + ctx.FontFormattingEngine.DiscardPendingStyleIfMoved(ctx.Document, range); var caretStyle = range.IsEmpty ? FormattingEngine.GetEffectiveCaretStyle( ctx.Document, diff --git a/src/MintedTextEditor.Maui/MintedEditorView.cs b/src/MintedTextEditor.Maui/MintedEditorView.cs index 062a86f..aaf3788 100644 --- a/src/MintedTextEditor.Maui/MintedEditorView.cs +++ b/src/MintedTextEditor.Maui/MintedEditorView.cs @@ -1112,6 +1112,7 @@ public void Undo() { var pos = _undoManager.Undo(); if (pos.HasValue) MoveCaretAfterHistoryChange(pos.Value); + NotifyImeSelectionChanged(); InvalidateCanvas(); } } @@ -1123,6 +1124,7 @@ public void Redo() { var pos = _undoManager.Redo(); if (pos.HasValue) MoveCaretAfterHistoryChange(pos.Value); + NotifyImeSelectionChanged(); InvalidateCanvas(); } } @@ -2335,6 +2337,11 @@ private void UpdateDocumentState() private FormattingState ComputeFormattingState() { + // A pending style applies only at the caret where it was picked. + var range = _inputController.Selection.Range; + _formattingEngine.DiscardPendingStyleIfMoved(_document, range); + _fontFormattingEngine.DiscardPendingStyleIfMoved(_document, range); + // Merge the pending styles the same way typed input does: character toggles from // one, font family, size and colours from the other. var pending = _formattingEngine.PendingStyle; @@ -2347,7 +2354,7 @@ private FormattingState ComputeFormattingState() .WithTextColor(pendingFont.TextColor) .WithHighlightColor(pendingFont.HighlightColor); } - return FormattingState.Compute(_document, _inputController.Selection.Range, pending ?? pendingFont); + return FormattingState.Compute(_document, range, pending ?? pendingFont); } /// diff --git a/tests/MintedTextEditor.Core.Tests/CharacterFormattingTests.cs b/tests/MintedTextEditor.Core.Tests/CharacterFormattingTests.cs index 9b452d2..4c172c1 100644 --- a/tests/MintedTextEditor.Core.Tests/CharacterFormattingTests.cs +++ b/tests/MintedTextEditor.Core.Tests/CharacterFormattingTests.cs @@ -240,8 +240,8 @@ public void PendingStyle_AppliesToNextTypedCharacter() // Place caret at end of "Hello" caret.MoveTo(new DocumentPosition(0, 0, 5)); - // Toggle bold with no selection (sets pending) - fmt.ToggleBold(doc, TextRange.Empty); + // Toggle bold with no selection at the caret (sets pending) + fmt.ToggleBold(doc, new TextRange(caret.Position, caret.Position)); // Type a character — should be bold ctrl.HandleTextInput("!", doc); @@ -267,7 +267,7 @@ public void PendingStyle_IsConsumed_SubsequentTypingInheritsPrecedingStyle() var ctrl = new EditorInputController(caret, caretRenderer) { FormattingEngine = fmt }; caret.MoveTo(new DocumentPosition(0, 0, 5)); - fmt.ToggleBold(doc, TextRange.Empty); + fmt.ToggleBold(doc, new TextRange(caret.Position, caret.Position)); ctrl.HandleTextInput("!", doc); // bold // The pending style is consumed by the first character... diff --git a/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs new file mode 100644 index 0000000..c11a48b --- /dev/null +++ b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs @@ -0,0 +1,273 @@ +using MintedTextEditor.Core.Commands; +using MintedTextEditor.Core.Document; +using MintedTextEditor.Core.Editing; +using MintedTextEditor.Core.Formatting; +using MintedTextEditor.Core.Input; +using MintedTextEditor.Core.Layout; +using MintedTextEditor.Core.Toolbar; + +namespace MintedTextEditor.Core.Tests; + +/// +/// A pending style (a format picked at a collapsed caret) applies only at the caret where it was +/// chosen, and plain-text paste uses the same insertion style as typing. +/// +public class PendingStyleTests +{ + private static readonly TextStyle Bold = TextStyle.Default.WithBold(true); + + private static Paragraph Para(params (string Text, TextStyle Style)[] runs) + { + var p = new Paragraph(); + foreach (var (text, style) in runs) p.AppendRun(text, style); + return p; + } + + private sealed record Editor( + EditorDocument Doc, DocumentLayout Layout, MockDrawingContext Context, Caret Caret, + EditorInputController Ctrl, FormattingEngine Fmt, FontFormattingEngine FontFmt); + + private static Editor Setup(string? clipboard, params Block[] blocks) + { + var doc = new EditorDocument(blocks); + var context = new MockDrawingContext(); + var layout = new TextLayoutEngine().Layout(doc, 800f, context); + var caret = new Caret(); + var fmt = new FormattingEngine(); + var fontFmt = new FontFormattingEngine(); + var ctrl = new EditorInputController(caret, new CaretRenderer()) + { + FormattingEngine = fmt, + FontFormattingEngine = fontFmt, + ClipboardProvider = new MockClipboardProvider(clipboard), + }; + return new Editor(doc, layout, context, caret, ctrl, fmt, fontFmt); + } + + private static void PlaceCaret(Editor e, DocumentPosition pos) + { + e.Ctrl.Selection.CollapseTo(pos); + e.Caret.MoveTo(pos); + } + + private static void Key(Editor e, EditorKey key, InputModifiers modifiers = InputModifiers.None) + => e.Ctrl.HandleKeyDown(new EditorKeyEventArgs(key, modifiers: modifiers), e.Doc, e.Layout, e.Context); + + private static TextRun RunContaining(EditorDocument doc, string text) + => ((Paragraph)doc.Blocks[0]).Inlines.OfType().First(r => r.Text.Contains(text)); + + // ── Pending style is cleared when the caret moves ──────────────── + + [Theory] + [InlineData(EditorKey.Left)] + [InlineData(EditorKey.Right)] + [InlineData(EditorKey.Home)] + [InlineData(EditorKey.End)] + public void PendingBold_CaretMovedByKey_TypingIsNotBold(EditorKey key) + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + Key(e, key); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.All(((Paragraph)e.Doc.Blocks[0]).Inlines.OfType(), r => Assert.False(r.Style.IsBold)); + } + + [Fact] + public void PendingBold_CaretMovedAndBack_TypingIsNotBold() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + Key(e, EditorKey.Right); + Key(e, EditorKey.Left); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.False(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingBold_CaretMovedByClick_TypingIsNotBold() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + e.Ctrl.HandlePointerDown(new EditorPointerEventArgs(0f, 5f, InputAction.Pressed), e.Doc, e.Layout, e.Context); + e.Ctrl.HandlePointerDown(new EditorPointerEventArgs(0f, 5f, InputAction.Released), e.Doc, e.Layout, e.Context); + Assert.NotEqual(new DocumentPosition(0, 0, 5), e.Caret.Position); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.False(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingBold_SelectionMovedFromCode_TypingIsNotBold() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + PlaceCaret(e, new DocumentPosition(0, 0, 11)); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.Equal("Hello WorldX", e.Doc.GetText()); + Assert.False(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingFontSize_SelectionMovedFromCode_TypingKeepsCaretSize() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + var at = new DocumentPosition(0, 0, 5); + PlaceCaret(e, at); + e.FontFmt.ApplyFontSize(e.Doc, new TextRange(at, at), 30f); + + PlaceCaret(e, new DocumentPosition(0, 0, 11)); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.Equal(TextStyle.Default.FontSize, RunContaining(e.Doc, "X").Style.FontSize); + } + + [Fact] + public void PendingBold_SameCaret_TypingIsBold() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.True(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingBold_SameLogicalSpotAtRunBoundary_TypingIsBold() + { + // (0,0,5) and (0,1,0) are the same spot: the end of "Hello" and the start of " World". + var e = Setup(null, Para(("Hello", TextStyle.Default), (" World", TextStyle.Default.WithItalic(true)))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + PlaceCaret(e, new DocumentPosition(0, 1, 0)); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.True(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingBold_ThenItalicAtAnotherCaret_TypingIsItalicOnly() + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + PlaceCaret(e, new DocumentPosition(0, 0, 11)); + Key(e, EditorKey.I, InputModifiers.Control); + e.Ctrl.HandleTextInput("X", e.Doc); + + var style = RunContaining(e.Doc, "X").Style; + Assert.True(style.IsItalic); + Assert.False(style.IsBold); + } + + [Fact] + public void Toolbar_PendingBold_AfterCaretMoved_ShowsBoldInactive() + { + var doc = new EditorDocument([Para(("Hello World", TextStyle.Default))]); + var ctx = new EditorContext(doc, new Selection(), new UndoManager(), + new FormattingEngine(), new FontFormattingEngine()); + ctx.Selection.CollapseTo(new DocumentPosition(0, 0, 5)); + new ToggleBoldCommand().Execute(ctx); + + ctx.Selection.CollapseTo(new DocumentPosition(0, 0, 2)); + var toolbar = ToolbarDefinition.CreateDefault(); + new ToolbarRenderer(toolbar).UpdateToggleStates(ctx); + + Assert.False(toolbar.AllItems.OfType().First(b => b.Label == "Bold").IsActive); + Assert.Null(ctx.FormattingEngine.PendingStyle); + } + + [Fact] + public void DiscardPendingStyleIfMoved_NonEmptySelection_Discards() + { + var doc = new EditorDocument([Para(("Hello World", TextStyle.Default))]); + var fmt = new FormattingEngine(); + var at = new DocumentPosition(0, 0, 5); + fmt.ToggleBold(doc, new TextRange(at, at)); + + fmt.DiscardPendingStyleIfMoved(doc, new TextRange(at, new DocumentPosition(0, 0, 8))); + + Assert.Null(fmt.PendingStyle); + Assert.Null(fmt.PendingStylePosition); + } + + // ── Plain-text paste uses the typing insertion style ───────────── + + [Fact] + public async Task Paste_AfterBoldText_InheritsBold() + { + var e = Setup("er", Para(("Hello ", TextStyle.Default), ("bold", Bold), (" tail", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 1, 4)); + + await e.Ctrl.HandlePasteAsync(e.Doc); + + Assert.Equal("Hello bolder tail", e.Doc.GetText()); + Assert.True(RunContaining(e.Doc, "bolder").Style.IsBold); + Assert.False(RunContaining(e.Doc, "tail").Style.IsBold); + } + + [Fact] + public async Task Paste_WithPendingBold_UsesPendingStyle() + { + var e = Setup("X", Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + await e.Ctrl.HandlePasteAsync(e.Doc); + + Assert.True(RunContaining(e.Doc, "X").Style.IsBold); + Assert.Null(e.Fmt.PendingStyle); + } + + [Fact] + public async Task Paste_WithPendingFontSize_UsesPendingSize() + { + var e = Setup("X", Para(("Hello World", TextStyle.Default))); + var at = new DocumentPosition(0, 0, 5); + PlaceCaret(e, at); + e.FontFmt.ApplyFontSize(e.Doc, new TextRange(at, at), 30f); + + await e.Ctrl.HandlePasteAsync(e.Doc); + + Assert.Equal(30f, RunContaining(e.Doc, "X").Style.FontSize); + } + + [Fact] + public async Task Paste_OverBoldSelection_InheritsStyleBeforeSelection() + { + var e = Setup("Earth", Para(("Hello ", Bold), ("World", TextStyle.Default))); + e.Ctrl.Selection.Set(new DocumentPosition(0, 1, 0), new DocumentPosition(0, 1, 5)); + e.Caret.MoveTo(new DocumentPosition(0, 1, 5)); + + await e.Ctrl.HandlePasteAsync(e.Doc); + + Assert.Equal("Hello Earth", e.Doc.GetText()); + Assert.True(RunContaining(e.Doc, "Earth").Style.IsBold); + } + + [Fact] + public async Task ClipboardOperationsPaste_AfterBoldText_InheritsBold() + { + var doc = new EditorDocument([Para(("bold", Bold), (" tail", TextStyle.Default))]); + var at = new DocumentPosition(0, 0, 4); + + await ClipboardOperations.PasteAsync(doc, at, new TextRange(at, at), new MockClipboardProvider("er")); + + Assert.Equal("bolder tail", doc.GetText()); + Assert.True(RunContaining(doc, "bolder").Style.IsBold); + } +} From a7dc8cad9a19a5a26a0e52872ba22e4e611484b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:29:12 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20pendi?= =?UTF-8?q?ng=20style=20anchor=20at=20the=20origin=20and=20after=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: TextRange.Empty was default(TextRange), whose positions have cell coordinates 0/0 (so IsInTableCell is true), while the caret starts at -1/-1. ToggleBold(doc, TextRange.Empty) followed by typing at the initial caret therefore discarded the pending style. TextRange.Empty now holds explicit (0,0,0) positions, and the pending-style comparison ignores cell coordinates outside table blocks, so default(TextRange) works too; cells in a table stay distinct. Finding 2: the comparison resolved both the saved and the current position against the edited document, so Backspace at the end of a run clamped the saved offset to the new caret and the pending style survived. The engines now store the paragraph and character offset when the pending style is picked (PendingStyleAnchor) and compare the caret against that; Enter re-anchors it in the new paragraph. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 2 +- .../Document/TextRange.cs | 4 +- .../Formatting/FontFormattingEngine.cs | 20 +++--- .../Formatting/FormattingEngine.cs | 62 +++++++++++++------ .../Input/EditorInputController.cs | 4 +- .../PendingStyleTests.cs | 59 ++++++++++++++++++ 6 files changed, 119 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937557d..c2ef846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Undo for character formatting**: bold, italic, underline, strikethrough, subscript, superscript and clear formatting (toolbar, Ctrl+B/I/U and commands), the font family and size dropdowns, and the text and highlight colour pickers now record one undo step each. Undo restores the previous runs with their exact styles and redo reapplies the change. Toggling with a collapsed caret still only sets the pending style and is not recorded. `ApplyStyleAction` gains a constructor that wraps any formatting operation, and its undo no longer resets the paragraph style or restores the wrong paragraph when a table lies inside the range. - **Removing a hyperlink**: `HyperlinkEngine.RemoveHyperlink` keeps the link text's formatting instead of turning it into one plain run, and `RemoveHyperlinkCommand` (toolbar and context menu) is now undoable through the new `RemoveHyperlinkAction`. - **Undo restoring copies**: undoing a delete, paragraph merge, formatting change or link removal puts back the original runs, images, links and line breaks instead of copies, so undoing an earlier image or link insertion afterwards still removes it. Line breaks no longer come back as `"\n"` text, and images and line breaks inside links are no longer dropped. -- **Pending style follows the caret**: a format picked with nothing selected (e.g. Bold at a bare caret) now applies only at the caret where it was picked. Moving the caret (arrow keys, Home/End, a click, or setting the selection from code) discards it, and the toolbar stops showing it; Enter still carries it into the new paragraph. New: `FormattingEngine`/`FontFormattingEngine.PendingStylePosition` and `DiscardPendingStyleIfMoved`. +- **Pending style follows the caret**: a format picked with nothing selected (e.g. Bold at a bare caret) now applies only at the caret where it was picked. Moving the caret (arrow keys, Home/End, a click, Backspace, or setting the selection from code) discards it, and the toolbar stops showing it; Enter still carries it into the new paragraph. New: `FormattingEngine`/`FontFormattingEngine.PendingStylePosition` and `DiscardPendingStyleIfMoved`. `TextRange.Empty` now holds positions outside any table cell (it used to be `default`, whose positions pointed at cell 0/0). - **Plain-text paste formatting**: pasted text gets the same style as typed text at the caret (the pending style, else the caret's formatting) instead of `TextStyle.Default`, both in `EditorInputController.HandlePasteAsync` and `ClipboardOperations.PasteAsync` (caret formatting only). - **IME after undo/redo**: `MintedEditorView.Undo()` and `Redo()` (also the toolbar buttons) now tell the platform keyboard that the text and selection changed, so Android and iOS keyboards don't keep editing stale text. - **`InsertHyperlinkCommand.DisplayText`** is now used: when set, the link is inserted with that text at the caret, or replaces the selected text. Undoing a hyperlink insertion now restores the original text with its formatting (previously it came back unformatted), and redoing a link made from a selection no longer throws. A link inserted with the caret right after an image or another link now goes after it rather than before. Undoing a deletion or a link insertion keeps soft line breaks, images and links (with all their content) as the same objects instead of converting them to text or copies, so earlier undo steps such as an image insertion still work, and undoing one that spanned paragraphs refreshes the layout of every paragraph after them. diff --git a/src/MintedTextEditor.Core/Document/TextRange.cs b/src/MintedTextEditor.Core/Document/TextRange.cs index b6f9a9d..4a593b8 100644 --- a/src/MintedTextEditor.Core/Document/TextRange.cs +++ b/src/MintedTextEditor.Core/Document/TextRange.cs @@ -14,8 +14,8 @@ namespace MintedTextEditor.Core.Document; /// Whether the range is empty (start equals end). public bool IsEmpty => Start == End; - /// An empty range at the document origin. - public static TextRange Empty => default; + /// An empty range at the document origin (outside any table cell). + public static TextRange Empty { get; } = new(new DocumentPosition(0, 0, 0), new DocumentPosition(0, 0, 0)); /// /// Creates a range between two positions, swapping them if diff --git a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs index ad43abb..cdd7eea 100644 --- a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs @@ -19,6 +19,9 @@ public class FontFormattingEngine /// The caret position where was chosen, or null when there is none. public DocumentPosition? PendingStylePosition { get; private set; } + // Where the pending style was chosen, as a paragraph and character offset taken when it was chosen. + private PendingStyleAnchor? _pendingAnchor; + /// /// Discards unless is still the collapsed /// caret where it was chosen, so a pending style applies only at that caret. @@ -27,20 +30,21 @@ public class FontFormattingEngine /// The current selection. public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection) { - if (PendingStyle is not null && !FormattingEngine.IsCaretAt(doc, selection, PendingStylePosition)) - SetPendingStyle(null, null); + if (PendingStyle is not null && !PendingStyleAnchor.IsCaretAt(doc, selection, _pendingAnchor)) + SetPendingStyle(doc, null, null); } /// Re-anchors the pending style at (an edit such as Enter carried it there). - internal void MovePendingStyleTo(DocumentPosition position) + internal void MovePendingStyleTo(Document.Document doc, DocumentPosition position) { - if (PendingStyle is not null) PendingStylePosition = position; + if (PendingStyle is not null) SetPendingStyle(doc, PendingStyle, position); } - private void SetPendingStyle(TextStyle? style, DocumentPosition? position) + private void SetPendingStyle(Document.Document doc, TextStyle? style, DocumentPosition? position) { PendingStyle = style; PendingStylePosition = style is null ? null : position; + _pendingAnchor = style is not null && position is { } p ? PendingStyleAnchor.At(doc, p) : null; } // ── Apply API ───────────────────────────────────────────────────── @@ -71,7 +75,9 @@ public void RemoveHighlightColor(Document.Document doc, TextRange range) public TextStyle? ConsumePendingStyle() { var style = PendingStyle; - SetPendingStyle(null, null); + PendingStyle = null; + PendingStylePosition = null; + _pendingAnchor = null; return style; } @@ -141,7 +147,7 @@ private void Apply(Document.Document doc, TextRange range, FuncThe caret position where was chosen, or null when there is none. public DocumentPosition? PendingStylePosition { get; private set; } + // Where the pending style was chosen, as a paragraph and character offset taken when it was chosen. + private PendingStyleAnchor? _pendingAnchor; + /// /// Discards unless is still the collapsed /// caret where it was chosen, so a pending style applies only at that caret. @@ -26,32 +29,21 @@ public class FormattingEngine /// The current selection. public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection) { - if (PendingStyle is not null && !IsCaretAt(doc, selection, PendingStylePosition)) - SetPendingStyle(null, null); + if (PendingStyle is not null && !PendingStyleAnchor.IsCaretAt(doc, selection, _pendingAnchor)) + SetPendingStyle(doc, null, null); } /// Re-anchors the pending style at (an edit such as Enter carried it there). - internal void MovePendingStyleTo(DocumentPosition position) - { - if (PendingStyle is not null) PendingStylePosition = position; - } - - /// - /// Whether is a collapsed caret at the same spot as - /// (same paragraph and character offset, however the position is split across inlines). - /// - internal static bool IsCaretAt(Document.Document doc, TextRange selection, DocumentPosition? position) + internal void MovePendingStyleTo(Document.Document doc, DocumentPosition position) { - if (position is not { } p || !selection.IsEmpty) return false; - var caret = selection.Start; - return caret.BlockIndex == p.BlockIndex && caret.CellRow == p.CellRow && caret.CellCol == p.CellCol - && DocumentEditor.GetAbsoluteCharOffset(doc, caret) == DocumentEditor.GetAbsoluteCharOffset(doc, p); + if (PendingStyle is not null) SetPendingStyle(doc, PendingStyle, position); } - private void SetPendingStyle(TextStyle? style, DocumentPosition? position) + private void SetPendingStyle(Document.Document doc, TextStyle? style, DocumentPosition? position) { PendingStyle = style; PendingStylePosition = style is null ? null : position; + _pendingAnchor = style is not null && position is { } p ? PendingStyleAnchor.At(doc, p) : null; } // ── Public toggle API ───────────────────────────────────────────── @@ -106,7 +98,7 @@ public void ClearFormatting(Document.Document doc, TextRange range) { if (range.IsEmpty) { - SetPendingStyle(TextStyle.Default, range.Start); + SetPendingStyle(doc, TextStyle.Default, range.Start); return; } DocumentEditor.ApplyTextStyle(doc, range, _ => TextStyle.Default); @@ -119,7 +111,9 @@ public void ClearFormatting(Document.Document doc, TextRange range) public TextStyle? ConsumePendingStyle() { var style = PendingStyle; - SetPendingStyle(null, null); + PendingStyle = null; + PendingStylePosition = null; + _pendingAnchor = null; return style; } @@ -164,7 +158,7 @@ private void Toggle( // inside bold text really turns it off and other formatting is kept. DiscardPendingStyleIfMoved(doc, range); var baseStyle = PendingStyle ?? DocumentEditor.GetInsertionStyle(doc, range.Start); - SetPendingStyle(setter(baseStyle, !getter(baseStyle)), range.Start); + SetPendingStyle(doc, setter(baseStyle, !getter(baseStyle)), range.Start); return; } @@ -225,3 +219,31 @@ private static bool IsRunInRange( return true; } } + +/// +/// Where a pending style was chosen: the paragraph (block, and cell inside a table) and the character +/// offset in it, taken when the style was chosen so later edits to the runs don't shift it. +/// +internal readonly record struct PendingStyleAnchor(int BlockIndex, int CellRow, int CellCol, int Offset) +{ + /// The anchor for in as it is now. + public static PendingStyleAnchor At(Document.Document doc, DocumentPosition position) + { + var p = Normalize(doc, position); + return new PendingStyleAnchor(p.BlockIndex, p.CellRow, p.CellCol, DocumentEditor.GetAbsoluteCharOffset(doc, p)); + } + + /// + /// Whether is a collapsed caret at : the same + /// paragraph and character offset, however the caret position is split across inlines. + /// + public static bool IsCaretAt(Document.Document doc, TextRange selection, PendingStyleAnchor? anchor) + => anchor is { } a && selection.IsEmpty && At(doc, selection.Start) == a; + + // Cell coordinates only mean something in a table block; elsewhere (e.g. default(DocumentPosition), + // whose cell is 0/0) they are dropped so the position matches the same spot outside a table. + private static DocumentPosition Normalize(Document.Document doc, DocumentPosition p) + => p.IsInTableCell && !(p.BlockIndex >= 0 && p.BlockIndex < doc.Blocks.Count && doc.Blocks[p.BlockIndex] is TableBlock) + ? new DocumentPosition(p.BlockIndex, p.InlineIndex, p.Offset) + : p; +} diff --git a/src/MintedTextEditor.Core/Input/EditorInputController.cs b/src/MintedTextEditor.Core/Input/EditorInputController.cs index 826a2c4..bc8c2cc 100644 --- a/src/MintedTextEditor.Core/Input/EditorInputController.cs +++ b/src/MintedTextEditor.Core/Input/EditorInputController.cs @@ -606,8 +606,8 @@ private void HandleEnter(Document.Document document) _caret.MoveTo(newPos); // A pending style picked before Enter applies to the text typed in the new paragraph. - FormattingEngine?.MovePendingStyleTo(newPos); - FontFormattingEngine?.MovePendingStyleTo(newPos); + FormattingEngine?.MovePendingStyleTo(document, newPos); + FontFormattingEngine?.MovePendingStyleTo(document, newPos); } // ── IME support ─────────────────────────────────────────────────── diff --git a/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs index c11a48b..57b27dd 100644 --- a/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs +++ b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs @@ -174,6 +174,65 @@ public void PendingBold_ThenItalicAtAnotherCaret_TypingIsItalicOnly() Assert.False(style.IsBold); } + // ── Review regressions ─────────────────────────────────────────── + + [Fact] + public void PendingBold_PickedWithTextRangeEmpty_AppliesAtInitialCaret() + { + var e = Setup(null, Para(("Hello", TextStyle.Default))); + e.Fmt.ToggleBold(e.Doc, TextRange.Empty); + e.FontFmt.ApplyFontSize(e.Doc, TextRange.Empty, 30f); + + e.Ctrl.HandleTextInput("X", e.Doc); // the caret starts at the origin + + var style = RunContaining(e.Doc, "X").Style; + Assert.True(style.IsBold); + Assert.Equal(30f, style.FontSize); + } + + [Fact] + public void PendingBold_PickedWithDefaultRange_AppliesAtInitialCaret() + { + var e = Setup(null, Para(("Hello", TextStyle.Default))); + e.Fmt.ToggleBold(e.Doc, default); + + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.True(RunContaining(e.Doc, "X").Style.IsBold); + } + + [Fact] + public void PendingBold_InTableCell_DiscardedInAnotherCellAtSameOffset() + { + var e = Setup(null, new TableBlock(1, 2)); + var cell0 = new DocumentPosition(0, 0, 0, 0, 0); + PlaceCaret(e, cell0); + Key(e, EditorKey.B, InputModifiers.Control); + + PlaceCaret(e, new DocumentPosition(0, 0, 0, 0, 1)); + e.Ctrl.HandleTextInput("X", e.Doc); + + var cell = ((TableBlock)e.Doc.Blocks[0]).GetCell(0, 1)!; + var run = ((Paragraph)cell.Blocks[0]).Inlines.OfType().Single(); + Assert.Equal("X", run.Text); + Assert.False(run.Style.IsBold); + } + + [Fact] + public void PendingBold_BackspaceAtRunEnd_DiscardsPendingStyle() + { + var e = Setup(null, Para(("Hello", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + Key(e, EditorKey.Backspace); + Assert.Null(e.Fmt.PendingStyle); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.Equal("HellX", e.Doc.GetText()); + Assert.False(RunContaining(e.Doc, "X").Style.IsBold); + } + [Fact] public void Toolbar_PendingBold_AfterCaretMoved_ShowsBoldInactive() { From f357ae8ba0ab2cb7428ab61a576099808ae6598f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:29:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20disca?= =?UTF-8?q?rd=20a=20stale=20pending=20style=20before=20handling=20a=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 3: HandleKeyDown only checked the pending style after the key. With Bold picked at offset 5 and the selection then set to [5,8] from code, Backspace or Delete collapsed the selection back to offset 5, so the check afterwards saw the original caret and kept the stale pending style. The controller now also discards it before handling the key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- .../Input/EditorInputController.cs | 4 +++- .../PendingStyleTests.cs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/MintedTextEditor.Core/Input/EditorInputController.cs b/src/MintedTextEditor.Core/Input/EditorInputController.cs index bc8c2cc..546fbd8 100644 --- a/src/MintedTextEditor.Core/Input/EditorInputController.cs +++ b/src/MintedTextEditor.Core/Input/EditorInputController.cs @@ -68,10 +68,12 @@ public EditorInputController(Caret caret, CaretRenderer caretRenderer) /// /// Handles a key-down event, dispatching to the appropriate navigation or edit action. - /// Returns true if the event was handled. A pending style is discarded when the key moved the caret. + /// Returns true if the event was handled. A pending style is discarded when the caret had moved + /// before the key (e.g. the selection was set from code) or the key moved it. /// public bool HandleKeyDown(EditorKeyEventArgs e, Document.Document document, DocumentLayout layout, IDrawingContext context) { + if (e.IsKeyDown) DiscardStalePendingStyles(document); bool handled = HandleKeyDownCore(e, document, layout, context); if (handled) DiscardStalePendingStyles(document); return handled; diff --git a/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs index 57b27dd..e1dd06f 100644 --- a/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs +++ b/tests/MintedTextEditor.Core.Tests/PendingStyleTests.cs @@ -233,6 +233,25 @@ public void PendingBold_BackspaceAtRunEnd_DiscardsPendingStyle() Assert.False(RunContaining(e.Doc, "X").Style.IsBold); } + [Theory] + [InlineData(EditorKey.Backspace)] + [InlineData(EditorKey.Delete)] + public void PendingBold_SelectionSetFromCode_ThenDeleteKey_DiscardsPendingStyle(EditorKey key) + { + var e = Setup(null, Para(("Hello World", TextStyle.Default))); + PlaceCaret(e, new DocumentPosition(0, 0, 5)); + Key(e, EditorKey.B, InputModifiers.Control); + + e.Ctrl.Selection.Set(new DocumentPosition(0, 0, 5), new DocumentPosition(0, 0, 8)); + e.Caret.MoveTo(new DocumentPosition(0, 0, 8)); + Key(e, key); + Assert.Null(e.Fmt.PendingStyle); + e.Ctrl.HandleTextInput("X", e.Doc); + + Assert.Equal("HelloXrld", e.Doc.GetText()); + Assert.False(RunContaining(e.Doc, "X").Style.IsBold); + } + [Fact] public void Toolbar_PendingBold_AfterCaretMoved_ShowsBoldInactive() {