#nullable enable using System.Diagnostics; // // HexView.cs: A hexadecimal viewer // // TODO: Support searching and highlighting of the search result // TODO: Support growing/shrinking the stream (e.g. del/backspace should work). // namespace Terminal.Gui; /// An hex viewer and editor over a /// /// /// provides a hex editor on top of a seekable with the left side /// showing an hex dump of the values 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 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 = new ('.'); /// 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 of the lambdas passed to all calls to AddCommand below. // Things this view knows how to do AddCommand (Command.Left, () => MoveLeft ()); AddCommand (Command.Right, () => MoveRight ()); AddCommand (Command.Down, () => MoveDown (BytesPerLine)); AddCommand (Command.Up, () => MoveUp (BytesPerLine)); AddCommand (Command.Tab, () => Navigate (NavigationDirection.Forward)); AddCommand (Command.BackTab, () => Navigate (NavigationDirection.Backward)); AddCommand (Command.PageUp, () => MoveUp (BytesPerLine * Frame.Height)); AddCommand (Command.PageDown, () => MoveDown (BytesPerLine * Frame.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 * (Frame.Height - 1 - (int)(Address - _displayStart) / BytesPerLine)) ); 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.Tab, Command.Tab); KeyBindings.Add (Key.Tab.WithShift, Command.BackTab); KeyBindings.Remove (Key.Space); KeyBindings.Remove (Key.Enter); LayoutComplete += 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 cursor position. public Point CursorPosition { get { if (_source is null || BytesPerLine == 0) { return Point.Empty; } var delta = (int)Address; if (_leftSideHasFocus) { int line = delta / BytesPerLine; int item = delta % BytesPerLine; return new (item, line); } else { 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; } 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 (); } } 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 internal override bool OnMouseEvent (MouseEvent 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, _source.Length); 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, _source.Length - 1); } else { Address = Math.Min (lineStart + item, _source.Length - 1); } if (me.Flags == MouseFlags.Button1DoubleClicked) { _leftSideHasFocus = !clickIsOnLeftSide; if (_leftSideHasFocus) { _firstNibble = empty == 1; } else { _firstNibble = true; } } SetNeedsDisplay (); return true; } /// public override void OnDrawContent (Rectangle viewport) { if (Source is null) { return; } 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 activeColor = GetHotNormalColor (); Attribute trackingColor = GetHotFocusColor (); 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 = GetHotNormalColor (); Driver.SetAttribute (currentAttribute); string 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 || edited) { SetAttribute (_leftSideHasFocus ? activeColor : trackingColor); } else { SetAttribute (GetNormalColor ()); } Driver.AddStr (offset >= n && !edited ? " " : $"{value:x2}"); SetAttribute (GetNormalColor ()); Driver.AddRune (_spaceCharRune); } Driver.AddStr (block + 1 == nblocks ? " " : "| "); } for (var bitem = 0; bitem < nblocks * NUM_BYTES_PER_HEX_COLUMN; bitem++) { int offset = line * nblocks * NUM_BYTES_PER_HEX_COLUMN + bitem; byte b = GetData (data, offset, out bool edited); Rune c; if (offset >= n && !edited) { c = _spaceCharRune; } else { if (b < 32) { c = _periodCharRune; } else if (b > 127) { c = _periodCharRune; } else { Rune.DecodeFromUtf8 (new (ref b), out c, out _); } } if (offset + _displayStart == Address || edited) { SetAttribute (_leftSideHasFocus ? trackingColor : activeColor); } else { SetAttribute (GetNormalColor ()); } Driver.AddRune (c); } } void SetAttribute (Attribute attribute) { if (currentAttribute != attribute) { currentAttribute = attribute; Driver.SetAttribute (attribute); } } } /// Raises the event. protected void RaiseEdited (HexViewEditEventArgs e) { OnEditied (e); Edited?.Invoke (this, e); } /// Event to be invoked when an edit is made on the . public event EventHandler? Edited; /// /// /// /// protected virtual void OnEditied (HexViewEditEventArgs e) { } /// Raises the event. protected void RaisePositionChanged () { HexViewEventArgs args = new (Address, CursorPosition, BytesPerLine); OnPositionChanged (args); PositionChanged?.Invoke (this, args); } /// /// Called when has changed. /// protected virtual void OnPositionChanged (HexViewEventArgs e) { } /// Event to be invoked when the position and cursor position changes. public event EventHandler? PositionChanged; /// public override bool OnProcessKeyDown (Key keyEvent) { if (!AllowEdits || _source is null) { return false; } // Ignore control characters and other special keys if (keyEvent < Key.Space || keyEvent.KeyCode > KeyCode.CharMask) { return false; } if (_leftSideHasFocus) { int value; var k = (char)keyEvent.KeyCode; 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; } byte b; if (!_edits.TryGetValue (Address, out 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; } else { Rune r = keyEvent.AsRune; // TODO: Enable entering Tab char - somehow disable Tab for navigation _edits [Address] = (byte)(r.Value & 0x00FF); MoveRight (); if ((byte)(r.Value & 0xFF00) > 0) { _edits [Address] = (byte)(r.Value & 0xFF00); MoveRight (); } //RaiseEdited (new (Address, _edits [Address])); } return false; } // // 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 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 () > 17) { BytesPerLine = NUM_BYTES_PER_HEX_COLUMN * ((Viewport.Width - GetLeftSideStartColumn ()) / 18); } } private bool MoveDown (int bytes) { RedisplayLine (Address); if (Address + bytes < _source.Length) { 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; while (p + BytesPerLine < _source.Length) { p += BytesPerLine; } Address = p; } if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + bytes); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private bool MoveEnd () { Address = _source!.Length; if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (Address); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } private bool MoveEndOfLine () { Address = Math.Min (Address / BytesPerLine * BytesPerLine + BytesPerLine - 1, _source!.Length); 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; } if (Address < _source.Length - 1) { Address++; } if (Address >= DisplayStart + BytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + BytesPerLine); SetNeedsDisplay (); } else { RedisplayLine (Address); } return true; } 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)); } private bool Navigate (NavigationDirection direction) { switch (direction) { case NavigationDirection.Forward: _leftSideHasFocus = !_leftSideHasFocus; RedisplayLine (Address); _firstNibble = true; return true; case NavigationDirection.Backward: _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; } }