#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.
///
///
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;
AddCommand (Command.Select, HandleMouseClick);
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 - Viewport.Y) / BytesPerLine)));
AddCommand (
Command.EndOfPage,
() => MoveDown (BytesPerLine * (Viewport.Height - 1 - (int)(Address - Viewport.Y) / BytesPerLine))
);
AddCommand (Command.ScrollDown, () => ScrollVertical (1));
AddCommand (Command.ScrollUp, () => ScrollVertical (-1));
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);
// The Select handler deals with both single and double clicks
MouseBindings.ReplaceCommands (MouseFlags.Button1Clicked, Command.Select);
MouseBindings.Add (MouseFlags.Button1DoubleClicked, Command.Select);
MouseBindings.Add (MouseFlags.WheeledUp, Command.ScrollUp);
MouseBindings.Add (MouseFlags.WheeledDown, Command.ScrollDown);
SubViewsLaidOut += HexViewSubViewsLaidOut;
}
private void HexViewSubViewsLaidOut (object? sender, LayoutEventArgs e)
{
SetBytesPerLine ();
SetContentSize (new (GetLeftSideStartColumn () + BytesPerLine / NUM_BYTES_PER_HEX_COLUMN * HEX_COLUMN_WIDTH + BytesPerLine - 1, (int)((GetEditedSize ()) / BytesPerLine) + 1));
}
/// 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 GetPosition (long address)
{
if (_source is null || BytesPerLine == 0)
{
return Point.Empty;
}
var line = address / BytesPerLine;
var item = address % BytesPerLine;
return new ((int)item, (int)line);
}
/// Gets cursor location, given an address.
///
public Point GetCursor (long address)
{
Point position = GetPosition (address);
if (_leftSideHasFocus)
{
int block = position.X / NUM_BYTES_PER_HEX_COLUMN;
int column = position.X % NUM_BYTES_PER_HEX_COLUMN;
position.X = block * HEX_COLUMN_WIDTH + column * 3 + (_firstNibble ? 0 : 1);
}
else
{
position.X += BytesPerLine / NUM_BYTES_PER_HEX_COLUMN * HEX_COLUMN_WIDTH - 1;
}
position.X += GetLeftSideStartColumn ();
position.Offset (-Viewport.X, -Viewport.Y);
return position;
}
private void ScrollToMakeCursorVisible (Point offsetToNewCursor)
{
// Adjust vertical scrolling
if (offsetToNewCursor.Y < 1)
{
ScrollVertical (offsetToNewCursor.Y);
}
else if (offsetToNewCursor.Y >= Viewport.Height)
{
ScrollVertical (offsetToNewCursor.Y);
}
if (offsetToNewCursor.X < 1)
{
ScrollHorizontal (offsetToNewCursor.X);
}
else if (offsetToNewCursor.X >= Viewport.Width)
{
ScrollHorizontal (offsetToNewCursor.X);
}
}
///
public override Point? PositionCursor ()
{
Point position = GetCursor (Address);
if (HasFocus
&& position.X >= 0
&& position.X < Viewport.Width
&& position.Y >= 0
&& position.Y < Viewport.Height)
{
Move (position.X, position.Y);
return position;
}
return null;
}
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 long GetEditedSize ()
{
if (_edits.Count == 0)
{
return _source!.Length;
}
long maxEditAddress = _edits.Keys.Max ();
return Math.Max (_source!.Length, maxEditAddress + 1);
}
///
/// 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 ();
SetNeedsDraw ();
}
///
/// Discards the edits made to the by resetting the contents of the
/// property.
///
public void DiscardEdits () { _edits = new (); }
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)");
}
DiscardEdits ();
_source = value;
SetBytesPerLine ();
if (Address > _source.Length)
{
Address = 0;
}
SetNeedsLayout ();
SetNeedsDraw ();
}
}
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;
}
long newAddress = Math.Clamp (value, 0, GetEditedSize ());
Point offsetToNewCursor = GetCursor (newAddress);
_address = newAddress;
// Ensure the new cursor position is visible
ScrollToMakeCursorVisible (offsetToNewCursor);
RaisePositionChanged ();
}
}
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;
SetNeedsDraw ();
SetNeedsLayout ();
}
}
private int GetLeftSideStartColumn () { return AddressWidth == 0 ? 0 : AddressWidth + 1; }
private bool? HandleMouseClick (ICommandContext? commandContext)
{
if (commandContext is not CommandContext { Binding.MouseEventArgs: { } } mouseCommandContext)
{
return false;
}
if (RaiseSelecting (commandContext) is true)
{
return true;
}
if (!HasFocus)
{
SetFocus ();
}
if (mouseCommandContext.Binding.MouseEventArgs.Position.X < GetLeftSideStartColumn ())
{
return true;
}
int blocks = BytesPerLine / NUM_BYTES_PER_HEX_COLUMN;
int blocksSize = blocks * HEX_COLUMN_WIDTH;
int blocksRightOffset = GetLeftSideStartColumn () + blocksSize - 1;
if (mouseCommandContext.Binding.MouseEventArgs.Position.X > blocksRightOffset + BytesPerLine - 1)
{
return true;
}
bool clickIsOnLeftSide = mouseCommandContext.Binding.MouseEventArgs.Position.X >= blocksRightOffset;
long lineStart = mouseCommandContext.Binding.MouseEventArgs.Position.Y * BytesPerLine + Viewport.Y * BytesPerLine;
int x = mouseCommandContext.Binding.MouseEventArgs.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 + mouseCommandContext.Binding.MouseEventArgs.Position.X - blocksRightOffset, GetEditedSize ());
}
else
{
Address = Math.Min (lineStart + item, GetEditedSize ());
}
if (mouseCommandContext.Binding.MouseEventArgs.Flags == MouseFlags.Button1DoubleClicked)
{
_leftSideHasFocus = !clickIsOnLeftSide;
if (_leftSideHasFocus)
{
_firstNibble = empty == 1;
}
else
{
_firstNibble = true;
}
SetNeedsDraw ();
}
return false;
}
///
protected override bool OnDrawingContent ()
{
if (Source is null)
{
return true;
}
Attribute currentAttribute = Attribute.Default;
Attribute current = GetFocusColor ();
SetAttribute (current);
Move (-Viewport.X, 0);
long addressOfFirstLine = Viewport.Y * BytesPerLine;
int nBlocks = BytesPerLine / NUM_BYTES_PER_HEX_COLUMN;
var data = new byte [nBlocks * NUM_BYTES_PER_HEX_COLUMN * Viewport.Height];
Source.Position = addressOfFirstLine;
long bytesRead = 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);
Attribute addressAttribute = new Attribute (GetNormalColor ().Foreground.GetHighlightColor (), GetNormalColor ().Background);
for (var line = 0; line < Viewport.Height; line++)
{
Move (-Viewport.X, line);
long addressOfLine = addressOfFirstLine + line * nBlocks * NUM_BYTES_PER_HEX_COLUMN;
if (addressOfLine <= GetEditedSize ())
{
SetAttribute (addressAttribute);
}
else
{
SetAttribute (new Attribute (GetNormalColor ().Background.GetHighlightColor (), addressAttribute.Background));
}
var address = $"{addressOfLine:x8}";
AddStr ($"{address.Substring (8 - AddressWidth)}");
SetAttribute (GetNormalColor ());
if (AddressWidth > 0)
{
AddStr (" ");
}
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 + addressOfFirstLine == Address)
{
// Selected
SetAttribute (_leftSideHasFocus ? editingAttribute : (edited ? editedAttribute : selectedAttribute));
}
else
{
SetAttribute (edited ? editedAttribute : GetNormalColor ());
}
AddStr (offset >= bytesRead && !edited ? " " : $"{value:x2}");
SetAttribute (GetNormalColor ());
AddRune (_spaceCharRune);
}
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 >= bytesRead && !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 + Source.Position == Address)
{
// Selected
SetAttribute (_leftSideHasFocus ? editingAttribute : (edited ? editedAttribute : selectedAttribute));
}
else
{
SetAttribute (edited ? editedAttribute : GetNormalColor ());
}
AddRune (c);
for (var i = 1; i < utf8BytesConsumed; i++)
{
byteIndex++;
AddRune (_periodCharRune);
}
}
}
return true;
}
/// 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 the position (see ) and have changed. Raises the
/// event.
///
protected void RaisePositionChanged ()
{
HexViewEventArgs args = new (Address, GetPosition (Address), BytesPerLine);
OnPositionChanged (args);
PositionChanged?.Invoke (this, args);
}
///
/// Called when the position (see ) and have changed.
///
protected virtual void OnPositionChanged (HexViewEventArgs e) { }
/// Raised when the position (see ) and have 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 ();
}
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 Viewport of data, so the
// offset is relative to the buffer.
//
//
private byte GetData (byte [] buffer, int offset, out bool edited)
{
long pos = Viewport.Y * BytesPerLine + 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 = Viewport.Y + 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 SetBytesPerLine ()
{
// 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)
{
if (Address + bytes < GetEditedSize ())
{
// We can move down lines cleanly (without extending stream)
Address += bytes;
}
else if ((bytes == BytesPerLine * Viewport.Height && _source!.Length >= Viewport.Y * BytesPerLine + BytesPerLine * Viewport.Height)
|| (bytes <= BytesPerLine * Viewport.Height - BytesPerLine
&& _source!.Length <= Viewport.Y * BytesPerLine + 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;
}
return true;
}
private bool MoveEnd ()
{
// This lets address go past the end of the stream one, enabling adding to the stream.
Address = GetEditedSize ();
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 ());
return true;
}
private bool MoveHome ()
{
Address = 0;
return true;
}
private bool MoveLeft ()
{
if (_leftSideHasFocus)
{
if (!_firstNibble)
{
_firstNibble = true;
return true;
}
_firstNibble = false;
}
if (Address == 0)
{
return true;
}
Address--;
return true;
}
private bool MoveRight ()
{
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++;
}
return true;
}
private bool MoveLeftStart ()
{
Address = Address / BytesPerLine * BytesPerLine;
return true;
}
private bool MoveUp (int bytes)
{
Address -= bytes;
return true;
}
///
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;
_firstNibble = true;
SetNeedsDraw ();
return true;
}
return false;
}
///
bool IDesignable.EnableForDesign ()
{
Source = new MemoryStream (Encoding.UTF8.GetBytes ("HexView data with wide codepoints: 𝔹Aℝ𝔽!"));
return true;
}
}