#nullable enable // // HexView.cs: A hexadecimal viewer // // TODO: Support searching and highlighting of the search result // TODO: Support shrinking the stream (e.g. del/backspace should work). // using System.Buffers; namespace Terminal.Gui; /// Hex viewer and editor over a /// /// /// provides a hex editor on top of a seekable with the left side /// showing the hex values of the bytes in the and the right side showing the contents /// (filtered /// to non-control sequence ASCII characters). /// /// Users can switch from one side to the other by using the tab key. /// /// To enable editing, set to true. When is true the user can /// make changes to the hexadecimal values of the . Any changes are tracked in the /// property (a ) indicating the position where the /// changes were made and the new values. A convenience method, will apply the edits to /// the . /// /// /// Control the byte at the caret for editing by setting the property to an offset in the /// stream. /// /// Control the first byte shown by setting the property to an offset in the stream. /// public class HexView : View, IDesignable { private const int DEFAULT_ADDRESS_WIDTH = 8; // The default value for AddressWidth private const int NUM_BYTES_PER_HEX_COLUMN = 4; private const int HEX_COLUMN_WIDTH = NUM_BYTES_PER_HEX_COLUMN * 3 + 2; // 3 cols per byte + 1 for vert separator + right space private bool _firstNibble; private bool _leftSideHasFocus; private static readonly Rune _spaceCharRune = new (' '); private static readonly Rune _periodCharRune = Glyphs.DottedSquare; private static readonly Rune _columnSeparatorRune = Glyphs.VLineDa4; /// Initializes a class. /// /// The to view and edit as hex, this must support seeking, /// or an exception will be thrown. /// public HexView (Stream? source) { Source = source; CanFocus = true; CursorVisibility = CursorVisibility.Default; _leftSideHasFocus = true; _firstNibble = true; // PERF: Closure capture of 'this' creates a lot of overhead. // BUG: Closure capture of 'this' may have unexpected results depending on how this is called. // The above two comments apply to all the lambdas passed to all calls to AddCommand below. AddCommand (Command.Left, () => MoveLeft ()); AddCommand (Command.Right, () => MoveRight ()); AddCommand (Command.Down, () => MoveDown (BytesPerLine)); AddCommand (Command.Up, () => MoveUp (BytesPerLine)); AddCommand (Command.PageUp, () => MoveUp (BytesPerLine * Viewport.Height)); AddCommand (Command.PageDown, () => MoveDown (BytesPerLine * Viewport.Height)); AddCommand (Command.Start, () => MoveHome ()); AddCommand (Command.End, () => MoveEnd ()); AddCommand (Command.LeftStart, () => MoveLeftStart ()); AddCommand (Command.RightEnd, () => MoveEndOfLine ()); AddCommand (Command.StartOfPage, () => MoveUp (BytesPerLine * ((int)(Address - _displayStart) / BytesPerLine))); AddCommand ( Command.EndOfPage, () => MoveDown (BytesPerLine * (Viewport.Height - 1 - (int)(Address - _displayStart) / BytesPerLine)) ); AddCommand (Command.DeleteCharLeft, () => true); AddCommand (Command.DeleteCharRight, () => true); AddCommand (Command.Insert, () => true); KeyBindings.Add (Key.CursorLeft, Command.Left); KeyBindings.Add (Key.CursorRight, Command.Right); KeyBindings.Add (Key.CursorDown, Command.Down); KeyBindings.Add (Key.CursorUp, Command.Up); KeyBindings.Add (Key.PageUp, Command.PageUp); KeyBindings.Add (Key.PageDown, Command.PageDown); KeyBindings.Add (Key.Home, Command.Start); KeyBindings.Add (Key.End, Command.End); KeyBindings.Add (Key.CursorLeft.WithCtrl, Command.LeftStart); KeyBindings.Add (Key.CursorRight.WithCtrl, Command.RightEnd); KeyBindings.Add (Key.CursorUp.WithCtrl, Command.StartOfPage); KeyBindings.Add (Key.CursorDown.WithCtrl, Command.EndOfPage); KeyBindings.Add (Key.Backspace, Command.DeleteCharLeft); KeyBindings.Add (Key.Delete, Command.DeleteCharRight); KeyBindings.Add (Key.InsertChar, Command.Insert); KeyBindings.Remove (Key.Space); KeyBindings.Remove (Key.Enter); SubviewsLaidOut += HexView_LayoutComplete; } /// Initializes a class. public HexView () : this (new MemoryStream ()) { } /// /// Gets or sets whether this allows editing of the of the underlying /// . /// /// true to allow edits; otherwise, false. public bool AllowEdits { get; set; } = true; /// Gets the current edit position. public Point Position { get { if (_source is null || BytesPerLine == 0) { return Point.Empty; } var delta = (int)Address; int line = delta / BytesPerLine; int item = delta % BytesPerLine; return new (item, line); } } /// public override Point? PositionCursor () { var delta = (int)(Address - _displayStart); int line = delta / BytesPerLine; int item = delta % BytesPerLine; int block = item / NUM_BYTES_PER_HEX_COLUMN; int column = item % NUM_BYTES_PER_HEX_COLUMN * 3; int x = GetLeftSideStartColumn () + block * HEX_COLUMN_WIDTH + column + (_firstNibble ? 0 : 1); int y = line; if (!_leftSideHasFocus) { x = GetLeftSideStartColumn () + BytesPerLine / NUM_BYTES_PER_HEX_COLUMN * HEX_COLUMN_WIDTH + item - 1; } Move (x, y); return new (x, y); } private SortedDictionary _edits = []; /// /// Gets a describing the edits done to the . /// Each Key indicates an offset where an edit was made and the Value is the changed byte. /// /// The edits. public IReadOnlyDictionary Edits => _edits; private Stream? _source; /// /// Sets or gets the the is operating on; the stream must support /// seeking ( == true). /// /// The source. public Stream? Source { get => _source; set { ArgumentNullException.ThrowIfNull (value); if (!value!.CanSeek) { throw new ArgumentException (@"The source stream must be seekable (CanSeek property)"); } _source = value; if (_displayStart > _source.Length) { DisplayStart = 0; } if (Address > _source.Length) { Address = 0; } SetNeedsLayout (); SetNeedsDisplay (); } } private int _bpl; /// The bytes length per line. public int BytesPerLine { get => _bpl; set { _bpl = value; RaisePositionChanged (); } } private long _address; /// Gets or sets the current byte position in the . public long Address { get => _address; set { if (_address == value) { return; } //ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual (value, Source!.Length, $"Position"); _address = value; RaisePositionChanged (); } } private long _displayStart; // TODO: Use Viewport content scrolling instead /// /// Sets or gets the offset into the that will be displayed at the top of the /// . /// /// The display start. public long DisplayStart { get => _displayStart; set { Address = value; SetDisplayStart (value); } } private int _addressWidth = DEFAULT_ADDRESS_WIDTH; /// /// Gets or sets the width of the Address column on the left. Set to 0 to hide. The default is 8. /// public int AddressWidth { get => _addressWidth; set { if (_addressWidth == value) { return; } _addressWidth = value; SetNeedsDisplay (); SetNeedsLayout (); } } private int GetLeftSideStartColumn () { return AddressWidth == 0 ? 0 : AddressWidth + 1; } internal void SetDisplayStart (long value) { if (value > 0 && value >= _source?.Length) { _displayStart = _source.Length - 1; } else if (value < 0) { _displayStart = 0; } else { _displayStart = value; } SetNeedsDisplay (); } /// /// Applies and edits made to the and resets the contents of the /// property. /// /// If provided also applies the changes to the passed . /// . public void ApplyEdits (Stream? stream = null) { foreach (KeyValuePair kv in _edits) { _source!.Position = kv.Key; _source.WriteByte (kv.Value); _source.Flush (); if (stream is { }) { stream.Position = kv.Key; stream.WriteByte (kv.Value); stream.Flush (); } } _edits = new (); SetNeedsDisplay (); } /// /// Discards the edits made to the by resetting the contents of the /// property. /// public void DiscardEdits () { _edits = new (); } /// protected override bool OnMouseEvent (MouseEventArgs me) { if (_source is null) { return false; } if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) && !me.Flags.HasFlag (MouseFlags.WheeledDown) && !me.Flags.HasFlag (MouseFlags.WheeledUp)) { return false; } if (!HasFocus) { SetFocus (); } if (me.Flags == MouseFlags.WheeledDown) { DisplayStart = Math.Min (DisplayStart + BytesPerLine, GetEditedSize ()); return true; } if (me.Flags == MouseFlags.WheeledUp) { DisplayStart = Math.Max (DisplayStart - BytesPerLine, 0); return true; } if (me.Position.X < GetLeftSideStartColumn ()) { return true; } int nblocks = BytesPerLine / NUM_BYTES_PER_HEX_COLUMN; int blocksSize = nblocks * HEX_COLUMN_WIDTH; int blocksRightOffset = GetLeftSideStartColumn () + blocksSize - 1; if (me.Position.X > blocksRightOffset + BytesPerLine - 1) { return true; } bool clickIsOnLeftSide = me.Position.X >= blocksRightOffset; long lineStart = me.Position.Y * BytesPerLine + _displayStart; int x = me.Position.X - GetLeftSideStartColumn () + 1; int block = x / HEX_COLUMN_WIDTH; x -= block * 2; int empty = x % 3; int item = x / 3; if (!clickIsOnLeftSide && item > 0 && (empty == 0 || x == block * HEX_COLUMN_WIDTH + HEX_COLUMN_WIDTH - 1 - block * 2)) { return true; } _firstNibble = true; if (clickIsOnLeftSide) { Address = Math.Min (lineStart + me.Position.X - blocksRightOffset, GetEditedSize ()); } else { Address = Math.Min (lineStart + item, GetEditedSize ()); } if (me.Flags == MouseFlags.Button1DoubleClicked) { _leftSideHasFocus = !clickIsOnLeftSide; if (_leftSideHasFocus) { _firstNibble = empty == 1; } else { _firstNibble = true; } } SetNeedsDisplay (); return true; } /// protected override bool OnDrawingContent (Rectangle viewport) { if (Source is null) { return true; } Attribute currentAttribute; Attribute current = GetFocusColor (); Driver?.SetAttribute (current); Move (0, 0); int nBlocks = BytesPerLine / NUM_BYTES_PER_HEX_COLUMN; var data = new byte [nBlocks * NUM_BYTES_PER_HEX_COLUMN * viewport.Height]; Source.Position = _displayStart; int n = _source!.Read (data, 0, data.Length); Attribute selectedAttribute = GetHotNormalColor (); Attribute editedAttribute = new Attribute (GetNormalColor ().Foreground.GetHighlightColor (), GetNormalColor ().Background); Attribute editingAttribute = new Attribute (GetFocusColor ().Background, GetFocusColor ().Foreground); for (var line = 0; line < viewport.Height; line++) { Rectangle lineRect = new (0, line, viewport.Width, 1); if (!Viewport.Contains (lineRect)) { continue; } Move (0, line); currentAttribute = new Attribute (GetNormalColor ().Foreground.GetHighlightColor (), GetNormalColor ().Background); Driver?.SetAttribute (currentAttribute); var address = $"{_displayStart + line * nBlocks * NUM_BYTES_PER_HEX_COLUMN:x8}"; Driver?.AddStr ($"{address.Substring (8 - AddressWidth)}"); if (AddressWidth > 0) { Driver?.AddStr (" "); } SetAttribute (GetNormalColor ()); for (var block = 0; block < nBlocks; block++) { for (var b = 0; b < NUM_BYTES_PER_HEX_COLUMN; b++) { int offset = line * nBlocks * NUM_BYTES_PER_HEX_COLUMN + block * NUM_BYTES_PER_HEX_COLUMN + b; byte value = GetData (data, offset, out bool edited); if (offset + _displayStart == Address) { // Selected SetAttribute (_leftSideHasFocus ? editingAttribute : (edited ? editedAttribute : selectedAttribute)); } else { SetAttribute (edited ? editedAttribute : GetNormalColor ()); } Driver?.AddStr (offset >= n && !edited ? " " : $"{value:x2}"); SetAttribute (GetNormalColor ()); Driver?.AddRune (_spaceCharRune); } Driver?.AddStr (block + 1 == nBlocks ? " " : $"{_columnSeparatorRune} "); } for (var byteIndex = 0; byteIndex < nBlocks * NUM_BYTES_PER_HEX_COLUMN; byteIndex++) { int offset = line * nBlocks * NUM_BYTES_PER_HEX_COLUMN + byteIndex; byte b = GetData (data, offset, out bool edited); Rune c; var utf8BytesConsumed = 0; if (offset >= n && !edited) { c = _spaceCharRune; } else { switch (b) { //case < 32: // c = _periodCharRune; // break; case > 127: { var utf8 = GetData (data, offset, 4, out bool _); OperationStatus status = Rune.DecodeFromUtf8 (utf8, out c, out utf8BytesConsumed); while (status == OperationStatus.NeedMoreData) { status = Rune.DecodeFromUtf8 (utf8, out c, out utf8BytesConsumed); } break; } default: Rune.DecodeFromUtf8 (new (ref b), out c, out _); break; } } if (offset + _displayStart == Address) { // Selected SetAttribute (_leftSideHasFocus ? editingAttribute : (edited ? editedAttribute : selectedAttribute)); } else { SetAttribute (edited ? editedAttribute : GetNormalColor ()); } Driver?.AddRune (c); for (var i = 1; i < utf8BytesConsumed; i++) { byteIndex++; Driver?.AddRune (_periodCharRune); } } } return true; void SetAttribute (Attribute attribute) { if (currentAttribute != attribute) { currentAttribute = attribute; Driver?.SetAttribute (attribute); } } } /// Raises the event. protected void RaiseEdited (HexViewEditEventArgs e) { OnEdited (e); Edited?.Invoke (this, e); } /// Event to be invoked when an edit is made on the . public event EventHandler? Edited; /// /// /// protected virtual void OnEdited (HexViewEditEventArgs e) { } /// /// Call this when (and ) has changed. Raises the /// event. /// protected void RaisePositionChanged () { SetNeedsDisplay (); HexViewEventArgs args = new (Address, Position, BytesPerLine); OnPositionChanged (args); PositionChanged?.Invoke (this, args); } /// /// Called when (and ) has changed. /// protected virtual void OnPositionChanged (HexViewEventArgs e) { } /// Raised when (and ) has changed. public event EventHandler? PositionChanged; /// protected override bool OnKeyDownNotHandled (Key keyEvent) { if (!AllowEdits || _source is null) { return false; } if (keyEvent.IsAlt) { return false; } if (_leftSideHasFocus) { int value; var k = (char)keyEvent.KeyCode; if (!char.IsAsciiHexDigit ((char)keyEvent.KeyCode)) { return false; } if (k is >= 'A' and <= 'F') { value = k - 'A' + 10; } else if (k is >= 'a' and <= 'f') { value = k - 'a' + 10; } else if (k is >= '0' and <= '9') { value = k - '0'; } else { return false; } if (!_edits.TryGetValue (Address, out byte b)) { _source.Position = Address; b = (byte)_source.ReadByte (); } // BUGBUG: This makes no sense here. RedisplayLine (Address); if (_firstNibble) { _firstNibble = false; b = (byte)((b & 0xf) | (value << NUM_BYTES_PER_HEX_COLUMN)); _edits [Address] = b; RaiseEdited (new (Address, _edits [Address])); } else { b = (byte)((b & 0xf0) | value); _edits [Address] = b; RaiseEdited (new (Address, _edits [Address])); MoveRight (); } return true; } keyEvent = keyEvent.NoAlt.NoCtrl; Rune r = keyEvent.AsRune; if (Rune.IsControl (r)) { return false; } var utf8 = new byte [4]; // If the rune is a wide char, encode as utf8 if (r.TryEncodeToUtf8 (utf8, out int bytesWritten)) { if (bytesWritten > 1) { bytesWritten = 4; } for (var utfIndex = 0; utfIndex < bytesWritten; utfIndex++) { _edits [Address] = utf8 [utfIndex]; RaiseEdited (new (Address, _edits [Address])); MoveRight (); } } else { _edits [Address] = (byte)r.Value; RaiseEdited (new (Address, _edits [Address])); MoveRight (); } return true; } // // This is used to support editing of the buffer on a peer List<>, // the offset corresponds to an offset relative to DisplayStart, and // the buffer contains the contents of a screenful of data, so the // offset is relative to the buffer. // // private byte GetData (byte [] buffer, int offset, out bool edited) { long pos = DisplayStart + offset; if (_edits.TryGetValue (pos, out byte v)) { edited = true; return v; } edited = false; return buffer [offset]; } private byte [] GetData (byte [] buffer, int offset, int count, out bool edited) { var returnBytes = new byte [count]; edited = false; long pos = DisplayStart + offset; for (long i = pos; i < pos + count; i++) { if (_edits.TryGetValue (i, out byte v)) { edited = true; returnBytes [i - pos] = v; } else { if (pos < buffer.Length - 1) { returnBytes [i - pos] = buffer [pos]; } } } return returnBytes; } private void HexView_LayoutComplete (object? sender, LayoutEventArgs e) { // Small buffers will just show the position, with the bsize field value (4 bytes) BytesPerLine = NUM_BYTES_PER_HEX_COLUMN; if (Viewport.Width - GetLeftSideStartColumn () >= HEX_COLUMN_WIDTH) { BytesPerLine = Math.Max ( NUM_BYTES_PER_HEX_COLUMN, NUM_BYTES_PER_HEX_COLUMN * ((Viewport.Width - GetLeftSideStartColumn ()) / (HEX_COLUMN_WIDTH + NUM_BYTES_PER_HEX_COLUMN))); } } private bool MoveDown (int bytes) { RedisplayLine (Address); if (Address + bytes < GetEditedSize ()) { // We can move down lines cleanly (without extending stream) Address += bytes; } else if ((bytes == BytesPerLine * Viewport.Height && _source!.Length >= DisplayStart + BytesPerLine * Viewport.Height) || (bytes <= BytesPerLine * Viewport.Height - BytesPerLine && _source!.Length <= DisplayStart + BytesPerLine * Viewport.Height)) { long p = Address; // This lets address go past the end of the stream one, enabling adding to the stream. while (p + BytesPerLine <= GetEditedSize ()) { p += BytesPerLine; } Address = p; } if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + bytes); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private bool MoveEnd () { // This lets address go past the end of the stream one, enabling adding to the stream. Address = GetEditedSize (); if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (Address); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private bool MoveEndOfLine () { // This lets address go past the end of the stream one, enabling adding to the stream. Address = Math.Min (Address / BytesPerLine * BytesPerLine + BytesPerLine - 1, GetEditedSize ()); SetNeedsDisplay (); return true; } private bool MoveHome () { DisplayStart = 0; SetNeedsDisplay (); return true; } private bool MoveLeft () { RedisplayLine (Address); if (_leftSideHasFocus) { if (!_firstNibble) { _firstNibble = true; return true; } _firstNibble = false; } if (Address == 0) { return true; } if (Address - 1 < DisplayStart) { SetDisplayStart (_displayStart - BytesPerLine); SetNeedsDisplay (); } else { RedisplayLine (Address); } Address--; return true; } private bool MoveRight () { RedisplayLine (Address); if (_leftSideHasFocus) { if (_firstNibble) { _firstNibble = false; return true; } _firstNibble = true; } // This lets address go past the end of the stream one, enabling adding to the stream. if (Address < GetEditedSize ()) { Address++; } if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + BytesPerLine); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private long GetEditedSize () { if (_edits.Count == 0) { return _source!.Length; } long maxEditAddress = _edits.Keys.Max (); return Math.Max (_source!.Length, maxEditAddress + 1); } private bool MoveLeftStart () { Address = Address / BytesPerLine * BytesPerLine; SetNeedsDisplay (); return true; } private bool MoveUp (int bytes) { RedisplayLine (Address); if (Address - bytes > -1) { Address -= bytes; } if (Address < DisplayStart) { SetDisplayStart (DisplayStart - bytes); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private void RedisplayLine (long pos) { if (BytesPerLine == 0) { return; } var delta = (int)(pos - DisplayStart); int line = delta / BytesPerLine; SetNeedsDisplay (new (0, line, Viewport.Width, 1)); } /// protected override bool OnAdvancingFocus (NavigationDirection direction, TabBehavior? behavior) { if (behavior is { } && behavior != TabStop) { return false; } if ((direction == NavigationDirection.Forward && _leftSideHasFocus) || (direction == NavigationDirection.Backward && !_leftSideHasFocus)) { _leftSideHasFocus = !_leftSideHasFocus; RedisplayLine (Address); _firstNibble = true; return true; } return false; } /// bool IDesignable.EnableForDesign () { Source = new MemoryStream (Encoding.UTF8.GetBytes ("HexView data with wide codepoints: 𝔹Aℝ𝔽!")); return true; } }