// // HexView.cs: A hexadecimal viewer // // TODO: // - Support searching and highlighting of the search result // - Bug showing the last line // 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 bsize = 4; private const int displayWidth = 9; private int bpl; private long displayStart, pos; private SortedDictionary edits = []; private bool firstNibble; private bool leftSide; private Stream source; 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; leftSide = 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)(position - displayStart) / bytesPerLine))); AddCommand ( Command.EndOfPage, () => MoveDown (bytesPerLine * (Frame.Height - 1 - (int)(position - displayStart) / bytesPerLine)) ); // Default keybindings for this view 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.V.WithAlt, Command.PageUp); KeyBindings.Add (Key.PageUp, Command.PageUp); KeyBindings.Add (Key.V.WithCtrl, Command.PageDown); 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); LayoutComplete += HexView_LayoutComplete; } /// Initializes a class. public HexView () : this (new MemoryStream ()) { } /// /// Gets or sets whether this allow editing of the of the underlying /// . /// /// true if allow edits; otherwise, false. public bool AllowEdits { get; set; } = true; /// The bytes length per line. public int BytesPerLine => bytesPerLine; /// Gets the current cursor position starting at one for both, line and column. public Point CursorPosition { get { if (!IsInitialized) { return Point.Empty; } var delta = (int)position; int line = delta / bytesPerLine + 1; int item = delta % bytesPerLine + 1; return new Point (item, line); } } /// /// 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 { position = value; SetDisplayStart (value); } } /// /// 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; /// Gets the current character position starting at one, related to the . public long Position => position + 1; /// /// Sets or gets the the is operating on; the stream must support /// seeking ( == true). /// /// The source. public Stream Source { get => source; set { if (value is null) { throw new ArgumentNullException ("source"); } if (!value.CanSeek) { throw new ArgumentException ("The source stream must be seekable (CanSeek property)", "source"); } source = value; if (displayStart > source.Length) { DisplayStart = 0; } if (position > source.Length) { position = 0; } SetNeedsDisplay (); } } private int bytesPerLine { get => bpl; set { bpl = value; OnPositionChanged (); } } private long position { get => pos; set { pos = value; OnPositionChanged (); } } /// /// This method 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 SortedDictionary (); SetNeedsDisplay (); } /// /// This method discards the edits made to the by resetting the contents of the /// property. /// public void DiscardEdits () { edits = new SortedDictionary (); } /// Event to be invoked when an edit is made on the . public event EventHandler Edited; /// protected internal override bool OnMouseEvent (MouseEvent me) { 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 < displayWidth) { return true; } int nblocks = bytesPerLine / bsize; int blocksSize = nblocks * 14; int blocksRightOffset = displayWidth + blocksSize - 1; if (me.Position.X > blocksRightOffset + bytesPerLine - 1) { return true; } leftSide = me.Position.X >= blocksRightOffset; long lineStart = me.Position.Y * bytesPerLine + displayStart; int x = me.Position.X - displayWidth + 1; int block = x / 14; x -= block * 2; int empty = x % 3; int item = x / 3; if (!leftSide && item > 0 && (empty == 0 || x == block * 14 + 14 - 1 - block * 2)) { return true; } firstNibble = true; if (leftSide) { position = Math.Min (lineStart + me.Position.X - blocksRightOffset, source.Length); } else { position = Math.Min (lineStart + item, source.Length); } if (me.Flags == MouseFlags.Button1DoubleClicked) { leftSide = !leftSide; if (leftSide) { firstNibble = empty == 1; } else { firstNibble = true; } } SetNeedsDisplay (); return true; } /// public override void OnDrawContent (Rectangle viewport) { Attribute currentAttribute; Attribute current = ColorScheme.Focus; Driver.SetAttribute (current); Move (0, 0); int nblocks = bytesPerLine / bsize; var data = new byte [nblocks * bsize * viewport.Height]; Source.Position = displayStart; int n = source.Read (data, 0, data.Length); Attribute activeColor = ColorScheme.HotNormal; Attribute trackingColor = ColorScheme.HotFocus; for (var line = 0; line < viewport.Height; line++) { Rectangle lineRect = new (0, line, viewport.Width, 1); if (!Viewport.Contains (lineRect)) { continue; } Move (0, line); Driver.SetAttribute (ColorScheme.HotNormal); Driver.AddStr ($"{displayStart + line * nblocks * bsize:x8} "); currentAttribute = ColorScheme.HotNormal; SetAttribute (GetNormalColor ()); for (var block = 0; block < nblocks; block++) { for (var b = 0; b < bsize; b++) { int offset = line * nblocks * bsize + block * bsize + b; byte value = GetData (data, offset, out bool edited); if (offset + displayStart == position || edited) { SetAttribute (leftSide ? 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 * bsize; bitem++) { int offset = line * nblocks * bsize + 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 ReadOnlySpan (ref b), out c, out _); } } if (offset + displayStart == position || edited) { SetAttribute (leftSide ? trackingColor : activeColor); } else { SetAttribute (GetNormalColor ()); } Driver.AddRune (c); } } void SetAttribute (Attribute attribute) { if (currentAttribute != attribute) { currentAttribute = attribute; Driver.SetAttribute (attribute); } } } /// Method used to invoke the event passing the . /// The key value pair. public virtual void OnEdited (HexViewEditEventArgs e) { Edited?.Invoke (this, e); } /// /// Method used to invoke the event passing the /// arguments. /// public virtual void OnPositionChanged () { PositionChanged?.Invoke (this, new HexViewEventArgs (Position, CursorPosition, BytesPerLine)); } /// public override bool OnProcessKeyDown (Key keyEvent) { if (!AllowEdits) { return false; } // Ignore control characters and other special keys if (keyEvent < Key.Space || keyEvent.KeyCode > KeyCode.CharMask) { return false; } if (leftSide) { int value; var k = (char)keyEvent.KeyCode; if (k >= 'A' && k <= 'F') { value = k - 'A' + 10; } else if (k >= 'a' && k <= 'f') { value = k - 'a' + 10; } else if (k >= '0' && k <= '9') { value = k - '0'; } else { return false; } byte b; if (!edits.TryGetValue (position, out b)) { source.Position = position; b = (byte)source.ReadByte (); } RedisplayLine (position); if (firstNibble) { firstNibble = false; b = (byte)((b & 0xf) | (value << bsize)); edits [position] = b; OnEdited (new HexViewEditEventArgs (position, edits [position])); } else { b = (byte)((b & 0xf0) | value); edits [position] = b; OnEdited (new HexViewEditEventArgs (position, edits [position])); MoveRight (); } return true; } return false; } /// Event to be invoked when the position and cursor position changes. public event EventHandler PositionChanged; /// public override Point? PositionCursor () { var delta = (int)(position - displayStart); int line = delta / bytesPerLine; int item = delta % bytesPerLine; int block = item / bsize; int column = item % bsize * 3; int x = displayWidth + block * 14 + column + (firstNibble ? 0 : 1); int y = line; if (!leftSide) { x = displayWidth + bytesPerLine / bsize * 14 + item - 1; } Move (x, y); return new (x, y); } 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 (); } // // 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 = bsize; if (Viewport.Width - displayWidth > 17) { bytesPerLine = bsize * ((Viewport.Width - displayWidth) / 18); } } private bool MoveDown (int bytes) { RedisplayLine (position); if (position + bytes < source.Length) { position += 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 = position; while (p + bytesPerLine < source.Length) { p += bytesPerLine; } position = p; } if (position >= DisplayStart + bytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + bytes); SetNeedsDisplay (); } else { RedisplayLine (position); } return true; } private bool MoveEnd () { position = source.Length; if (position >= DisplayStart + bytesPerLine * Viewport.Height) { SetDisplayStart (position); SetNeedsDisplay (); } else { RedisplayLine (position); } return true; } private bool MoveEndOfLine () { position = Math.Min (position / bytesPerLine * bytesPerLine + bytesPerLine - 1, source.Length); SetNeedsDisplay (); return true; } private bool MoveHome () { DisplayStart = 0; SetNeedsDisplay (); return true; } private bool MoveLeft () { RedisplayLine (position); if (leftSide) { if (!firstNibble) { firstNibble = true; return true; } firstNibble = false; } if (position == 0) { return true; } if (position - 1 < DisplayStart) { SetDisplayStart (displayStart - bytesPerLine); SetNeedsDisplay (); } else { RedisplayLine (position); } position--; return true; } private bool MoveRight () { RedisplayLine (position); if (leftSide) { if (firstNibble) { firstNibble = false; return true; } firstNibble = true; } if (position < source.Length) { position++; } if (position >= DisplayStart + bytesPerLine * Viewport.Height) { SetDisplayStart (DisplayStart + bytesPerLine); SetNeedsDisplay (); } else { RedisplayLine (position); } return true; } private bool MoveLeftStart () { position = position / bytesPerLine * bytesPerLine; SetNeedsDisplay (); return true; } private bool MoveUp (int bytes) { RedisplayLine (position); if (position - bytes > -1) { position -= bytes; } if (position < DisplayStart) { SetDisplayStart (DisplayStart - bytes); SetNeedsDisplay (); } else { RedisplayLine (position); } 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: if (leftSide) { leftSide = false; RedisplayLine (position); firstNibble = true; return true; } break; case NavigationDirection.Backward: if (!leftSide) { leftSide = true; RedisplayLine (position); firstNibble = true; return true; } break; } return false; } /// bool IDesignable.EnableForDesign () { Source = new MemoryStream (Encoding.UTF8.GetBytes ("HexEditor Unicode that shouldn't 𝔹Aℝ𝔽!")); return true; } }