From 4c6145cee9a414a0ecc52d91f0a8b726ffa04ed5 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 27 Sep 2025 20:26:44 +0100 Subject: [PATCH] Fixes #4233 - CharMap rendering (#4255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 <175728472+Copilot@users.noreply.github.com> * 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 <175728472+Copilot@users.noreply.github.com> --- .../Scenarios/CharacterMap/CharacterMap.cs | 104 ++- .../UICatalog/Scenarios/CombiningMarks.cs | 88 +- .../App/Application.Initialization.cs | 10 - Terminal.Gui/Text/RuneExtensions.cs | 17 +- Terminal.Gui/Views/CharMap/CharMap.cs | 858 +++++++++++------- Terminal.Gui/Views/TextInput/TextView.cs | 18 +- .../UnitTestsParallelizable/Text/RuneTests.cs | 6 +- 7 files changed, 725 insertions(+), 376 deletions(-) diff --git a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs b/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs index 5a7872c33..26eb08072 100644 --- a/Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs +++ b/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 GetDemoKeyStrokes () + { + List 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 GetDemoKeyStrokes () + private MenuItemv2 CreateMenuUnicodeCategorySelector () { - List keys = new (); + // First option is "All" (no filter), followed by all UnicodeCategory names + string [] allCategoryNames = Enum.GetNames (); + var options = new string [allCategoryNames.Length + 1]; + options [0] = "All"; + Array.Copy (allCategoryNames, 0, options, 1, allCategoryNames.Length); - for (var i = 0; i < 200; i++) + // TODO: When #4126 is merged update this to use OptionSelector + var selector = new OptionSelector { - keys.Add (Key.CursorDown); - } + AssignHotKeysToCheckBoxes = true, + Options = options + }; - // Category table - keys.Add (Key.Tab.WithShift); + _unicodeCategorySelector = selector; - // Block elements - keys.Add (Key.B); - keys.Add (Key.L); + // Default to "All" + selector.SelectedItem = 0; + _charMap!.ShowUnicodeCategory = null; - keys.Add (Key.Tab); + selector.SelectedItemChanged += (s, e) => + { + int? idx = selector.SelectedItem; - for (var i = 0; i < 200; i++) - { - keys.Add (Key.CursorLeft); - } + if (idx is null) + { + return; + } - return keys; + if (idx.Value == 0) + { + _charMap.ShowUnicodeCategory = null; + } + else + { + // Map index to UnicodeCategory (offset by 1 because 0 is "All") + UnicodeCategory cat = Enum.GetValues () [idx.Value - 1]; + _charMap.ShowUnicodeCategory = cat; + } + }; + + return new() { CommandView = selector }; } } diff --git a/Examples/UICatalog/Scenarios/CombiningMarks.cs b/Examples/UICatalog/Scenarios/CombiningMarks.cs index da9370acf..6e0467ce0 100644 --- a/Examples/UICatalog/Scenarios/CombiningMarks.cs +++ b/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 (); diff --git a/Terminal.Gui/App/Application.Initialization.cs b/Terminal.Gui/App/Application.Initialization.cs index c688783e4..e79492fd5 100644 --- a/Terminal.Gui/App/Application.Initialization.cs +++ b/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 diff --git a/Terminal.Gui/Text/RuneExtensions.cs b/Terminal.Gui/Text/RuneExtensions.cs index 6ff5d8e2e..70f68b339 100644 --- a/Terminal.Gui/Text/RuneExtensions.cs +++ b/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. /// - 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); + } /// Get number of bytes required to encode the rune, based on the provided encoding. /// This is a Terminal.Gui extension method to to support TUI text manipulation. diff --git a/Terminal.Gui/Views/CharMap/CharMap.cs b/Terminal.Gui/Views/CharMap/CharMap.cs index f7db26002..02308ac1e 100644 --- a/Terminal.Gui/Views/CharMap/CharMap.cs +++ b/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); /// /// 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,89 +104,82 @@ 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 _visibleRowStarts = new (); + private readonly Dictionary _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 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 bool? Move (ICommandContext? commandContext, int cpOffset) + private int VisibleRowIndexForCodePoint (int codePoint) { - if (RaiseSelecting (commandContext) is true) - { - return true; - } - - SelectedCodePoint += cpOffset; - - return true; + int start = (codePoint / 16) * 16; + return _rowStartToVisibleIndex.GetValueOrDefault (start, -1); } - private void ScrollToMakeCursorVisible (Point offsetToNewCursor) - { - // Adjust vertical scrolling - if (offsetToNewCursor.Y < 1) // Header is at Y = 0 - { - ScrollVertical (offsetToNewCursor.Y - HEADER_HEIGHT); - } - else if (offsetToNewCursor.Y >= Viewport.Height) - { - ScrollVertical (offsetToNewCursor.Y - Viewport.Height + HEADER_HEIGHT); - } - - // Adjust horizontal scrolling - if (offsetToNewCursor.X < RowLabelWidth + 1) - { - ScrollHorizontal (offsetToNewCursor.X - (RowLabelWidth + 1)); - } - else if (offsetToNewCursor.X >= Viewport.Width) - { - ScrollHorizontal (offsetToNewCursor.X - Viewport.Width + 1); - } - } - - #region Cursor - - private Point GetCursor (int codePoint) - { - // + 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; - - return new (x, y); - } - - /// - public override Point? PositionCursor () - { - Point cursor = GetCursor (SelectedCodePoint); - - if (HasFocus - && cursor.X >= RowLabelWidth - && cursor.X < Viewport.Width - && cursor.Y > 0 - && cursor.Y < Viewport.Height) - { - Move (cursor.X, cursor.Y); - } - else - { - return null; - } - - return cursor; - } - - #endregion Cursor - - // ReSharper disable once InconsistentNaming - private static readonly int MAX_CODE_POINT = UnicodeRange.Ranges.Max (r => r.End); + 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 @@ -219,6 +216,21 @@ public class CharMap : View, IDesignable /// public event EventHandler>? SelectedCodePointChanged; + /// + /// Gets or sets whether the number of columns each glyph is displayed. + /// + public bool ShowGlyphWidths + { + get => _rowHeight == 2; + set + { + _rowHeight = value ? 2 : 1; + // height changed => content height depends on row height + RebuildVisibleRows (); + SetNeedsDraw (); + } + } + /// /// Specifies the starting offset for the character map. The default is 0x2500 which is the Box Drawing /// characters. @@ -233,15 +245,42 @@ public class CharMap : View, IDesignable } } + + private UnicodeCategory? _showUnicodeCategory; + /// - /// Gets or sets whether the number of columns each glyph is displayed. + /// When set, only glyphs whose UnicodeCategory matches the value are rendered. If (default), + /// all glyphs are rendered. /// - public bool ShowGlyphWidths + public UnicodeCategory? ShowUnicodeCategory { - get => _rowHeight == 2; + get => _showUnicodeCategory; set { - _rowHeight = value ? 2 : 1; + 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 (); } } @@ -249,6 +288,292 @@ public class CharMap : View, IDesignable 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) + { + return true; + } + + SelectedCodePoint += cpOffset; + + 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 + if (offsetToNewCursor.Y < 1) // Header is at Y = 0 + { + ScrollVertical (offsetToNewCursor.Y - HEADER_HEIGHT); + } + else if (offsetToNewCursor.Y >= Viewport.Height) + { + ScrollVertical (offsetToNewCursor.Y - Viewport.Height + HEADER_HEIGHT); + } + + // Adjust horizontal scrolling + if (offsetToNewCursor.X < RowLabelWidth + 1) + { + ScrollHorizontal (offsetToNewCursor.X - (RowLabelWidth + 1)); + } + else if (offsetToNewCursor.X >= Viewport.Width) + { + ScrollHorizontal (offsetToNewCursor.X - Viewport.Width + 1); + } + } + + #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 (); + + 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 + { + decResponse = getCodePointError; + } + + 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 = "Category: ", X = 0, Y = Pos.Bottom (label) }; + dlg.Add (label); + Span 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); + + 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 (); + } + + #endregion Details Dialog + + #region Cursor + + private Point GetCursor (int codePoint) + { + // + 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) + { + // 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); + } + + /// + public override Point? PositionCursor () + { + Point cursor = GetCursor (SelectedCodePoint); + + if (HasFocus + && cursor.X >= RowLabelWidth + && cursor.X < Viewport.Width + && cursor.Y > 0 + && cursor.Y < Viewport.Height) + { + Move (cursor.X, cursor.Y); + } + else + { + return null; + } + + return cursor; + } + + #endregion Cursor + #region Drawing private static int RowLabelWidth => $"U+{MAX_CODE_POINT:x5}".Length + 1; @@ -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 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 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; } /// @@ -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 } diff --git a/Terminal.Gui/Views/TextInput/TextView.cs b/Terminal.Gui/Views/TextInput/TextView.cs index 5450835d8..773424362 100644 --- a/Terminal.Gui/Views/TextInput/TextView.cs +++ b/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 () diff --git a/Tests/UnitTestsParallelizable/Text/RuneTests.cs b/Tests/UnitTestsParallelizable/Text/RuneTests.cs index 0ede7d3ba..4389b870c 100644 --- a/Tests/UnitTestsParallelizable/Text/RuneTests.cs +++ b/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