Procházet zdrojové kódy

Fixes #4233 - CharMap rendering (#4255)

* Fixed almost all issues

* code comments

* fixed copilot suggestion

* Add Unicode filtering and improve context menu handling

Enabled nullable reference types for better null safety. Added a
Unicode category filter to `CharacterMap` via the new
`ShowUnicodeCategory` property and `OptionSelector`. Updated
rendering logic to dynamically manage visible rows based on the
filter, improving performance and usability.

Refactored menu items to include the Unicode category selector.
Enhanced `TextView` context menu handling to support mouse-based
positioning. Performed miscellaneous code cleanup and added
comments for improved readability and maintainability.

* Fix Unicode rendering and simplify CombiningMarks

Updated `RuneExtensions.GetColumns` to handle specific Unicode glyphs (I Ching symbols) rendered as double-width in Windows Terminal, despite being single-width per Unicode. Added a workaround to return `2` for these glyphs and fallback to `UnicodeCalculator.GetWidth` for others.

Simplified `CombiningMarks` by removing examples for Unicode characters `\u0600` and `\u0301`, streamlining the scenario.

Referenced PR #4255 for context on the workaround.

* Update RuneTests with new Unicode test cases and fixes

Added new test cases for Unicode characters U+d7b0 (ힰ) and
U+f61e () with expected parameters. Updated the test case
for U+4dc0 (䷀) to adjust the second parameter from 1 to 2
and added references to the Microsoft Terminal Unicode
width overrides file and GitHub issue #19389. Existing test
cases for other Unicode characters remain unchanged.

* Update Terminal.Gui/Views/CharMap/CharMap.cs

Co-authored-by: Copilot <[email protected]>

* Update comments in GetColumns method for clarity

Updated comments in the `GetColumns` method of the `RuneExtensions` class to replace "HACK" with "TODO" and reference issue #4259 instead of pull request #4255. This change clarifies that the code is a temporary measure and should be removed once the issue is resolved. No functional changes were made to the code logic.

---------

Co-authored-by: Copilot <[email protected]>
Tig před 2 měsíci
rodič
revize
4c6145cee9

+ 79 - 31
Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs

@@ -1,9 +1,6 @@
 #nullable enable
 
-using System;
-using System.Collections.Generic;
 using System.Globalization;
-using System.Linq;
 using System.Text;
 
 namespace UICatalog.Scenarios;
@@ -24,6 +21,33 @@ public class CharacterMap : Scenario
     private Label? _errorLabel;
     private TableView? _categoryList;
     private CharMap? _charMap;
+    private OptionSelector? _unicodeCategorySelector;
+
+    public override List<Key> GetDemoKeyStrokes ()
+    {
+        List<Key> keys = new ();
+
+        for (var i = 0; i < 200; i++)
+        {
+            keys.Add (Key.CursorDown);
+        }
+
+        // Category table
+        keys.Add (Key.Tab.WithShift);
+
+        // Block elements
+        keys.Add (Key.B);
+        keys.Add (Key.L);
+
+        keys.Add (Key.Tab);
+
+        for (var i = 0; i < 200; i++)
+        {
+            keys.Add (Key.CursorLeft);
+        }
+
+        return keys;
+    }
 
     // Don't create a Window, just return the top-level view
     public override void Main ()
@@ -39,9 +63,9 @@ public class CharacterMap : Scenario
         {
             X = 0,
             Y = 1,
-            Height = Dim.Fill (),
-           // SchemeName = "Base"
+            Height = Dim.Fill ()
 
+            // SchemeName = "Base"
         };
         top.Add (_charMap);
 
@@ -50,7 +74,8 @@ public class CharacterMap : Scenario
             X = Pos.Right (_charMap) + 1,
             Y = Pos.Y (_charMap),
             HotKeySpecifier = (Rune)'_',
-            Text = "_Jump To:",
+            Text = "_Jump To:"
+
             //SchemeName = "Dialog"
         };
         top.Add (jumpLabel);
@@ -60,7 +85,8 @@ public class CharacterMap : Scenario
             X = Pos.Right (jumpLabel) + 1,
             Y = Pos.Y (_charMap),
             Width = 17,
-            Caption = "e.g. 01BE3 or ✈",
+            Caption = "e.g. 01BE3 or ✈"
+
             //SchemeName = "Dialog"
         };
         top.Add (jumpEdit);
@@ -89,10 +115,12 @@ public class CharacterMap : Scenario
 
         jumpEdit.Accepting += JumpEditOnAccept;
 
-        _categoryList = new () { 
-            X = Pos.Right (_charMap), 
-            Y = Pos.Bottom (jumpLabel), 
-            Height = Dim.Fill (),
+        _categoryList = new ()
+        {
+            X = Pos.Right (_charMap),
+            Y = Pos.Bottom (jumpLabel),
+            Height = Dim.Fill ()
+
             //SchemeName = "Dialog"
         };
         _categoryList.FullRowSelect = true;
@@ -165,7 +193,7 @@ public class CharacterMap : Scenario
                     ),
                 new (
                      "_Options",
-                     new MenuItemv2 [] { CreateMenuShowWidth () }
+                     [CreateMenuShowWidth (), CreateMenuUnicodeCategorySelector ()]
                     )
             ]
         };
@@ -317,6 +345,7 @@ public class CharacterMap : Scenario
             CheckedState = _charMap!.ShowGlyphWidths ? CheckState.Checked : CheckState.None
         };
         var item = new MenuItemv2 { CommandView = cb };
+
         item.Action += () =>
                        {
                            if (_charMap is { })
@@ -328,29 +357,48 @@ public class CharacterMap : Scenario
         return item;
     }
 
-    public override List<Key> GetDemoKeyStrokes ()
+    private MenuItemv2 CreateMenuUnicodeCategorySelector ()
     {
-        List<Key> keys = new ();
-
-        for (var i = 0; i < 200; i++)
+        // First option is "All" (no filter), followed by all UnicodeCategory names
+        string [] allCategoryNames = Enum.GetNames<UnicodeCategory> ();
+        var options = new string [allCategoryNames.Length + 1];
+        options [0] = "All";
+        Array.Copy (allCategoryNames, 0, options, 1, allCategoryNames.Length);
+
+        // TODO: When #4126 is merged update this to use OptionSelector<UnicodeCategory?>
+        var selector = new OptionSelector
         {
-            keys.Add (Key.CursorDown);
-        }
-
-        // Category table
-        keys.Add (Key.Tab.WithShift);
+            AssignHotKeysToCheckBoxes = true,
+            Options = options
+        };
 
-        // Block elements
-        keys.Add (Key.B);
-        keys.Add (Key.L);
+        _unicodeCategorySelector = selector;
 
-        keys.Add (Key.Tab);
+        // Default to "All"
+        selector.SelectedItem = 0;
+        _charMap!.ShowUnicodeCategory = null;
 
-        for (var i = 0; i < 200; i++)
-        {
-            keys.Add (Key.CursorLeft);
-        }
-
-        return keys;
+        selector.SelectedItemChanged += (s, e) =>
+                                        {
+                                            int? idx = selector.SelectedItem;
+
+                                            if (idx is null)
+                                            {
+                                                return;
+                                            }
+
+                                            if (idx.Value == 0)
+                                            {
+                                                _charMap.ShowUnicodeCategory = null;
+                                            }
+                                            else
+                                            {
+                                                // Map index to UnicodeCategory (offset by 1 because 0 is "All")
+                                                UnicodeCategory cat = Enum.GetValues<UnicodeCategory> () [idx.Value - 1];
+                                                _charMap.ShowUnicodeCategory = cat;
+                                            }
+                                        };
+
+        return new() { CommandView = selector };
     }
 }

+ 72 - 16
Examples/UICatalog/Scenarios/CombiningMarks.cs

@@ -11,22 +11,78 @@ public class CombiningMarks : Scenario
         var top = new Toplevel ();
 
         top.DrawComplete += (s, e) =>
-                                   {
-                                       top.Move (0, 0);
-                                       top.AddStr ("Terminal.Gui only supports combining marks that normalize. See Issue #2616.");
-                                       top.Move (0, 2);
-                                       top.AddStr ("\u0301\u0301\u0328<- \"\\u301\\u301\\u328]\" using AddStr.");
-                                       top.Move (0, 3);
-                                       top.AddStr ("[a\u0301\u0301\u0328]<- \"[a\\u301\\u301\\u328]\" using AddStr.");
-                                       top.Move (0, 4);
-                                       top.AddRune ('[');
-                                       top.AddRune ('a');
-                                       top.AddRune ('\u0301');
-                                       top.AddRune ('\u0301');
-                                       top.AddRune ('\u0328');
-                                       top.AddRune (']');
-                                       top.AddStr ("<- \"[a\\u301\\u301\\u328]\" using AddRune for each.");
-                                   };
+        {
+            // Forces reset _lineColsOffset because we're dealing with direct draw
+            Application.ClearScreenNextIteration = true;
+
+            var i = -1;
+            top.AddStr ("Terminal.Gui only supports combining marks that normalize. See Issue #2616.");
+            top.Move (0, ++i);
+            top.AddStr ("\u0301<- \"\\u0301\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u0301]<- \"[\\u0301]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[ \u0301]<- \"[ \\u0301]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u0301 ]<- \"[\\u0301 ]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("\u0301\u0301\u0328<- \"\\u0301\\u0301\\u0328\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u0301\u0301\u0328]<- \"[\\u0301\\u0301\\u0328]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[a\u0301\u0301\u0328]<- \"[a\\u0301\\u0301\\u0328]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddRune ('[');
+            top.AddRune ('a');
+            top.AddRune ('\u0301');
+            top.AddRune ('\u0301');
+            top.AddRune ('\u0328');
+            top.AddRune (']');
+            top.AddStr ("<- \"[a\\u0301\\u0301\\u0328]\" using AddRune for each.");
+            top.Move (0, ++i);
+            top.AddStr ("[a\u0301\u0301\u0328]<- \"[a\\u0301\\u0301\\u0328]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[e\u0301\u0301\u0328]<- \"[e\\u0301\\u0301\\u0328]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[e\u0328\u0301]<- \"[e\\u0328\\u0301]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("\u00ad<- \"\\u00ad\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u00ad]<- \"[\\u00ad]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddRune ('[');
+            top.AddRune ('\u00ad');
+            top.AddRune (']');
+            top.AddStr ("<- \"[\\u00ad]\" using AddRune for each.");
+            i++;
+            top.Move (0, ++i);
+            top.AddStr ("From now on we are using TextFormatter");
+            TextFormatter tf = new () { Text = "[e\u0301\u0301\u0328]<- \"[e\\u0301\\u0301\\u0328]\" using TextFormatter." };
+            tf.Draw (new (0, ++i, tf.Text.Length, 1), top.GetAttributeForRole (VisualRole.Normal), top.GetAttributeForRole (VisualRole.Normal));
+            tf.Text = "[e\u0328\u0301]<- \"[e\\u0328\\u0301]\" using TextFormatter.";
+            tf.Draw (new (0, ++i, tf.Text.Length, 1), top.GetAttributeForRole (VisualRole.Normal), top.GetAttributeForRole (VisualRole.Normal));
+            i++;
+            top.Move (0, ++i);
+            top.AddStr ("From now on we are using Surrogate pairs with combining diacritics");
+            top.Move (0, ++i);
+            top.AddStr ("[\ud835\udc4b\u0302]<- \"[\\ud835\\udc4b\\u0302]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\ud83d\udc68\ud83e\uddd2]<- \"[\\ud83d\\udc68\\ud83e\\uddd2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("\u200d<- \"\\u200d\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\u200d]<- \"[\\u200d]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\ud83d\udc68\u200d\ud83e\uddd2]<- \"[\\ud83d\\udc68\\u200d\\ud83e\\uddd2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F469\U0001F9D2]<- \"[\\U0001F469\\U0001F9D2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F469\u200D\U0001F9D2]<- \"[\\U0001F469\\u200D\\U0001F9D2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F468\U0001F469\U0001F9D2]<- \"[\\U0001F468\\U0001F469\\U0001F9D2]\" using AddStr.");
+            top.Move (0, ++i);
+            top.AddStr ("[\U0001F468\u200D\U0001F469\u200D\U0001F9D2]<- \"[\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F9D2]\" using AddStr.");
+        };
 
         Application.Run (top);
         top.Dispose ();

+ 0 - 10
Terminal.Gui/App/Application.Initialization.cs

@@ -82,16 +82,6 @@ public static partial class Application // Initialization (Init/Shutdown)
         if (driver is { })
         {
             Driver = driver;
-
-            if (driver is FakeDriver)
-            {
-                //// We're running unit tests. Disable loading config files other than default
-                //if (Locations == ConfigLocations.All)
-                //{
-                //    Locations = ConfigLocations.Default;
-                //    ResetAllSettings ();
-                //}
-            }
         }
 
         // Ignore Configuration for ForceDriver if driverName is specified

+ 16 - 1
Terminal.Gui/Text/RuneExtensions.cs

@@ -111,7 +111,22 @@ public static class RuneExtensions
     ///     The number of columns required to fit the rune, 0 if the argument is the null character, or -1 if the value is
     ///     not printable, otherwise the number of columns that the rune occupies.
     /// </returns>
-    public static int GetColumns (this Rune rune) { return UnicodeCalculator.GetWidth (rune); }
+    public static int GetColumns (this Rune rune)
+    {
+        int value = rune.Value;
+
+        // TODO: Remove this code when #4259 is fixed
+        // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/4259
+        if (value is >= 0x2630 and <= 0x2637 ||  // Trigrams
+            value is >= 0x268A and <= 0x268F ||  // Monograms/Digrams
+            value is >= 0x4DC0 and <= 0x4DFF)    // Hexagrams
+        {
+            return 2; // Assume double-width due to Windows Terminal font rendering
+        }
+
+        // Fallback to original GetWidth for other code points
+        return UnicodeCalculator.GetWidth (rune);
+    }
 
     /// <summary>Get number of bytes required to encode the rune, based on the provided encoding.</summary>
     /// <remarks>This is a Terminal.Gui extension method to <see cref="System.Text.Rune"/> to support TUI text manipulation.</remarks>

+ 546 - 306
Terminal.Gui/Views/CharMap/CharMap.cs

@@ -1,4 +1,5 @@
 #nullable enable
+using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.Text.Json;
@@ -15,7 +16,9 @@ public class CharMap : View, IDesignable
 {
     private const int COLUMN_WIDTH = 3; // Width of each column of glyphs
     private const int HEADER_HEIGHT = 1; // Height of the header
-    private int _rowHeight = 1; // Height of each row of 16 glyphs - changing this is not tested
+
+    // ReSharper disable once InconsistentNaming
+    private static readonly int MAX_CODE_POINT = UnicodeRange.Ranges.Max (r => r.End);
 
     /// <summary>
     ///     Initializes a new instance.
@@ -64,7 +67,8 @@ public class CharMap : View, IDesignable
         MouseBindings.Add (MouseFlags.WheeledLeft, Command.ScrollLeft);
         MouseBindings.Add (MouseFlags.WheeledRight, Command.ScrollRight);
 
-        SetContentSize (new (COLUMN_WIDTH * 16 + RowLabelWidth, MAX_CODE_POINT / 16 * _rowHeight + HEADER_HEIGHT));
+        // Initial content size; height will be corrected by RebuildVisibleRows()
+        SetContentSize (new (COLUMN_WIDTH * 16 + RowLabelWidth, HEADER_HEIGHT + _rowHeight));
 
         // Set up the horizontal scrollbar. Turn off AutoShow since we do it manually.
         HorizontalScrollBar.AutoShow = false;
@@ -100,18 +104,190 @@ public class CharMap : View, IDesignable
         // The scrollbars are in the Padding. VisualRole.Focus/Active are used to draw the
         // CharMap headers. Override Padding to force it to draw to match.
         Padding!.GettingAttributeForRole += PaddingOnGettingAttributeForRole;
+
+        // Build initial visible rows (all rows with at least one valid codepoint)
+        RebuildVisibleRows ();
     }
 
-    private void PaddingOnGettingAttributeForRole (object? sender, VisualRoleEventArgs e)
+    // Visible rows management: each entry is the starting code point of a 16-wide row
+    private readonly List<int> _visibleRowStarts = new ();
+    private readonly Dictionary<int, int> _rowStartToVisibleIndex = new ();
+
+    private void RebuildVisibleRows ()
     {
-        if (e.Role != VisualRole.Focus && e.Role != VisualRole.Active)
+        _visibleRowStarts.Clear ();
+        _rowStartToVisibleIndex.Clear ();
+
+        int maxRow = MAX_CODE_POINT / 16;
+
+        for (var row = 0; row <= maxRow; row++)
         {
-            e.Result = GetAttributeForRole (HasFocus ? VisualRole.Focus : VisualRole.Active);
+            int start = row * 16;
+            bool anyValid = false;
+            bool anyVisible = false;
+
+            for (var col = 0; col < 16; col++)
+            {
+                int cp = start + col;
+                if (cp > RuneExtensions.MaxUnicodeCodePoint)
+                {
+                    break;
+                }
+
+                if (!Rune.IsValid (cp))
+                {
+                    continue;
+                }
+
+                anyValid = true;
+
+                if (!ShowUnicodeCategory.HasValue)
+                {
+                    // With no filter, a row is displayed if it has any valid codepoint
+                    anyVisible = true;
+                    break;
+                }
+
+                var rune = new Rune (cp);
+                Span<char> utf16 = new char [2];
+                rune.EncodeToUtf16 (utf16);
+                UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (utf16 [0]);
+                if (cat == ShowUnicodeCategory.Value)
+                {
+                    anyVisible = true;
+                    break;
+                }
+            }
+
+            if (anyValid && (!ShowUnicodeCategory.HasValue ? anyValid : anyVisible))
+            {
+                _rowStartToVisibleIndex [start] = _visibleRowStarts.Count;
+                _visibleRowStarts.Add (start);
+            }
         }
 
-        e.Handled = true;
+        // Update content size to match visible rows
+        SetContentSize (new (COLUMN_WIDTH * 16 + RowLabelWidth, _visibleRowStarts.Count * _rowHeight + HEADER_HEIGHT));
+
+        // Keep vertical scrollbar aligned with new content size
+        VerticalScrollBar.ScrollableContentSize = GetContentSize ().Height;
     }
 
+    private int VisibleRowIndexForCodePoint (int codePoint)
+    {
+        int start = (codePoint / 16) * 16;
+        return _rowStartToVisibleIndex.GetValueOrDefault (start, -1);
+    }
+
+    private int _rowHeight = 1; // Height of each row of 16 glyphs - changing this is not tested
+    private int _selectedCodepoint; // Currently selected codepoint
+    private int _startCodepoint; // The codepoint that will be displayed at the top of the Viewport
+
+    /// <summary>
+    ///     Gets or sets the currently selected codepoint. Causes the Viewport to scroll to make the selected code point
+    ///     visible.
+    /// </summary>
+    public int SelectedCodePoint
+    {
+        get => _selectedCodepoint;
+        set
+        {
+            if (_selectedCodepoint == value)
+            {
+                return;
+            }
+
+            int newSelectedCodePoint = Math.Clamp (value, 0, MAX_CODE_POINT);
+
+            Point offsetToNewCursor = GetCursor (newSelectedCodePoint);
+
+            _selectedCodepoint = newSelectedCodePoint;
+
+            // Ensure the new cursor position is visible
+            ScrollToMakeCursorVisible (offsetToNewCursor);
+
+            SetNeedsDraw ();
+            SelectedCodePointChanged?.Invoke (this, new (SelectedCodePoint));
+        }
+    }
+
+    /// <summary>
+    ///     Raised when the selected code point changes.
+    /// </summary>
+    public event EventHandler<EventArgs<int>>? SelectedCodePointChanged;
+
+    /// <summary>
+    ///     Gets or sets whether the number of columns each glyph is displayed.
+    /// </summary>
+    public bool ShowGlyphWidths
+    {
+        get => _rowHeight == 2;
+        set
+        {
+            _rowHeight = value ? 2 : 1;
+            // height changed => content height depends on row height
+            RebuildVisibleRows ();
+            SetNeedsDraw ();
+        }
+    }
+
+    /// <summary>
+    ///     Specifies the starting offset for the character map. The default is 0x2500 which is the Box Drawing
+    ///     characters.
+    /// </summary>
+    public int StartCodePoint
+    {
+        get => _startCodepoint;
+        set
+        {
+            _startCodepoint = value;
+            SelectedCodePoint = value;
+        }
+    }
+
+
+    private UnicodeCategory? _showUnicodeCategory;
+
+    /// <summary>
+    ///     When set, only glyphs whose UnicodeCategory matches the value are rendered. If <see langword="null"/> (default),
+    ///     all glyphs are rendered.
+    /// </summary>
+    public UnicodeCategory? ShowUnicodeCategory
+    {
+        get => _showUnicodeCategory;
+        set
+        {
+            if (_showUnicodeCategory == value)
+            {
+                return;
+            }
+
+            _showUnicodeCategory = value;
+            RebuildVisibleRows ();
+
+            // Ensure selection is on a visible row
+            int desiredRowStart = (SelectedCodePoint / 16) * 16;
+            if (!_rowStartToVisibleIndex.ContainsKey (desiredRowStart))
+            {
+                // Find nearest visible row (prefer next; fallback to last)
+                int idx = _visibleRowStarts.FindIndex (s => s >= desiredRowStart);
+                if (idx < 0 && _visibleRowStarts.Count > 0)
+                {
+                    idx = _visibleRowStarts.Count - 1;
+                }
+                if (idx >= 0)
+                {
+                    SelectedCodePoint = _visibleRowStarts [idx];
+                }
+            }
+
+            SetNeedsDraw ();
+        }
+    }
+
+    private void CopyCodePoint () { Clipboard.Contents = $"U+{SelectedCodePoint:x5}"; }
+    private void CopyGlyph () { Clipboard.Contents = $"{new Rune (SelectedCodePoint)}"; }
+
     private bool? Move (ICommandContext? commandContext, int cpOffset)
     {
         if (RaiseSelecting (commandContext) is true)
@@ -124,6 +300,16 @@ public class CharMap : View, IDesignable
         return true;
     }
 
+    private void PaddingOnGettingAttributeForRole (object? sender, VisualRoleEventArgs e)
+    {
+        if (e.Role != VisualRole.Focus && e.Role != VisualRole.Active)
+        {
+            e.Result = GetAttributeForRole (HasFocus ? VisualRole.Focus : VisualRole.Active);
+        }
+
+        e.Handled = true;
+    }
+
     private void ScrollToMakeCursorVisible (Point offsetToNewCursor)
     {
         // Adjust vertical scrolling
@@ -147,107 +333,246 @@ public class CharMap : View, IDesignable
         }
     }
 
-    #region Cursor
+    #region Details Dialog
 
-    private Point GetCursor (int codePoint)
+    [RequiresUnreferencedCode ("AOT")]
+    [RequiresDynamicCode ("AOT")]
+    private void ShowDetails ()
     {
-        // + 1 for padding between label and first column
-        int x = codePoint % 16 * COLUMN_WIDTH + RowLabelWidth + 1 - Viewport.X;
-        int y = codePoint / 16 * _rowHeight + HEADER_HEIGHT - Viewport.Y;
+        if (!Application.Initialized)
+        {
+            // Some unit tests invoke Accept without Init
+            return;
+        }
 
-        return new (x, y);
-    }
+        UcdApiClient? client = new ();
+        var decResponse = string.Empty;
+        var getCodePointError = string.Empty;
 
-    /// <inheritdoc/>
-    public override Point? PositionCursor ()
-    {
-        Point cursor = GetCursor (SelectedCodePoint);
+        Dialog? waitIndicator = new ()
+        {
+            Title = Strings.charMapCPInfoDlgTitle,
+            X = Pos.Center (),
+            Y = Pos.Center (),
+            Width = 40,
+            Height = 10,
+            Buttons = [new () { Text = Strings.btnCancel }]
+        };
 
-        if (HasFocus
-            && cursor.X >= RowLabelWidth
-            && cursor.X < Viewport.Width
-            && cursor.Y > 0
-            && cursor.Y < Viewport.Height)
+        var errorLabel = new Label
         {
-            Move (cursor.X, cursor.Y);
+            Text = UcdApiClient.BaseUrl,
+            X = 0,
+            Y = 0,
+            Width = Dim.Fill (),
+            Height = Dim.Fill (3),
+            TextAlignment = Alignment.Center
+        };
+
+        var spinner = new SpinnerView
+        {
+            X = Pos.Center (),
+            Y = Pos.Bottom (errorLabel),
+            Style = new SpinnerStyle.Aesthetic ()
+        };
+        spinner.AutoSpin = true;
+        waitIndicator.Add (errorLabel);
+        waitIndicator.Add (spinner);
+
+        waitIndicator.Ready += async (s, a) =>
+                               {
+                                   try
+                                   {
+                                       decResponse = await client.GetCodepointDec (SelectedCodePoint).ConfigureAwait (false);
+                                       Application.Invoke (() => waitIndicator.RequestStop ());
+                                   }
+                                   catch (HttpRequestException e)
+                                   {
+                                       getCodePointError = errorLabel.Text = e.Message;
+                                       Application.Invoke (() => waitIndicator.RequestStop ());
+                                   }
+                               };
+        Application.Run (waitIndicator);
+        waitIndicator.Dispose ();
+
+        var name = string.Empty;
+
+        if (!string.IsNullOrEmpty (decResponse))
+        {
+            using JsonDocument document = JsonDocument.Parse (decResponse);
+
+            JsonElement root = document.RootElement;
+
+            // Get a property by name and output its value
+            if (root.TryGetProperty ("name", out JsonElement nameElement))
+            {
+                name = nameElement.GetString ();
+            }
+
+            decResponse = JsonSerializer.Serialize (
+                                                    document.RootElement,
+                                                    new
+                                                        JsonSerializerOptions
+                                                    { WriteIndented = true }
+                                                   );
         }
         else
         {
-            return null;
+            decResponse = getCodePointError;
         }
 
-        return cursor;
-    }
+        var title = $"{ToCamelCase (name!)} - {new Rune (SelectedCodePoint)} U+{SelectedCodePoint:x5}";
 
-    #endregion Cursor
+        Button? copyGlyph = new () { Text = Strings.charMapCopyGlyph };
+        Button? copyCodepoint = new () { Text = Strings.charMapCopyCP };
+        Button? cancel = new () { Text = Strings.btnCancel };
 
-    // ReSharper disable once InconsistentNaming
-    private static readonly int MAX_CODE_POINT = UnicodeRange.Ranges.Max (r => r.End);
-    private int _selectedCodepoint; // Currently selected codepoint
-    private int _startCodepoint; // The codepoint that will be displayed at the top of the Viewport
+        var dlg = new Dialog { Title = title, Buttons = [copyGlyph, copyCodepoint, cancel] };
 
-    /// <summary>
-    ///     Gets or sets the currently selected codepoint. Causes the Viewport to scroll to make the selected code point
-    ///     visible.
-    /// </summary>
-    public int SelectedCodePoint
-    {
-        get => _selectedCodepoint;
-        set
-        {
-            if (_selectedCodepoint == value)
-            {
-                return;
-            }
+        copyGlyph.Accepting += (s, a) =>
+                               {
+                                   CopyGlyph ();
+                                   dlg!.RequestStop ();
+                                   a.Handled = true;
+                               };
 
-            int newSelectedCodePoint = Math.Clamp (value, 0, MAX_CODE_POINT);
+        copyCodepoint.Accepting += (s, a) =>
+                                   {
+                                       CopyCodePoint ();
+                                       dlg!.RequestStop ();
+                                       a.Handled = true;
+                                   };
 
-            Point offsetToNewCursor = GetCursor (newSelectedCodePoint);
+        cancel.Accepting += (s, a) =>
+                            {
+                                dlg!.RequestStop ();
+                                a.Handled = true;
+                            };
+
+        var rune = (Rune)SelectedCodePoint;
+        var label = new Label { Text = "IsAscii: ", X = 0, Y = 0 };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.IsAscii}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = ", Bmp: ", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.IsBmp}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = ", CombiningMark: ", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.IsCombiningMark ()}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = ", SurrogatePair: ", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.IsSurrogatePair ()}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = ", Plane: ", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.Plane}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = "Columns: ", X = 0, Y = Pos.Bottom (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.GetColumns ()}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = ", Utf16SequenceLength: ", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = $"{rune.Utf16SequenceLength}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new () { Text = "Category: ", X = 0, Y = Pos.Bottom (label) };
+        dlg.Add (label);
+        Span<char> utf16 = stackalloc char [2];
+        int charCount = rune.EncodeToUtf16 (utf16);
+
+        // Get the bidi class for the first code unit
+        // For most bidi characters, the first code unit is sufficient
+        UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory (utf16 [0]);
+
+        label = new () { Text = $"{category}", X = Pos.Right (label), Y = Pos.Top (label) };
+        dlg.Add (label);
+
+        label = new ()
+        {
+            Text =
+                $"{Strings.charMapInfoDlgInfoLabel} {UcdApiClient.BaseUrl}codepoint/dec/{SelectedCodePoint}:",
+            X = 0,
+            Y = Pos.Bottom (label)
+        };
+        dlg.Add (label);
 
-            _selectedCodepoint = newSelectedCodePoint;
+        var json = new TextView
+        {
+            X = 0,
+            Y = Pos.Bottom (label),
+            Width = Dim.Fill (),
+            Height = Dim.Fill (2),
+            ReadOnly = true,
+            Text = decResponse
+        };
 
-            // Ensure the new cursor position is visible
-            ScrollToMakeCursorVisible (offsetToNewCursor);
+        dlg.Add (json);
 
-            SetNeedsDraw ();
-            SelectedCodePointChanged?.Invoke (this, new (SelectedCodePoint));
-        }
+        Application.Run (dlg);
+        dlg.Dispose ();
     }
 
-    /// <summary>
-    ///     Raised when the selected code point changes.
-    /// </summary>
-    public event EventHandler<EventArgs<int>>? SelectedCodePointChanged;
+    #endregion Details Dialog
 
-    /// <summary>
-    ///     Specifies the starting offset for the character map. The default is 0x2500 which is the Box Drawing
-    ///     characters.
-    /// </summary>
-    public int StartCodePoint
+    #region Cursor
+
+    private Point GetCursor (int codePoint)
     {
-        get => _startCodepoint;
-        set
+        // + 1 for padding between label and first column
+        int x = codePoint % 16 * COLUMN_WIDTH + RowLabelWidth + 1 - Viewport.X;
+
+        int visibleRowIndex = VisibleRowIndexForCodePoint (codePoint);
+        if (visibleRowIndex < 0)
         {
-            _startCodepoint = value;
-            SelectedCodePoint = value;
+            // If filtered out, stick to current Y to avoid jumping; caller will clamp
+            int fallbackY = HEADER_HEIGHT - Viewport.Y;
+            return new (x, fallbackY);
         }
+
+        int y = visibleRowIndex * _rowHeight + HEADER_HEIGHT - Viewport.Y;
+
+        return new (x, y);
     }
 
-    /// <summary>
-    ///     Gets or sets whether the number of columns each glyph is displayed.
-    /// </summary>
-    public bool ShowGlyphWidths
+    /// <inheritdoc/>
+    public override Point? PositionCursor ()
     {
-        get => _rowHeight == 2;
-        set
+        Point cursor = GetCursor (SelectedCodePoint);
+
+        if (HasFocus
+            && cursor.X >= RowLabelWidth
+            && cursor.X < Viewport.Width
+            && cursor.Y > 0
+            && cursor.Y < Viewport.Height)
         {
-            _rowHeight = value ? 2 : 1;
-            SetNeedsDraw ();
+            Move (cursor.X, cursor.Y);
+        }
+        else
+        {
+            return null;
         }
+
+        return cursor;
     }
 
-    private void CopyCodePoint () { Clipboard.Contents = $"U+{SelectedCodePoint:x5}"; }
-    private void CopyGlyph () { Clipboard.Contents = $"{new Rune (SelectedCodePoint)}"; }
+    #endregion Cursor
 
     #region Drawing
 
@@ -262,7 +587,7 @@ public class CharMap : View, IDesignable
         }
 
         int selectedCol = SelectedCodePoint % 16;
-        int selectedRow = SelectedCodePoint / 16;
+        int selectedRowIndex = VisibleRowIndexForCodePoint (SelectedCodePoint);
 
         // Headers
 
@@ -302,32 +627,33 @@ public class CharMap : View, IDesignable
         // Start at 1 because Header.
         for (var y = 1; y < Viewport.Height; y++)
         {
-            // What row is this?
-            int row = (y + Viewport.Y - 1) / _rowHeight;
-            int val = row * 16;
+            // Which visible row is this?
+            int visibleRow = (y + Viewport.Y - 1) / _rowHeight;
+
+            if (visibleRow < 0 || visibleRow >= _visibleRowStarts.Count)
+            {
+                // No row at this y; clear label area and continue
+                Move (0, y);
+                AddStr (new (' ', Viewport.Width));
+
+                continue;
+            }
+
+            int rowStart = _visibleRowStarts [visibleRow];
 
             // Draw the row label (U+XXXX_)
             SetAttributeForRole (HasFocus ? VisualRole.Focus : VisualRole.Active);
             Move (0, y);
 
             // Swap Active/Focus so the selected row is highlighted
-            if (y + Viewport.Y - 1 == selectedRow)
+            if (visibleRow == selectedRowIndex)
             {
                 SetAttributeForRole (HasFocus ? VisualRole.Active : VisualRole.Focus);
             }
 
-            if (val > MAX_CODE_POINT)
-            {
-                // No row
-                Move (0, y);
-                AddStr (new (' ', RowLabelWidth));
-
-                continue;
-            }
-
             if (!ShowGlyphWidths || (y + Viewport.Y) % _rowHeight > 0)
             {
-                AddStr ($"U+{val / 16:x5}_");
+                AddStr ($"U+{rowStart / 16:x5}_");
             }
             else
             {
@@ -349,12 +675,24 @@ public class CharMap : View, IDesignable
                 Move (x, y);
 
                 // If we're at the cursor position highlight the cell
-                if (row == selectedRow && col == selectedCol)
+                if (visibleRow == selectedRowIndex && col == selectedCol)
                 {
                     SetAttributeForRole (VisualRole.Active);
                 }
 
-                int scalar = val + col;
+                int scalar = rowStart + col;
+
+                // Don't render out-of-range scalars
+                if (scalar > MAX_CODE_POINT)
+                {
+                    AddRune (' ');
+                    if (visibleRow == selectedRowIndex && col == selectedCol)
+                    {
+                        SetAttributeForRole (VisualRole.Normal);
+                    }
+                    continue;
+                }
+
                 var rune = (Rune)'?';
 
                 if (Rune.IsValid (scalar))
@@ -364,9 +702,88 @@ public class CharMap : View, IDesignable
 
                 int width = rune.GetColumns ();
 
+                // Compute visibility based on ShowUnicodeCategory
+                bool isVisible = Rune.IsValid (scalar);
+                if (isVisible && ShowUnicodeCategory.HasValue)
+                {
+                    Span<char> filterUtf16 = new char [2];
+                    rune.EncodeToUtf16 (filterUtf16);
+                    UnicodeCategory cat = CharUnicodeInfo.GetUnicodeCategory (filterUtf16 [0]);
+                    isVisible = cat == ShowUnicodeCategory.Value;
+                }
+
                 if (!ShowGlyphWidths || (y + Viewport.Y) % _rowHeight > 0)
                 {
-                    // Draw the rune
+                    // Glyph row
+                    if (isVisible)
+                    {
+                        RenderRune (rune, width);
+                    }
+                    else
+                    {
+                        AddRune (' ');
+                    }
+                }
+                else
+                {
+                    // Width row (ShowGlyphWidths)
+                    if (isVisible)
+                    {
+                        // Draw the width of the rune faint
+                        Attribute attr = GetAttributeForRole (VisualRole.Normal);
+                        SetAttribute (attr with { Style = attr.Style | TextStyle.Faint });
+                        AddStr ($"{width}");
+                    }
+                    else
+                    {
+                        AddRune (' ');
+                    }
+                }
+
+                // If we're at the cursor position, and we don't have focus
+                if (visibleRow == selectedRowIndex && col == selectedCol)
+                {
+                    SetAttributeForRole (VisualRole.Normal);
+                }
+            }
+        }
+
+        return true;
+
+        void RenderRune (Rune rune, int width)
+        {
+            // Get the UnicodeCategory
+            Span<char> utf16 = new char [2];
+            int charCount = rune.EncodeToUtf16 (utf16);
+
+            // Get the bidi class for the first code unit
+            // For most bidi characters, the first code unit is sufficient
+            UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory (utf16 [0]);
+
+            switch (category)
+            {
+                case UnicodeCategory.OtherNotAssigned:
+                    SetAttributeForRole (VisualRole.Highlight);
+                    AddRune (Rune.ReplacementChar);
+                    SetAttributeForRole (VisualRole.Normal);
+
+                    break;
+
+                // Format character that affects the layout of text or the operation of text processes, but is not normally rendered. 
+                // These report width of 0 and don't render on their own.
+                case UnicodeCategory.Format:
+                    SetAttributeForRole (VisualRole.Highlight);
+                    AddRune ('F');
+                    SetAttributeForRole (VisualRole.Normal);
+
+                    break;
+
+                // Nonspacing character that indicates modifications of a base character.
+                case UnicodeCategory.NonSpacingMark:
+                // Spacing character that indicates modifications of a base character and affects the width of the glyph for that base character. 
+                case UnicodeCategory.SpacingCombiningMark:
+                // Enclosing mark character, which is a nonspacing combining character that surrounds all previous characters up to and including a base character.
+                case UnicodeCategory.EnclosingMark:
                     if (width > 0)
                     {
                         AddRune (rune);
@@ -394,28 +811,39 @@ public class CharMap : View, IDesignable
                             }
                             else
                             {
-                                AddRune (Rune.ReplacementChar);
+                                SetAttributeForRole (VisualRole.Highlight);
+                                AddRune ('M');
+                                SetAttributeForRole (VisualRole.Normal);
                             }
                         }
                     }
-                }
-                else
-                {
-                    // Draw the width of the rune faint
-                    Attribute attr = GetAttributeForRole (VisualRole.Normal);
-                    SetAttribute (attr with { Style = attr.Style | TextStyle.Faint });
-                    AddStr ($"{width}");
-                }
 
-                // If we're at the cursor position, and we don't have focus
-                if (row == selectedRow && col == selectedCol)
-                {
-                    SetAttributeForRole (VisualRole.Normal);
-                }
+                    break;
+
+                // These report width of 0, but render as 1
+                case UnicodeCategory.Control:
+                case UnicodeCategory.LineSeparator:
+                case UnicodeCategory.ParagraphSeparator:
+                case UnicodeCategory.Surrogate:
+                    AddRune (rune);
+
+                    break;
+
+                default:
+
+                    // Draw the rune
+                    if (width > 0)
+                    {
+                        AddRune (rune);
+                    }
+                    else
+                    {
+                        throw new InvalidOperationException ($"The Rune \"{rune}\" (U+{rune.Value:x6}) has zero width and no special-case UnicodeCategory logic applies.");
+                    }
+
+                    break;
             }
         }
-
-        return true;
     }
 
     /// <summary>
@@ -560,7 +988,14 @@ public class CharMap : View, IDesignable
             return false;
         }
 
-        int row = (position.Y - 1 - -Viewport.Y) / _rowHeight; // -1 for header
+        int visibleRow = (position.Y - 1 - -Viewport.Y) / _rowHeight;
+
+        if (visibleRow < 0 || visibleRow >= _visibleRowStarts.Count)
+        {
+            codePoint = 0;
+            return false;
+        }
+
         int col = (position.X - RowLabelWidth - -Viewport.X) / COLUMN_WIDTH;
 
         if (col > 15)
@@ -568,7 +1003,7 @@ public class CharMap : View, IDesignable
             col = 15;
         }
 
-        codePoint = row * 16 + col;
+        codePoint = _visibleRowStarts [visibleRow] + col;
 
         if (codePoint > MAX_CODE_POINT)
         {
@@ -579,199 +1014,4 @@ public class CharMap : View, IDesignable
     }
 
     #endregion Mouse Handling
-
-    #region Details Dialog
-
-    [RequiresUnreferencedCode ("AOT")]
-    [RequiresDynamicCode ("AOT")]
-    private void ShowDetails ()
-    {
-        if (!Application.Initialized)
-        {
-            // Some unit tests invoke Accept without Init
-            return;
-        }
-
-        UcdApiClient? client = new ();
-        var decResponse = string.Empty;
-        var getCodePointError = string.Empty;
-
-        Dialog? waitIndicator = new ()
-        {
-            Title = Strings.charMapCPInfoDlgTitle,
-            X = Pos.Center (),
-            Y = Pos.Center (),
-            Width = 40,
-            Height = 10,
-            Buttons = [new () { Text = Strings.btnCancel }]
-        };
-
-        var errorLabel = new Label
-        {
-            Text = UcdApiClient.BaseUrl,
-            X = 0,
-            Y = 0,
-            Width = Dim.Fill (),
-            Height = Dim.Fill (3),
-            TextAlignment = Alignment.Center
-        };
-
-        var spinner = new SpinnerView
-        {
-            X = Pos.Center (),
-            Y = Pos.Bottom (errorLabel),
-            Style = new SpinnerStyle.Aesthetic ()
-        };
-        spinner.AutoSpin = true;
-        waitIndicator.Add (errorLabel);
-        waitIndicator.Add (spinner);
-
-        waitIndicator.Ready += async (s, a) =>
-                               {
-                                   try
-                                   {
-                                       decResponse = await client.GetCodepointDec (SelectedCodePoint).ConfigureAwait (false);
-                                       Application.Invoke (() => waitIndicator.RequestStop ());
-                                   }
-                                   catch (HttpRequestException e)
-                                   {
-                                       getCodePointError = errorLabel.Text = e.Message;
-                                       Application.Invoke (() => waitIndicator.RequestStop ());
-                                   }
-                               };
-        Application.Run (waitIndicator);
-        waitIndicator.Dispose ();
-
-        if (!string.IsNullOrEmpty (decResponse))
-        {
-            var name = string.Empty;
-
-            using (JsonDocument document = JsonDocument.Parse (decResponse))
-            {
-                JsonElement root = document.RootElement;
-
-                // Get a property by name and output its value
-                if (root.TryGetProperty ("name", out JsonElement nameElement))
-                {
-                    name = nameElement.GetString ();
-                }
-
-                //// Navigate to a nested property and output its value
-                //if (root.TryGetProperty ("property3", out JsonElement property3Element)
-                //&& property3Element.TryGetProperty ("nestedProperty", out JsonElement nestedPropertyElement)) {
-                //	Console.WriteLine (nestedPropertyElement.GetString ());
-                //}
-                decResponse = JsonSerializer.Serialize (
-                                                        document.RootElement,
-                                                        new
-                                                            JsonSerializerOptions
-                                                            { WriteIndented = true }
-                                                       );
-            }
-
-            var title = $"{ToCamelCase (name!)} - {new Rune (SelectedCodePoint)} U+{SelectedCodePoint:x5}";
-
-            Button? copyGlyph = new () { Text = Strings.charMapCopyGlyph };
-            Button? copyCodepoint = new () { Text = Strings.charMapCopyCP };
-            Button? cancel = new () { Text = Strings.btnCancel };
-
-            var dlg = new Dialog { Title = title, Buttons = [copyGlyph, copyCodepoint, cancel] };
-
-            copyGlyph.Accepting += (s, a) =>
-                                   {
-                                       CopyGlyph ();
-                                       dlg!.RequestStop ();
-                                       a.Handled = true;
-                                   };
-
-            copyCodepoint.Accepting += (s, a) =>
-                                       {
-                                           CopyCodePoint ();
-                                           dlg!.RequestStop ();
-                                           a.Handled = true;
-                                       };
-            cancel.Accepting += (s, a) =>
-                                {
-                                    dlg!.RequestStop ();
-                                    a.Handled = true;
-                                };
-
-            var rune = (Rune)SelectedCodePoint;
-            var label = new Label { Text = "IsAscii: ", X = 0, Y = 0 };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.IsAscii}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = ", Bmp: ", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.IsBmp}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = ", CombiningMark: ", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.IsCombiningMark ()}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = ", SurrogatePair: ", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.IsSurrogatePair ()}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = ", Plane: ", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.Plane}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = "Columns: ", X = 0, Y = Pos.Bottom (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.GetColumns ()}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = ", Utf16SequenceLength: ", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new () { Text = $"{rune.Utf16SequenceLength}", X = Pos.Right (label), Y = Pos.Top (label) };
-            dlg.Add (label);
-
-            label = new ()
-            {
-                Text =
-                    $"{Strings.charMapInfoDlgInfoLabel} {UcdApiClient.BaseUrl}codepoint/dec/{SelectedCodePoint}:",
-                X = 0,
-                Y = Pos.Bottom (label)
-            };
-            dlg.Add (label);
-
-            var json = new TextView
-            {
-                X = 0,
-                Y = Pos.Bottom (label),
-                Width = Dim.Fill (),
-                Height = Dim.Fill (2),
-                ReadOnly = true,
-                Text = decResponse
-            };
-
-            dlg.Add (json);
-
-            Application.Run (dlg);
-            dlg.Dispose ();
-        }
-        else
-        {
-            MessageBox.ErrorQuery (
-                                   Strings.error,
-                                   $"{UcdApiClient.BaseUrl}codepoint/dec/{SelectedCodePoint} {Strings.failedGetting}{Environment.NewLine}{new Rune (SelectedCodePoint)} U+{SelectedCodePoint:x5}.",
-                                   Strings.btnOk
-                                  );
-        }
-    }
-
-    #endregion Details Dialog
 }

+ 8 - 10
Terminal.Gui/Views/TextInput/TextView.cs

@@ -515,7 +515,7 @@ public class TextView : View, IDesignable
                     Command.Context,
                     () =>
                     {
-                        ShowContextMenu (true);
+                        ShowContextMenu (null);
 
                         return true;
                     }
@@ -1745,13 +1745,7 @@ public class TextView : View, IDesignable
         }
         else if (ev.Flags == ContextMenu!.MouseFlags)
         {
-            ContextMenu!.X = ev.ScreenPosition.X;
-            ContextMenu!.Y = ev.ScreenPosition.Y;
-
-            ShowContextMenu (false);
-
-            //ContextMenu.Position = ViewportToScreen ((Viewport with { X = ev.Position.X, Y = ev.Position.Y }).Location);
-            //ShowContextMenu ();
+            ShowContextMenu (ev.ScreenPosition);
         }
 
         OnUnwrappedCursorPosition ();
@@ -4574,14 +4568,18 @@ public class TextView : View, IDesignable
         }
     }
 
-    private void ShowContextMenu (bool keyboard)
+    private void ShowContextMenu (Point? mousePosition)
     {
         if (!Equals (_currentCulture, Thread.CurrentThread.CurrentUICulture))
         {
             _currentCulture = Thread.CurrentThread.CurrentUICulture;
         }
 
-        ContextMenu?.MakeVisible (ViewportToScreen (new Point (CursorPosition.X, CursorPosition.Y)));
+        if (mousePosition is null)
+        {
+            mousePosition = ViewportToScreen (new Point (CursorPosition.X, CursorPosition.Y));
+        }
+        ContextMenu?.MakeVisible (mousePosition);
     }
 
     private void StartSelecting ()

+ 4 - 2
Tests/UnitTestsParallelizable/Text/RuneTests.cs

@@ -222,10 +222,12 @@ public class RuneTests
     [InlineData (
                     '\u4dc0',
                     "䷀",
-                    1,
+                    2,
                     1,
                     3
-                )] // ䷀Hexagram For The Creative Heaven -  U+4dc0 - https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
+                )]  // ䷀Hexagram For The Creative Heaven -  U+4dc0 - https://github.com/microsoft/terminal/blob/main/src/types/unicode_width_overrides.xml
+                    // See https://github.com/microsoft/terminal/issues/19389
+
     [InlineData ('\ud7b0', "ힰ", 1, 1, 3)] // ힰ ┤Hangul Jungseong O-Yeo - ힰ U+d7b0')]
     [InlineData ('\uf61e', "", 1, 1, 3)] // Private Use Area
     [InlineData ('\u23f0', "⏰", 2, 1, 3)] // Alarm Clock - ⏰ U+23f0