Skip to main content

hxy_view/
lib.rs

1//! Reusable egui hex-view widget.
2//!
3//! Renders bytes from a [`HexSource`] in a virtualised scroll view with a
4//! configurable number of hex columns (16 by default), an address column,
5//! and an ASCII sidebar. Supports click, shift-click, and drag across
6//! arbitrary row boundaries to extend the selection. Highlights are
7//! painted as contiguous bars across each row for readability.
8
9#![forbid(unsafe_code)]
10
11use egui::Align2;
12use egui::Color32;
13use egui::FontId;
14use egui::Pos2;
15use egui::Rect;
16use egui::Sense;
17use egui::Stroke;
18use egui::StrokeKind;
19use egui::TextStyle;
20use egui::Ui;
21use egui::Vec2;
22use hxy_core::ByteLen;
23use hxy_core::ByteOffset;
24use hxy_core::ByteRange;
25use hxy_core::ColumnCount;
26use hxy_core::Error as CoreError;
27use hxy_core::HexSource;
28use hxy_core::Selection;
29
30/// Where the byte-value palette should be applied.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum ValueHighlight {
33    /// Paint the palette as a background fill per byte. Text gets a
34    /// contrast-adjusted color so it stays readable over the tint.
35    Background,
36    /// Tint the hex/ascii glyphs themselves; leave the background alone.
37    Text,
38}
39
40/// Callback type for context menu rendering -- invoked on right-click
41/// anywhere in the hex or ASCII pane.
42pub type ContextMenuFn<'s> = Box<dyn FnOnce(&mut egui::Ui) + 's>;
43
44/// Foreground + background colour choice for a single byte cell.
45/// Returned by a [`ByteStylerFn`] so the consumer can fully override the
46/// built-in palette's decision per byte (e.g. to highlight a matched
47/// pattern, a diff, or bytes pointed to by a parsed template).
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub struct ByteStyle {
50    /// Background tint for the byte cell. `None` falls back to whatever
51    /// the configured palette would have chosen (or nothing, if none).
52    pub bg: Option<Color32>,
53    /// Text (glyph) color. `None` falls back to the default palette /
54    /// theme behavior, including contrast adjustment against `bg`.
55    pub fg: Option<Color32>,
56}
57
58/// Per-byte styler. Receives each byte's value and absolute file offset
59/// and returns a [`ByteStyle`]. Consumers can use this to drive their
60/// own colour logic (search hits, struct-field overlays, diff colours,
61/// etc.) without subclassing the widget.
62pub type ByteStylerFn<'s> = Box<dyn Fn(u8, ByteOffset) -> ByteStyle + 's>;
63
64/// Formatter for address-column labels. Receives the offset of the row's
65/// first byte and the width (in characters) the built-in address uses,
66/// returns the string to render. Default formats as uppercase zero-padded
67/// hex.
68pub type AddressFormatterFn<'s> = Box<dyn Fn(ByteOffset, usize) -> String + 's>;
69
70/// Formatter for the column-header row above the hex/ascii panes.
71/// Receives the zero-based column index and returns its label. Default
72/// renders an uppercase hex digit.
73pub type ColumnHeaderFormatterFn<'s> = Box<dyn Fn(usize) -> String + 's>;
74
75pub struct HexView<'s, S: HexSource + ?Sized> {
76    source: &'s S,
77    columns: ColumnCount,
78    selection: &'s mut Option<Selection>,
79    value_highlight: Option<ValueHighlight>,
80    palette_override: Option<HighlightPalette>,
81    byte_styler: Option<ByteStylerFn<'s>>,
82    address_formatter: Option<AddressFormatterFn<'s>>,
83    column_header_formatter: Option<ColumnHeaderFormatterFn<'s>>,
84    context_menu: Option<ContextMenuFn<'s>>,
85    minimap: bool,
86    minimap_colored: bool,
87    initial_scroll: Option<f32>,
88    /// When set, overrides `initial_scroll` -- resolved at render
89    /// time to place the row containing this byte near the top.
90    scroll_to_byte: Option<ByteOffset>,
91    /// Transient highlight for a byte range the consumer wants to
92    /// draw attention to -- e.g. the template panel reflecting which
93    /// field the pointer is over. Painted as a secondary fill that
94    /// co-exists with the primary selection.
95    hover_span: Option<ByteRange>,
96    /// Leaf-field byte ranges from a template execution. The
97    /// painter draws a thin outline around each range so the user
98    /// can see field boundaries the same way 010 Editor does.
99    /// Ranges must be sorted by start and must not overlap.
100    field_boundaries: &'s [(ByteOffset, ByteLen)],
101    /// Per-field tint, parallel to `field_boundaries`. When present
102    /// and long enough, the minimap paints each hit byte with its
103    /// field's colour so the user sees the same colour map in both
104    /// views. Empty = no minimap override; byte-palette / grayscale
105    /// is used instead.
106    field_colors: &'s [Color32],
107    /// Optional caller-supplied id salt; see [`HexView::id_salt`].
108    id_salt: Option<egui::Id>,
109}
110
111impl<'s, S: HexSource + ?Sized> HexView<'s, S> {
112    pub fn new(source: &'s S, selection: &'s mut Option<Selection>) -> Self {
113        Self {
114            source,
115            columns: ColumnCount::DEFAULT,
116            selection,
117            value_highlight: None,
118            palette_override: None,
119            byte_styler: None,
120            address_formatter: None,
121            column_header_formatter: None,
122            context_menu: None,
123            minimap: false,
124            minimap_colored: true,
125            initial_scroll: None,
126            scroll_to_byte: None,
127            hover_span: None,
128            field_boundaries: &[],
129            field_colors: &[],
130            id_salt: None,
131        }
132    }
133
134    /// Supply leaf-field byte ranges from a template. The view paints
135    /// a thin outline at each range's edges so users can see where
136    /// template fields start and end without looking at the side
137    /// panel. Must be sorted by start; caller guarantees no overlap.
138    pub fn field_boundaries(mut self, boundaries: &'s [(ByteOffset, ByteLen)]) -> Self {
139        self.field_boundaries = boundaries;
140        self
141    }
142
143    /// Per-field colour, parallel to `field_boundaries`. The minimap
144    /// overrides its byte-palette / grayscale fill with this colour
145    /// for bytes that fall inside a field, so the overview strip
146    /// matches the colouring the user sees in the main view.
147    pub fn field_colors(mut self, colors: &'s [Color32]) -> Self {
148        self.field_colors = colors;
149        self
150    }
151
152    /// Stable seed for this view's internal widget ids. egui runs two
153    /// layout passes; without a stable salt the hex body and column
154    /// header -- both giant `allocate_exact_size` widgets -- get auto-
155    /// ids derived from call-site position, which drifts under
156    /// egui_dock's tab shuffling and triggers "Widget rect changed id
157    /// between passes" warnings. Callers should pass something tied
158    /// to the tab (e.g. `FileId`).
159    pub fn id_salt(mut self, salt: impl std::hash::Hash) -> Self {
160        self.id_salt = Some(egui::Id::new(salt));
161        self
162    }
163
164    /// Tell the hex view to draw a secondary highlight over the given
165    /// byte range. Consumer-driven; cleared when `None` is passed.
166    pub fn hover_span(mut self, span: Option<ByteRange>) -> Self {
167        self.hover_span = span;
168        self
169    }
170
171    /// Install a per-byte styler. When set, the callback's returned
172    /// [`ByteStyle`] takes precedence over the palette for that byte.
173    /// `None` fields in the returned style fall back to the palette's
174    /// choice (or theme default).
175    pub fn byte_styler(mut self, f: impl Fn(u8, ByteOffset) -> ByteStyle + 's) -> Self {
176        self.byte_styler = Some(Box::new(f));
177        self
178    }
179
180    /// Override the address-column label formatter. Default is uppercase
181    /// zero-padded hex.
182    pub fn address_formatter(mut self, f: impl Fn(ByteOffset, usize) -> String + 's) -> Self {
183        self.address_formatter = Some(Box::new(f));
184        self
185    }
186
187    /// Override the column-header label formatter. Default is a single
188    /// uppercase hex digit.
189    pub fn column_header_formatter(mut self, f: impl Fn(usize) -> String + 's) -> Self {
190        self.column_header_formatter = Some(Box::new(f));
191        self
192    }
193
194    /// Scroll the view to `offset` (in pixels from the top of content)
195    /// on this frame. Useful for restoring a saved scroll position on
196    /// file reopen.
197    pub fn scroll_to(mut self, offset: f32) -> Self {
198        self.initial_scroll = Some(offset);
199        self
200    }
201
202    /// Scroll so the row containing `byte` is near the top of the
203    /// visible area. Resolved at render time using the current font
204    /// and column settings; takes precedence over `scroll_to`.
205    pub fn scroll_to_byte(mut self, byte: ByteOffset) -> Self {
206        self.scroll_to_byte = Some(byte);
207        self
208    }
209
210    /// Draw a narrow "minimap" strip on the right-hand side of the view
211    /// that shows the full file colored by the current palette, with a
212    /// viewport indicator, and supports click/drag to scroll.
213    pub fn minimap(mut self, enabled: bool) -> Self {
214        self.minimap = enabled;
215        self
216    }
217
218    /// When the minimap is enabled, toggle whether bytes are painted in
219    /// the highlight palette's colours or as a simple grayscale gradient
220    /// keyed on byte value. Off is less busy.
221    pub fn minimap_colored(mut self, colored: bool) -> Self {
222        self.minimap_colored = colored;
223        self
224    }
225
226    pub fn columns(mut self, cols: ColumnCount) -> Self {
227        self.columns = cols;
228        self
229    }
230
231    /// Toggle value-class highlighting. `None` disables, `Some(mode)`
232    /// enables with either a background fill or text recoloring.
233    pub fn value_highlight(mut self, mode: Option<ValueHighlight>) -> Self {
234        self.value_highlight = mode;
235        self
236    }
237
238    /// Override the built-in theme-based palette. Use this to plug in a
239    /// class palette, a value gradient, or (later) a custom colour
240    /// scheme.
241    pub fn palette(mut self, palette: HighlightPalette) -> Self {
242        self.palette_override = Some(palette);
243        self
244    }
245
246    /// Install a context-menu callback rendered when the user
247    /// right-clicks anywhere in the hex or ASCII pane. Callers use this
248    /// to add per-app commands like Copy.
249    pub fn context_menu(mut self, add_contents: impl FnOnce(&mut egui::Ui) + 's) -> Self {
250        self.context_menu = Some(Box::new(add_contents));
251        self
252    }
253
254    pub fn show(self, ui: &mut Ui) -> HexViewResponse {
255        let Self {
256            source,
257            columns,
258            selection,
259            value_highlight,
260            palette_override,
261            byte_styler,
262            address_formatter,
263            column_header_formatter,
264            context_menu,
265            minimap,
266            minimap_colored,
267            initial_scroll,
268            scroll_to_byte,
269            hover_span,
270            field_boundaries,
271            field_colors,
272            id_salt,
273        } = self;
274        let salt = id_salt.unwrap_or_else(|| ui.id().with("hxy_hex_view"));
275        ui.push_id(salt, |ui| {
276            let palette = value_highlight.map(|mode| {
277                let palette = palette_override
278                    .unwrap_or_else(|| HighlightPalette::for_theme_and_mode(ui.visuals().dark_mode, mode));
279                (mode, palette)
280            });
281            let total_rows = row_count(source.len(), columns);
282            let address_chars = address_hex_width(source.len());
283            let font_id = TextStyle::Monospace.resolve(ui.style());
284            let row_height = ui.text_style_height(&TextStyle::Monospace);
285            let char_w = measure_char_width(ui, &font_id);
286            let layout = RowLayout::compute(char_w, address_chars, columns);
287            let source_len = source.len();
288
289            let mut response = HexViewResponse::default();
290
291            paint_column_header(ui, &layout, &font_id, row_height, column_header_formatter.as_deref());
292
293            let scroll_id = ui.id().with("hxy_scroll");
294            // Minimap click, explicit `scroll_to`, or a stashed pending value
295            // from a prior frame can all drive the next scroll position.
296            // `scroll_to_byte` takes precedence: compute the target row's
297            // top Y from columns + row_height.
298            let pending_offset = scroll_to_byte
299                .map(|b| {
300                    let row = b.get() / u64::from(columns.get());
301                    (row as f32) * row_height
302                })
303                .or_else(|| ui.ctx().data_mut(|d| d.remove_temp::<f32>(scroll_id)))
304                .or(initial_scroll);
305
306            let minimap_width = if minimap { (char_w * 8.0).max(48.0) } else { 0.0 };
307            let scrollbar_width = ui.style().spacing.scroll.bar_width.max(10.0);
308            let avail = ui.available_rect_before_wrap();
309            let hex_rect = Rect::from_min_size(
310                avail.min,
311                Vec2::new(avail.width() - minimap_width - scrollbar_width, avail.height()),
312            );
313            let minimap_rect =
314                Rect::from_min_size(Pos2::new(hex_rect.right(), avail.top()), Vec2::new(minimap_width, avail.height()));
315            let scrollbar_rect = Rect::from_min_size(
316                Pos2::new(minimap_rect.right(), avail.top()),
317                Vec2::new(scrollbar_width, avail.height()),
318            );
319
320            let hex_out = ui
321                .scope_builder(egui::UiBuilder::new().max_rect(hex_rect), |ui| {
322                    // Hex view owns scroll state but the visible bar lives
323                    // in the rightmost column (past the minimap) as a
324                    // separate widget, so we always hide the inner bar.
325                    let mut area = egui::ScrollArea::vertical()
326                        .auto_shrink([false, false])
327                        .id_salt(scroll_id)
328                        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
329                    if let Some(target) = pending_offset {
330                        area = area.vertical_scroll_offset(target);
331                    }
332                    area.show(ui, |ui| {
333                        paint_and_interact(
334                            ui,
335                            &layout,
336                            &font_id,
337                            row_height,
338                            total_rows,
339                            source_len,
340                            columns,
341                            source,
342                            selection,
343                            palette.clone(),
344                            byte_styler.as_deref(),
345                            address_formatter.as_deref(),
346                            context_menu,
347                            hover_span,
348                            field_boundaries,
349                            &mut response,
350                        );
351                    })
352                })
353                .inner;
354
355            response.scroll_offset = hex_out.state.offset.y;
356            response.viewport_height = hex_out.inner_rect.height();
357
358            if minimap {
359                draw_minimap(
360                    ui,
361                    scroll_id,
362                    minimap_rect,
363                    source,
364                    source_len,
365                    palette,
366                    minimap_colored,
367                    row_height,
368                    hex_out.state.offset.y,
369                    hex_out.inner_rect.height(),
370                    total_rows,
371                    hover_span,
372                    field_boundaries,
373                    field_colors,
374                );
375            }
376
377            draw_scrollbar(
378                ui,
379                scroll_id,
380                scrollbar_rect,
381                hex_out.state.offset.y,
382                hex_out.inner_rect.height(),
383                total_rows as f32 * row_height,
384            );
385
386            response
387        })
388        .inner
389    }
390}
391
392#[derive(Default)]
393pub struct HexViewResponse {
394    pub hovered_offset: Option<ByteOffset>,
395    pub error: Option<CoreError>,
396    /// Scroll offset (in content pixels) at the end of this frame.
397    pub scroll_offset: f32,
398    /// Visible viewport height (in pixels).
399    pub viewport_height: f32,
400    /// Byte range actually rendered this frame (after clipping). Useful
401    /// for consumers that want to paint overlays only over visible bytes.
402    pub visible_range: Option<ByteRange>,
403    /// Cursor byte offset from the current selection. Mirrors
404    /// `selection.cursor`. None when no selection is set.
405    pub cursor_offset: Option<ByteOffset>,
406    /// Geometry info for the just-rendered frame. Lets consumers compute
407    /// the screen rect of any visible byte (useful for painting overlays
408    /// from outside the widget).
409    pub layout: Option<HexViewLayout>,
410}
411
412/// Immutable snapshot of the HexView's per-frame geometry. Values are in
413/// screen coordinates (absolute, already accounting for scroll). Only
414/// valid within the current egui frame.
415#[derive(Clone, Copy, Debug)]
416pub struct HexViewLayout {
417    block_rect: Rect,
418    row_height: f32,
419    columns: ColumnCount,
420    source_len: ByteLen,
421    inner: RowLayout,
422}
423
424impl HexViewLayout {
425    /// Columns rendered per row.
426    pub fn columns(&self) -> ColumnCount {
427        self.columns
428    }
429
430    /// Screen rect of the hex cell for the given byte offset, if the
431    /// offset is within the source. The rect may fall outside the
432    /// currently-visible viewport -- callers doing overlay painting
433    /// should intersect with the viewport clip.
434    pub fn hex_cell_rect(&self, offset: ByteOffset) -> Option<Rect> {
435        let (row_origin, col) = self.row_origin_and_col(offset)?;
436        Some(self.inner.hex_cell_rect(row_origin, col, self.row_height))
437    }
438
439    /// Screen rect of the ASCII cell for the given byte offset.
440    pub fn ascii_cell_rect(&self, offset: ByteOffset) -> Option<Rect> {
441        let (row_origin, col) = self.row_origin_and_col(offset)?;
442        Some(self.inner.ascii_cell_rect(row_origin, col, self.row_height))
443    }
444
445    /// Screen rect spanning a contiguous run of cells on a single row
446    /// (from column `from` through column `to`, inclusive). Useful for
447    /// drawing a bracket over an entire row's worth of selection. If
448    /// the range crosses multiple rows, callers should issue one span
449    /// rect per row.
450    pub fn hex_span_rect(&self, row: hxy_core::RowIndex, from: usize, to: usize) -> Option<Rect> {
451        let cols = usize::from(self.columns.get());
452        if from >= cols || to >= cols || from > to {
453            return None;
454        }
455        let row_origin = self.row_origin_for_row(row)?;
456        Some(self.inner.hex_span_rect(row_origin, from, to, self.row_height))
457    }
458
459    fn row_origin_and_col(&self, offset: ByteOffset) -> Option<(Pos2, usize)> {
460        if offset.get() >= self.source_len.get() {
461            return None;
462        }
463        let cols = u64::from(self.columns.get());
464        let row = offset.get() / cols;
465        let col = (offset.get() % cols) as usize;
466        Some((self.row_origin_for_row(hxy_core::RowIndex::new(row))?, col))
467    }
468
469    fn row_origin_for_row(&self, row: hxy_core::RowIndex) -> Option<Pos2> {
470        let y = self.block_rect.top() + row.get() as f32 * self.row_height;
471        Some(Pos2::new(self.block_rect.left(), y))
472    }
473}
474
475fn row_count(len: ByteLen, columns: ColumnCount) -> usize {
476    let len = len.get();
477    if len == 0 {
478        return 0;
479    }
480    let rows = len.div_ceil(columns.as_u64());
481    usize::try_from(rows).unwrap_or(usize::MAX)
482}
483
484fn address_hex_width(len: ByteLen) -> usize {
485    let bits_needed = 64 - len.get().saturating_sub(1).leading_zeros() as usize;
486    bits_needed.div_ceil(4).max(8)
487}
488
489fn measure_char_width(ui: &Ui, font_id: &FontId) -> f32 {
490    let painter = ui.painter();
491    let galley = painter.layout_no_wrap("0".to_string(), font_id.clone(), Color32::WHITE);
492    galley.size().x
493}
494
495/// Precomputed x-offsets for every slot in a row. Addressed in "points".
496#[derive(Clone, Copy, Debug)]
497struct RowLayout {
498    address_w: f32,
499    hex_start_x: f32,
500    hex_cell_w: f32,
501    hex_gap: f32,
502    ascii_start_x: f32,
503    ascii_cell_w: f32,
504    total_width: f32,
505    columns: ColumnCount,
506    address_chars: usize,
507}
508
509impl RowLayout {
510    fn compute(char_w: f32, address_chars: usize, columns: ColumnCount) -> Self {
511        let address_w = char_w * address_chars as f32;
512        let hex_gap = char_w * 0.5;
513        let hex_cell_w = char_w * 2.0;
514        let cols_f = f32::from(columns.get());
515        let hex_total = cols_f * hex_cell_w + (cols_f - 1.0) * hex_gap;
516        let section_gap = char_w * 2.0;
517        let hex_start_x = address_w + section_gap;
518        let ascii_cell_w = char_w;
519        let ascii_start_x = hex_start_x + hex_total + section_gap;
520        let ascii_total = cols_f * ascii_cell_w;
521        Self {
522            address_w,
523            hex_start_x,
524            hex_cell_w,
525            hex_gap,
526            ascii_start_x,
527            ascii_cell_w,
528            total_width: ascii_start_x + ascii_total,
529            columns,
530            address_chars,
531        }
532    }
533
534    /// Background-tint rect for hex cell `col` that bleeds halfway into
535    /// each side-gap so adjacent tints touch with no visible seam. The
536    /// first/last columns clamp to the pane edges.
537    fn hex_tint_rect(&self, row_origin: Pos2, col: usize, total_cols: usize, row_height: f32) -> Rect {
538        let cell = self.hex_cell_rect(row_origin, col, row_height);
539        let left = if col == 0 { cell.left() } else { cell.left() - self.hex_gap / 2.0 };
540        let right = if col + 1 >= total_cols { cell.right() } else { cell.right() + self.hex_gap / 2.0 };
541        Rect::from_min_max(Pos2::new(left, cell.top()), Pos2::new(right, cell.bottom()))
542    }
543
544    /// ASCII tint rect: ASCII cells already sit edge-to-edge, so this is
545    /// just the cell rect with no rounding.
546    fn ascii_tint_rect(&self, row_origin: Pos2, col: usize, _total_cols: usize, row_height: f32) -> Rect {
547        self.ascii_cell_rect(row_origin, col, row_height)
548    }
549
550    fn hex_cell_rect(&self, row_origin: Pos2, col: usize, row_height: f32) -> Rect {
551        let left = row_origin.x + self.hex_start_x + (col as f32) * (self.hex_cell_w + self.hex_gap);
552        Rect::from_min_size(Pos2::new(left, row_origin.y), Vec2::new(self.hex_cell_w, row_height))
553    }
554
555    /// Rect spanning hex columns `from..=to` contiguously (no internal
556    /// gaps between cells in the span).
557    fn hex_span_rect(&self, row_origin: Pos2, from: usize, to: usize, row_height: f32) -> Rect {
558        let start = self.hex_cell_rect(row_origin, from, row_height).left();
559        let end = self.hex_cell_rect(row_origin, to, row_height).right();
560        Rect::from_min_max(Pos2::new(start, row_origin.y), Pos2::new(end, row_origin.y + row_height))
561    }
562
563    fn ascii_cell_rect(&self, row_origin: Pos2, col: usize, row_height: f32) -> Rect {
564        let left = row_origin.x + self.ascii_start_x + (col as f32) * self.ascii_cell_w;
565        Rect::from_min_size(Pos2::new(left, row_origin.y), Vec2::new(self.ascii_cell_w, row_height))
566    }
567
568    fn ascii_span_rect(&self, row_origin: Pos2, from: usize, to: usize, row_height: f32) -> Rect {
569        let start = self.ascii_cell_rect(row_origin, from, row_height).left();
570        let end = self.ascii_cell_rect(row_origin, to, row_height).right();
571        Rect::from_min_max(Pos2::new(start, row_origin.y), Pos2::new(end, row_origin.y + row_height))
572    }
573
574    fn address_rect(&self, row_origin: Pos2, row_height: f32) -> Rect {
575        Rect::from_min_size(row_origin, Vec2::new(self.address_w, row_height))
576    }
577
578    /// Map a pointer position within the rendered block to `(row_in_block,
579    /// column)`. `row_in_block` is clamped to `0..num_rows`; `column` is
580    /// clamped to `0..columns`. Returns `None` only if the pointer's x is
581    /// before the hex pane or past the ASCII pane.
582    fn hit_test(&self, block_rect: Rect, pos: Pos2, row_height: f32, num_rows: usize) -> Option<HitRowCol> {
583        let x = (pos.x - block_rect.left()).max(0.0);
584        let y = (pos.y - block_rect.top()).clamp(0.0, num_rows.saturating_sub(1) as f32 * row_height);
585        let row = ((y / row_height) as usize).min(num_rows.saturating_sub(1));
586
587        let cols = usize::from(self.columns.get());
588        if x >= self.hex_start_x && x < self.ascii_start_x {
589            let local = x - self.hex_start_x;
590            let stride = self.hex_cell_w + self.hex_gap;
591            let col = ((local / stride) as usize).min(cols - 1);
592            return Some(HitRowCol { row, col });
593        }
594        let ascii_end = self.ascii_start_x + (cols as f32) * self.ascii_cell_w;
595        if x >= self.ascii_start_x && x < ascii_end {
596            let local = x - self.ascii_start_x;
597            let col = ((local / self.ascii_cell_w) as usize).min(cols - 1);
598            return Some(HitRowCol { row, col });
599        }
600        None
601    }
602}
603
604#[derive(Clone, Copy)]
605struct HitRowCol {
606    row: usize,
607    col: usize,
608}
609
610/// Everything the painter needs that stays constant for the whole frame.
611struct PaintCtx<'a> {
612    layout: &'a RowLayout,
613    font_id: &'a FontId,
614    row_height: f32,
615    columns: ColumnCount,
616    palette: Option<(ValueHighlight, HighlightPalette)>,
617    byte_styler: Option<&'a dyn Fn(u8, ByteOffset) -> ByteStyle>,
618    address_formatter: Option<&'a dyn Fn(ByteOffset, usize) -> String>,
619    colors: RowColors,
620    selected_range: Option<ByteRange>,
621    cursor_offset: Option<ByteOffset>,
622    hover_offset: Option<ByteOffset>,
623    hover_span: Option<ByteRange>,
624    field_boundaries: &'a [(ByteOffset, ByteLen)],
625}
626
627/// Geometry + source metadata used for pointer hit-testing against the
628/// full (scrolled) content rect.
629struct HitCtx<'a> {
630    layout: &'a RowLayout,
631    block_rect: Rect,
632    row_height: f32,
633    columns: ColumnCount,
634    total_rows: usize,
635    source_len: ByteLen,
636}
637
638#[derive(Clone, Copy)]
639struct RowColors {
640    text: Color32,
641    weak: Color32,
642    selection_bg: Color32,
643    selection_fg: Color32,
644    cursor_stroke: Stroke,
645    hover_stroke: Stroke,
646}
647
648#[allow(clippy::too_many_arguments)]
649fn paint_and_interact<S: HexSource + ?Sized>(
650    ui: &mut Ui,
651    layout: &RowLayout,
652    font_id: &FontId,
653    row_height: f32,
654    total_rows: usize,
655    source_len: ByteLen,
656    columns: ColumnCount,
657    source: &S,
658    selection: &mut Option<Selection>,
659    palette: Option<(ValueHighlight, HighlightPalette)>,
660    byte_styler: Option<&dyn Fn(u8, ByteOffset) -> ByteStyle>,
661    address_formatter: Option<&dyn Fn(ByteOffset, usize) -> String>,
662    context_menu: Option<ContextMenuFn<'_>>,
663    hover_span: Option<ByteRange>,
664    field_boundaries: &[(ByteOffset, ByteLen)],
665    response_out: &mut HexViewResponse,
666) {
667    let cols = usize::from(columns.get());
668    let total_height = total_rows as f32 * row_height;
669    let block_size = Vec2::new(layout.total_width, total_height);
670    let (block_rect, response) = ui.allocate_exact_size(block_size, Sense::click_and_drag());
671
672    let hit = HitCtx { layout, block_rect, row_height, columns, total_rows, source_len };
673
674    // Clip-driven visibility: paint only rows that intersect the scroll
675    // area's clip rect, letting the area scroll with pixel granularity
676    // rather than snapping to whole rows.
677    let Some((first_visible, last_visible_exclusive)) =
678        visible_rows(&block_rect, row_height, total_rows, ui.clip_rect())
679    else {
680        response_out.hovered_offset = None;
681        if let Some(add) = context_menu {
682            response.context_menu(add);
683        }
684        return;
685    };
686
687    let range_start = (first_visible as u64).saturating_mul(cols as u64).min(source_len.get());
688    let range_end = (last_visible_exclusive as u64).saturating_mul(cols as u64).min(source_len.get());
689    let Ok(read_range) = ByteRange::new(ByteOffset::new(range_start), ByteOffset::new(range_end)) else {
690        return;
691    };
692    let bytes = match source.read(read_range) {
693        Ok(b) => b,
694        Err(e) => {
695            response_out.error = Some(e);
696            return;
697        }
698    };
699
700    let weak = ui.visuals().weak_text_color();
701    let colors = RowColors {
702        text: ui.visuals().text_color(),
703        weak,
704        selection_bg: ui.visuals().selection.bg_fill,
705        selection_fg: ui.visuals().selection.stroke.color,
706        cursor_stroke: Stroke::new(1.5, ui.visuals().strong_text_color()),
707        hover_stroke: Stroke::new(1.0, weak.gamma_multiply(0.9)),
708    };
709
710    let selected_range = selection.and_then(|s| {
711        let r = s.range();
712        if r.is_empty() { None } else { Some(r) }
713    });
714    let cursor_offset = selection.map(|s| s.cursor);
715    let hover_offset = hovered_byte(ui, &response, &hit);
716
717    let ctx = PaintCtx {
718        layout,
719        font_id,
720        row_height,
721        columns,
722        palette,
723        byte_styler,
724        address_formatter,
725        colors,
726        selected_range,
727        cursor_offset,
728        hover_offset,
729        hover_span,
730        field_boundaries,
731    };
732    let painter = ui.painter_at(block_rect);
733    paint_rows(&painter, &ctx, block_rect, first_visible, &bytes);
734
735    apply_interaction(ui, &response, &hit, selection);
736
737    response_out.hovered_offset = hover_offset;
738    response_out.cursor_offset = cursor_offset;
739    response_out.visible_range = Some(read_range);
740    response_out.layout = Some(HexViewLayout { block_rect, row_height, columns, source_len, inner: *layout });
741
742    if let Some(add) = context_menu {
743        response.context_menu(add);
744    }
745}
746
747fn visible_rows(block_rect: &Rect, row_height: f32, total_rows: usize, clip: Rect) -> Option<(usize, usize)> {
748    if total_rows == 0 {
749        return None;
750    }
751    let total_height = total_rows as f32 * row_height;
752    let visible_top = (clip.top() - block_rect.top()).max(0.0);
753    let visible_bottom = (clip.bottom() - block_rect.top()).clamp(0.0, total_height);
754    if visible_bottom <= visible_top {
755        return None;
756    }
757    let first = (visible_top / row_height).floor() as usize;
758    let last = ((visible_bottom / row_height).ceil() as usize).min(total_rows);
759    if first >= last { None } else { Some((first, last)) }
760}
761
762/// Paint in two passes so marker strokes (cursor/hover) end up on top of
763/// every neighboring row's tint -- otherwise the cell to the right of the
764/// cursor can paint its tint *over* the cursor stroke.
765fn paint_rows(painter: &egui::Painter, ctx: &PaintCtx<'_>, block_rect: Rect, first_visible: usize, bytes: &[u8]) {
766    let cols = usize::from(ctx.columns.get());
767
768    for (chunk_idx, chunk) in bytes.chunks(cols).enumerate() {
769        let row_idx = first_visible + chunk_idx;
770        let row_origin = row_origin_for(block_rect, row_idx, ctx.row_height);
771        let row_first_offset = ByteOffset::new((row_idx as u64) * (cols as u64));
772        paint_row_backs_and_glyphs(painter, ctx, row_origin, row_first_offset, chunk);
773    }
774    for (chunk_idx, chunk) in bytes.chunks(cols).enumerate() {
775        let row_idx = first_visible + chunk_idx;
776        let row_origin = row_origin_for(block_rect, row_idx, ctx.row_height);
777        let row_first_offset = ByteOffset::new((row_idx as u64) * (cols as u64));
778        paint_row_marks(painter, ctx, row_origin, row_first_offset, chunk.len());
779    }
780}
781
782fn row_origin_for(block_rect: Rect, row_idx: usize, row_height: f32) -> Pos2 {
783    Pos2::new(block_rect.left(), block_rect.top() + (row_idx as f32) * row_height)
784}
785
786fn paint_row_backs_and_glyphs(
787    painter: &egui::Painter,
788    ctx: &PaintCtx<'_>,
789    row_origin: Pos2,
790    row_first_offset: ByteOffset,
791    chunk: &[u8],
792) {
793    let cols = usize::from(ctx.columns.get());
794
795    painter.text(
796        ctx.layout.address_rect(row_origin, ctx.row_height).left_center(),
797        Align2::LEFT_CENTER,
798        match ctx.address_formatter {
799            Some(f) => f(row_first_offset, ctx.layout.address_chars),
800            None => format_address(row_first_offset, ctx.layout.address_chars),
801        },
802        ctx.font_id.clone(),
803        ctx.colors.weak,
804    );
805
806    if let Some(range) = ctx.selected_range {
807        paint_row_selection(
808            painter,
809            ctx.layout,
810            row_origin,
811            ctx.row_height,
812            row_first_offset,
813            chunk.len(),
814            range,
815            ctx.colors.selection_bg,
816        );
817    }
818    if let Some(range) = ctx.hover_span {
819        // Paint hover underneath the selection so the user's explicit
820        // selection colour stays authoritative. Gamma-multiply the
821        // selection background to get a softer tint that reads as a
822        // secondary marker rather than a primary highlight.
823        let tint = ctx.colors.selection_bg.gamma_multiply(0.45);
824        paint_row_selection(
825            painter,
826            ctx.layout,
827            row_origin,
828            ctx.row_height,
829            row_first_offset,
830            chunk.len(),
831            range,
832            tint,
833        );
834    }
835
836    for (i, byte) in chunk.iter().enumerate() {
837        let byte_offset = ByteOffset::new(row_first_offset.get() + i as u64);
838        let hex_rect = ctx.layout.hex_cell_rect(row_origin, i, ctx.row_height);
839        let ascii_rect = ctx.layout.ascii_cell_rect(row_origin, i, ctx.row_height);
840        let is_sel = ctx.selected_range.is_some_and(|r| r.contains(byte_offset));
841
842        // Palette-derived defaults for this byte.
843        let class_color = ctx.palette.as_ref().map(|(_, p)| p.color_for(*byte));
844        let (palette_bg, palette_fg) = match ctx.palette.as_ref().map(|(m, _)| *m) {
845            Some(ValueHighlight::Background) => {
846                let bg = class_color;
847                let fg = contrast_text_color(class_color.unwrap_or(ctx.colors.text), ctx.colors.text);
848                (bg, fg)
849            }
850            Some(ValueHighlight::Text) => (None, class_color.unwrap_or(ctx.colors.text)),
851            None => (None, ctx.colors.text),
852        };
853
854        // Per-byte styler overrides; `None` fields fall back to palette.
855        let user_style = ctx.byte_styler.map(|f| f(*byte, byte_offset));
856        let bg = user_style.and_then(|s| s.bg).or(palette_bg);
857        let fg_override = user_style.and_then(|s| s.fg);
858
859        if let Some(color) = bg.filter(|_| !is_sel) {
860            let hex_tint = ctx.layout.hex_tint_rect(row_origin, i, cols, ctx.row_height);
861            let ascii_tint = ctx.layout.ascii_tint_rect(row_origin, i, cols, ctx.row_height);
862            painter.rect_filled(hex_tint, 0.0, color);
863            painter.rect_filled(ascii_tint, 0.0, color);
864        }
865
866        let fg = if is_sel {
867            ctx.colors.selection_fg
868        } else if let Some(f) = fg_override {
869            f
870        } else if let Some(color) = bg {
871            // Styler supplied a bg but no fg -- pick a contrast color.
872            contrast_text_color(color, ctx.colors.text)
873        } else {
874            palette_fg
875        };
876
877        painter.text(hex_rect.center(), Align2::CENTER_CENTER, format!("{byte:02X}"), ctx.font_id.clone(), fg);
878        let ch = if (0x20..0x7f).contains(byte) { *byte as char } else { '.' };
879        painter.text(ascii_rect.center(), Align2::CENTER_CENTER, ch.to_string(), ctx.font_id.clone(), fg);
880    }
881}
882
883fn paint_row_marks(
884    painter: &egui::Painter,
885    ctx: &PaintCtx<'_>,
886    row_origin: Pos2,
887    row_first_offset: ByteOffset,
888    chunk_len: usize,
889) {
890    let cols = usize::from(ctx.columns.get());
891    paint_row_field_outlines(painter, ctx, row_origin, row_first_offset, chunk_len, cols);
892    for i in 0..chunk_len.min(cols) {
893        let byte_offset = ByteOffset::new(row_first_offset.get() + i as u64);
894        let hex_rect = ctx.layout.hex_cell_rect(row_origin, i, ctx.row_height);
895        let ascii_rect = ctx.layout.ascii_cell_rect(row_origin, i, ctx.row_height);
896        let hex_mark = hex_rect.expand2(Vec2::new(ctx.layout.hex_gap * 0.35, 2.0));
897        let ascii_mark = ascii_rect.expand2(Vec2::new(0.5, 2.0));
898        if ctx.cursor_offset == Some(byte_offset) {
899            painter.rect_stroke(hex_mark, 2.0, ctx.colors.cursor_stroke, StrokeKind::Middle);
900            painter.rect_stroke(ascii_mark, 2.0, ctx.colors.cursor_stroke, StrokeKind::Middle);
901        } else if ctx.hover_offset == Some(byte_offset) {
902            painter.rect_stroke(hex_mark, 2.0, ctx.colors.hover_stroke, StrokeKind::Middle);
903            painter.rect_stroke(ascii_mark, 2.0, ctx.colors.hover_stroke, StrokeKind::Middle);
904        }
905    }
906}
907
908/// Paint hairline outlines around every template-field span that
909/// overlaps this row. One rect per field per row; adjacent fields
910/// meet edge-to-edge (the hex-cell gap is split down the middle so
911/// their vertical edges share a column). Stroke endpoints are
912/// snapped to physical pixel centres so 1px lines stay crisp.
913fn paint_row_field_outlines(
914    painter: &egui::Painter,
915    ctx: &PaintCtx<'_>,
916    row_origin: Pos2,
917    row_first_offset: ByteOffset,
918    chunk_len: usize,
919    cols: usize,
920) {
921    if ctx.field_boundaries.is_empty() || chunk_len == 0 {
922        return;
923    }
924    let stroke = Stroke::new(1.0, ctx.colors.weak.gamma_multiply(0.7));
925    let row_first = row_first_offset.get();
926    let row_last_exclusive = row_first + chunk_len.min(cols) as u64;
927    let row_visible_cols = chunk_len.min(cols);
928
929    let first_idx =
930        ctx.field_boundaries.partition_point(|(start, len)| start.get().saturating_add(len.get()) <= row_first);
931
932    for (start, len) in &ctx.field_boundaries[first_idx..] {
933        let field_start = start.get();
934        let field_end = field_start.saturating_add(len.get());
935        if field_start >= row_last_exclusive {
936            break;
937        }
938        let seg_start = field_start.max(row_first);
939        let seg_end = field_end.min(row_last_exclusive);
940        if seg_start >= seg_end {
941            continue;
942        }
943        let first_col = (seg_start - row_first) as usize;
944        let last_col = (seg_end - row_first - 1) as usize;
945
946        let top_edge = field_start == seg_start;
947        let bottom_edge = field_end == seg_end;
948        let left_edge = field_start == seg_start;
949        let right_edge = field_end == seg_end;
950
951        let hex_rect = hex_outline_rect(ctx.layout, row_origin, ctx.row_height, first_col, last_col, row_visible_cols);
952        let ascii_rect =
953            ascii_outline_rect(ctx.layout, row_origin, ctx.row_height, first_col, last_col, row_visible_cols);
954
955        for rect in [hex_rect, ascii_rect] {
956            paint_rect_edges(painter, rect, stroke, top_edge, bottom_edge, left_edge, right_edge);
957        }
958    }
959}
960
961/// Rect covering hex columns `[first_col..=last_col]` with each side
962/// bleeding halfway into the adjacent inter-cell gap -- matches the
963/// tint-rect geometry so outlines and field tints coincide exactly.
964/// First / last columns of the row clamp to the pane edges so two
965/// rows' worth of outlines don't step sideways at the wrap.
966fn hex_outline_rect(
967    layout: &RowLayout,
968    row_origin: Pos2,
969    row_height: f32,
970    first_col: usize,
971    last_col: usize,
972    total_cols: usize,
973) -> Rect {
974    let left_cell = layout.hex_cell_rect(row_origin, first_col, row_height);
975    let right_cell = layout.hex_cell_rect(row_origin, last_col, row_height);
976    let half_gap = layout.hex_gap * 0.5;
977    let left = if first_col == 0 { left_cell.left() } else { left_cell.left() - half_gap };
978    let right = if last_col + 1 >= total_cols { right_cell.right() } else { right_cell.right() + half_gap };
979    Rect::from_min_max(Pos2::new(left, row_origin.y), Pos2::new(right, row_origin.y + row_height))
980}
981
982/// ASCII cells already sit edge-to-edge, so the span rect needs no
983/// gap fudging -- the tint-rect math is just `cell_rect` union.
984fn ascii_outline_rect(
985    layout: &RowLayout,
986    row_origin: Pos2,
987    row_height: f32,
988    first_col: usize,
989    last_col: usize,
990    _total_cols: usize,
991) -> Rect {
992    let left = layout.ascii_cell_rect(row_origin, first_col, row_height).left();
993    let right = layout.ascii_cell_rect(row_origin, last_col, row_height).right();
994    Rect::from_min_max(Pos2::new(left, row_origin.y), Pos2::new(right, row_origin.y + row_height))
995}
996
997fn paint_rect_edges(
998    painter: &egui::Painter,
999    rect: Rect,
1000    stroke: Stroke,
1001    top: bool,
1002    bottom: bool,
1003    left: bool,
1004    right: bool,
1005) {
1006    let ppp = painter.pixels_per_point();
1007    let snap_x = |x: f32| (x * ppp).round() / ppp + 0.5 / ppp;
1008    let snap_y = |y: f32| (y * ppp).round() / ppp + 0.5 / ppp;
1009    let l = snap_x(rect.left());
1010    let r = snap_x(rect.right());
1011    let t = snap_y(rect.top());
1012    let b = snap_y(rect.bottom());
1013    if top {
1014        painter.line_segment([Pos2::new(l, t), Pos2::new(r, t)], stroke);
1015    }
1016    if bottom {
1017        painter.line_segment([Pos2::new(l, b), Pos2::new(r, b)], stroke);
1018    }
1019    if left {
1020        painter.line_segment([Pos2::new(l, t), Pos2::new(l, b)], stroke);
1021    }
1022    if right {
1023        painter.line_segment([Pos2::new(r, t), Pos2::new(r, b)], stroke);
1024    }
1025}
1026
1027#[allow(clippy::too_many_arguments)]
1028fn paint_row_selection(
1029    painter: &egui::Painter,
1030    layout: &RowLayout,
1031    row_origin: Pos2,
1032    row_height: f32,
1033    row_first_offset: ByteOffset,
1034    chunk_len: usize,
1035    selection: ByteRange,
1036    bg: Color32,
1037) {
1038    let cols = usize::from(layout.columns.get());
1039    let row_start = row_first_offset.get();
1040    let row_end = row_start + chunk_len as u64;
1041
1042    let sel_start = selection.start().get();
1043    let sel_end = selection.end().get();
1044    if sel_end <= row_start || sel_start >= row_end {
1045        return;
1046    }
1047
1048    let local_from = (sel_start.saturating_sub(row_start)) as usize;
1049    let local_to_exclusive = (sel_end.min(row_end).saturating_sub(row_start)) as usize;
1050    if local_to_exclusive == 0 || local_from >= cols {
1051        return;
1052    }
1053    let local_to = local_to_exclusive.saturating_sub(1);
1054
1055    let hex_bar = layout.hex_span_rect(row_origin, local_from, local_to, row_height);
1056    let ascii_bar = layout.ascii_span_rect(row_origin, local_from, local_to, row_height);
1057    painter.rect_filled(hex_bar, 2.0, bg);
1058    painter.rect_filled(ascii_bar, 2.0, bg);
1059}
1060
1061fn apply_interaction(ui: &Ui, response: &egui::Response, hit: &HitCtx<'_>, selection: &mut Option<Selection>) {
1062    let cols = usize::from(hit.columns.get());
1063    let shift = ui.input(|i| i.modifiers.shift);
1064    let active = response.dragged() || response.drag_started() || response.clicked();
1065    if !active {
1066        return;
1067    }
1068
1069    let pos = response.interact_pointer_pos().or_else(|| ui.ctx().input(|i| i.pointer.interact_pos()));
1070    let Some(pos) = pos else { return };
1071    let Some(rc) = hit.layout.hit_test(hit.block_rect, pos, hit.row_height, hit.total_rows) else {
1072        return;
1073    };
1074    let Some(hit_offset) = hit_to_offset(rc, cols, hit.source_len) else { return };
1075
1076    if response.drag_started() {
1077        *selection = Some(match (shift, *selection) {
1078            (true, Some(existing)) => Selection { anchor: existing.anchor, cursor: hit_offset },
1079            _ => Selection::caret(hit_offset),
1080        });
1081    } else if response.dragged() {
1082        match selection.as_mut() {
1083            Some(s) => s.cursor = hit_offset,
1084            None => *selection = Some(Selection::caret(hit_offset)),
1085        }
1086    } else if response.clicked() {
1087        *selection = Some(match (shift, *selection) {
1088            (true, Some(existing)) => Selection { anchor: existing.anchor, cursor: hit_offset },
1089            _ => Selection::caret(hit_offset),
1090        });
1091    }
1092}
1093
1094fn hit_to_offset(hit: HitRowCol, cols: usize, source_len: ByteLen) -> Option<ByteOffset> {
1095    let offset = (hit.row as u64).checked_mul(cols as u64)?.checked_add(hit.col as u64)?;
1096    if offset >= source_len.get() {
1097        if source_len.get() == 0 {
1098            return None;
1099        }
1100        return Some(ByteOffset::new(source_len.get() - 1));
1101    }
1102    Some(ByteOffset::new(offset))
1103}
1104
1105#[allow(clippy::too_many_arguments)]
1106fn hovered_byte(ui: &Ui, response: &egui::Response, hit: &HitCtx<'_>) -> Option<ByteOffset> {
1107    let pos = response.hover_pos().or_else(|| {
1108        response.is_pointer_button_down_on().then(|| ui.ctx().input(|i| i.pointer.latest_pos())).flatten()
1109    })?;
1110    if !hit.block_rect.contains(pos) {
1111        return None;
1112    }
1113    let rc = hit.layout.hit_test(hit.block_rect, pos, hit.row_height, hit.total_rows)?;
1114    hit_to_offset(rc, usize::from(hit.columns.get()), hit.source_len)
1115}
1116
1117/// Colour source for byte-value tinting. Plugins can hand a fully-
1118/// specified 256-entry table via [`Self::Custom`] so a template run
1119/// can override the user's default palette for the duration.
1120#[derive(Clone, Debug)]
1121pub enum HighlightPalette {
1122    Class(BytePalette),
1123    Value(ValueGradient),
1124    /// Plugin-supplied palette: one colour per byte value, held
1125    /// behind an `Arc` so cloning the enum is cheap.
1126    Custom(std::sync::Arc<[Color32; 256]>),
1127}
1128
1129impl HighlightPalette {
1130    pub fn for_theme_and_mode(dark: bool, mode: ValueHighlight) -> Self {
1131        Self::Class(BytePalette::for_theme_and_mode(dark, mode))
1132    }
1133
1134    pub fn color_for(&self, byte: u8) -> Color32 {
1135        match self {
1136            Self::Class(p) => p.color_for(byte),
1137            Self::Value(g) => g.color_for(byte),
1138            Self::Custom(table) => table[byte as usize],
1139        }
1140    }
1141}
1142
1143/// Every byte value gets a unique colour from a fixed HSL hue wheel.
1144/// Saturation and lightness are tuned per theme/mode so the resulting
1145/// colours stay readable under the view's fixed text contrast rules.
1146#[derive(Clone, Copy, Debug)]
1147pub struct ValueGradient {
1148    pub saturation: f32,
1149    pub lightness: f32,
1150}
1151
1152impl ValueGradient {
1153    pub const BG_DARK: Self = Self { saturation: 0.55, lightness: 0.32 };
1154    pub const BG_LIGHT: Self = Self { saturation: 0.5, lightness: 0.78 };
1155    pub const TEXT_DARK: Self = Self { saturation: 0.75, lightness: 0.68 };
1156    pub const TEXT_LIGHT: Self = Self { saturation: 0.7, lightness: 0.4 };
1157
1158    pub fn for_theme_and_mode(dark: bool, mode: ValueHighlight) -> Self {
1159        match (dark, mode) {
1160            (true, ValueHighlight::Background) => Self::BG_DARK,
1161            (false, ValueHighlight::Background) => Self::BG_LIGHT,
1162            (true, ValueHighlight::Text) => Self::TEXT_DARK,
1163            (false, ValueHighlight::Text) => Self::TEXT_LIGHT,
1164        }
1165    }
1166
1167    pub fn color_for(&self, byte: u8) -> Color32 {
1168        let hue = (f32::from(byte) / 256.0) * 360.0;
1169        hsl_to_rgb(hue, self.saturation, self.lightness)
1170    }
1171}
1172
1173fn hsl_to_rgb(h: f32, s: f32, l: f32) -> Color32 {
1174    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
1175    let h_norm = h / 60.0;
1176    let x = c * (1.0 - (h_norm.rem_euclid(2.0) - 1.0).abs());
1177    let (r1, g1, b1) = match h_norm as u32 {
1178        0 => (c, x, 0.0),
1179        1 => (x, c, 0.0),
1180        2 => (0.0, c, x),
1181        3 => (0.0, x, c),
1182        4 => (x, 0.0, c),
1183        _ => (c, 0.0, x),
1184    };
1185    let m = l - c / 2.0;
1186    let cv = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
1187    Color32::from_rgb(cv(r1), cv(g1), cv(b1))
1188}
1189
1190/// Palette for byte-class tinting. Each variant of [`ByteClass`] maps
1191/// to one colour. Rendered text remains readable via the view's
1192/// contrast-adjustment.
1193#[derive(Clone, Copy, Debug)]
1194pub struct BytePalette {
1195    pub null: Color32,
1196    pub all_bits: Color32,
1197    pub whitespace: Color32,
1198    pub printable: Color32,
1199    pub control: Color32,
1200    pub extended: Color32,
1201}
1202
1203impl BytePalette {
1204    /// Pick a palette variant appropriate for the theme and highlight
1205    /// mode. Background mode uses muted semi-transparent tints;
1206    /// text mode uses saturated opaque colors readable against the theme
1207    /// background.
1208    pub fn for_theme_and_mode(dark: bool, mode: ValueHighlight) -> Self {
1209        match (dark, mode) {
1210            (true, ValueHighlight::Background) => Self::BG_DARK,
1211            (false, ValueHighlight::Background) => Self::BG_LIGHT,
1212            (true, ValueHighlight::Text) => Self::TEXT_DARK,
1213            (false, ValueHighlight::Text) => Self::TEXT_LIGHT,
1214        }
1215    }
1216
1217    pub const BG_DARK: Self = Self {
1218        null: Color32::from_rgb(60, 60, 64),
1219        all_bits: Color32::from_rgb(200, 150, 40),
1220        whitespace: Color32::from_rgb(50, 90, 140),
1221        printable: Color32::from_rgb(40, 120, 60),
1222        control: Color32::from_rgb(150, 60, 60),
1223        extended: Color32::from_rgb(120, 60, 140),
1224    };
1225
1226    pub const BG_LIGHT: Self = Self {
1227        null: Color32::from_rgb(220, 220, 220),
1228        all_bits: Color32::from_rgb(245, 215, 110),
1229        whitespace: Color32::from_rgb(180, 210, 240),
1230        printable: Color32::from_rgb(190, 235, 200),
1231        control: Color32::from_rgb(240, 190, 190),
1232        extended: Color32::from_rgb(225, 195, 240),
1233    };
1234
1235    pub const TEXT_DARK: Self = Self {
1236        null: Color32::from_rgb(140, 140, 140),
1237        all_bits: Color32::from_rgb(255, 200, 80),
1238        whitespace: Color32::from_rgb(120, 180, 240),
1239        printable: Color32::from_rgb(120, 220, 140),
1240        control: Color32::from_rgb(240, 130, 130),
1241        extended: Color32::from_rgb(210, 140, 230),
1242    };
1243
1244    pub const TEXT_LIGHT: Self = Self {
1245        null: Color32::from_rgb(120, 120, 120),
1246        all_bits: Color32::from_rgb(180, 120, 20),
1247        whitespace: Color32::from_rgb(30, 90, 180),
1248        printable: Color32::from_rgb(30, 130, 60),
1249        control: Color32::from_rgb(180, 50, 50),
1250        extended: Color32::from_rgb(130, 40, 170),
1251    };
1252
1253    pub fn color_for(&self, byte: u8) -> Color32 {
1254        match ByteClass::of(byte) {
1255            ByteClass::Null => self.null,
1256            ByteClass::AllBits => self.all_bits,
1257            ByteClass::Whitespace => self.whitespace,
1258            ByteClass::Printable => self.printable,
1259            ByteClass::Control => self.control,
1260            ByteClass::Extended => self.extended,
1261        }
1262    }
1263}
1264
1265/// Pick a glyph color for text painted on top of `bg`. Brighter
1266/// backgrounds get a darker grey; darker backgrounds get near-white. The
1267/// `default_fg` is returned unchanged when `bg` is transparent.
1268fn contrast_text_color(bg: Color32, default_fg: Color32) -> Color32 {
1269    if bg.a() == 0 {
1270        return default_fg;
1271    }
1272    let luminance = 0.299 * f32::from(bg.r()) + 0.587 * f32::from(bg.g()) + 0.114 * f32::from(bg.b());
1273    let t = (luminance / 255.0).clamp(0.0, 1.0);
1274    let white = 240.0_f32;
1275    let gray = 30.0_f32;
1276    let v = (white * (1.0 - t) + gray * t).round() as u8;
1277    Color32::from_rgb(v, v, v)
1278}
1279
1280/// Coarse categorization of a byte value for palette lookup.
1281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1282pub(crate) enum ByteClass {
1283    Null,
1284    AllBits,
1285    Whitespace,
1286    Printable,
1287    Control,
1288    Extended,
1289}
1290
1291impl ByteClass {
1292    pub(crate) fn of(byte: u8) -> Self {
1293        match byte {
1294            0x00 => Self::Null,
1295            0xFF => Self::AllBits,
1296            b'\t' | b'\n' | b'\r' => Self::Whitespace,
1297            0x01..=0x1F | 0x7F => Self::Control,
1298            0x20..=0x7E => Self::Printable,
1299            0x80..=0xFE => Self::Extended,
1300        }
1301    }
1302}
1303
1304#[allow(clippy::too_many_arguments)]
1305fn draw_minimap<S: HexSource + ?Sized>(
1306    ui: &mut Ui,
1307    scroll_id: egui::Id,
1308    minimap_rect: Rect,
1309    source: &S,
1310    source_len: ByteLen,
1311    palette: Option<(ValueHighlight, HighlightPalette)>,
1312    colored: bool,
1313    row_height: f32,
1314    current_offset: f32,
1315    viewport_height: f32,
1316    total_rows: usize,
1317    hover_span: Option<ByteRange>,
1318    field_boundaries: &[(ByteOffset, ByteLen)],
1319    field_colors: &[Color32],
1320) {
1321    if minimap_rect.width() < 1.0 || minimap_rect.height() < 1.0 || source_len.get() == 0 {
1322        return;
1323    }
1324    let cols = 16usize;
1325    let response = ui.allocate_rect(minimap_rect, Sense::click_and_drag());
1326    let painter = ui.painter_at(minimap_rect);
1327    painter.rect_filled(minimap_rect, 0.0, ui.visuals().extreme_bg_color);
1328
1329    let cell_w = (minimap_rect.width() / cols as f32).max(1.0);
1330    // Fixed zoom: each hex row gets a constant number of minimap pixels
1331    // regardless of file size. The minimap is a window onto the file
1332    // that scrolls with the main viewport, not a whole-file overview.
1333    let cell_h = 2.0_f32;
1334
1335    let minimap_capacity_rows = (minimap_rect.height() / cell_h).floor() as usize;
1336    if minimap_capacity_rows == 0 {
1337        return;
1338    }
1339    let fallback = ui.visuals().text_color();
1340    let len = source_len.get();
1341    let dark = ui.visuals().dark_mode;
1342
1343    // Map the minimap window's top row linearly to the file's scroll
1344    // fraction. That way the viewport indicator travels the full height
1345    // of the minimap as you scroll from start to end -- like a regular
1346    // scrollbar -- instead of pinning itself to the middle.
1347    let viewport_top_row_f = (current_offset / row_height).max(0.0);
1348    let viewport_rows_f = (viewport_height / row_height).max(1.0);
1349    let capacity_f = minimap_capacity_rows as f32;
1350    let content_height = total_rows as f32 * row_height;
1351    let max_scroll = (content_height - viewport_height).max(0.0);
1352    let scroll_frac = if max_scroll > 0.0 { (current_offset / max_scroll).clamp(0.0, 1.0) } else { 0.0 };
1353    let max_top = (total_rows as f32 - capacity_f).max(0.0);
1354    let window_top_f = scroll_frac * max_top;
1355    let window_top_row = window_top_f.floor() as u64;
1356    let shown_rows = minimap_capacity_rows.min(total_rows.saturating_sub(window_top_row as usize));
1357
1358    // Single contiguous read for all rows visible in the window.
1359    let read_start = window_top_row.saturating_mul(cols as u64).min(len);
1360    let read_end = read_start.saturating_add(shown_rows as u64 * cols as u64).min(len);
1361    let bytes = ByteRange::new(ByteOffset::new(read_start), ByteOffset::new(read_end))
1362        .ok()
1363        .and_then(|r| source.read(r).ok())
1364        .unwrap_or_default();
1365
1366    let field_override = !field_boundaries.is_empty() && !field_colors.is_empty();
1367    for i in 0..shown_rows {
1368        let chunk_start = i * cols;
1369        if chunk_start >= bytes.len() {
1370            break;
1371        }
1372        let chunk_end = (chunk_start + cols).min(bytes.len());
1373        let chunk = &bytes[chunk_start..chunk_end];
1374        let y = minimap_rect.top() + i as f32 * cell_h;
1375        let row_base_offset = read_start + (i as u64) * cols as u64;
1376        for (c, byte) in chunk.iter().enumerate() {
1377            let x = minimap_rect.left() + c as f32 * cell_w;
1378            let offset = row_base_offset + c as u64;
1379            let field_color =
1380                if field_override { field_color_for(field_boundaries, field_colors, offset) } else { None };
1381            let color = field_color.unwrap_or_else(|| {
1382                if colored {
1383                    palette.as_ref().map(|(_, p)| p.color_for(*byte)).unwrap_or(fallback)
1384                } else {
1385                    grayscale_for_byte(*byte, dark)
1386                }
1387            });
1388            painter.rect_filled(Rect::from_min_size(Pos2::new(x, y), Vec2::new(cell_w, cell_h)), 0.0, color);
1389        }
1390    }
1391
1392    // Viewport indicator at its absolute position inside the scrolled
1393    // window. High-contrast outline + accent bracket for readability
1394    // over any palette.
1395    let indicator_top_y = minimap_rect.top() + (viewport_top_row_f - window_top_f) * cell_h;
1396    let indicator_height = viewport_rows_f * cell_h;
1397    let indicator = Rect::from_min_max(
1398        Pos2::new(minimap_rect.left(), indicator_top_y.max(minimap_rect.top())),
1399        Pos2::new(minimap_rect.right(), (indicator_top_y + indicator_height).min(minimap_rect.bottom())),
1400    );
1401    let (fill, outline) = if dark {
1402        (Color32::from_rgba_unmultiplied(255, 255, 255, 70), Color32::WHITE)
1403    } else {
1404        (Color32::from_rgba_unmultiplied(0, 0, 0, 70), Color32::from_rgb(20, 20, 20))
1405    };
1406    painter.rect_filled(indicator, 0.0, fill);
1407    painter.rect_stroke(indicator, 0.0, Stroke::new(2.0, outline), StrokeKind::Inside);
1408    let accent = ui.visuals().selection.bg_fill;
1409    let bracket = Rect::from_min_max(indicator.left_top(), Pos2::new(indicator.left() + 4.0, indicator.bottom()));
1410    painter.rect_filled(bracket, 0.0, accent);
1411
1412    // Hover-span marker: mirrors the secondary highlight the hex view
1413    // draws when the template panel is pointing at a field. When the
1414    // span is outside the currently-shown minimap window, draw a
1415    // small caret at the top/bottom edge so the user still has a
1416    // direction to scroll.
1417    if let Some(span) = hover_span {
1418        paint_hover_span_on_minimap(
1419            &painter,
1420            minimap_rect,
1421            span,
1422            cols as u64,
1423            cell_h,
1424            window_top_row,
1425            shown_rows as u64,
1426            accent,
1427        );
1428    }
1429
1430    // Click/drag maps pointer y to a position in the *whole file* so a
1431    // top->bottom drag on the minimap scrolls from file start to end in
1432    // one motion, regardless of how much content the fixed-zoom window
1433    // happens to be showing right now.
1434    let pointer = response
1435        .interact_pointer_pos()
1436        .or_else(|| response.hover_pos().filter(|_| response.is_pointer_button_down_on()));
1437    if let Some(pos) = pointer.filter(|_| response.dragged() || response.clicked() || response.drag_started()) {
1438        let y = (pos.y - minimap_rect.top()).clamp(0.0, minimap_rect.height());
1439        let frac = y / minimap_rect.height();
1440        let target_scroll = (frac * max_scroll).clamp(0.0, max_scroll);
1441        ui.ctx().data_mut(|d| d.insert_temp(scroll_id, target_scroll));
1442        ui.ctx().request_repaint();
1443    }
1444}
1445
1446/// Paint the template-panel's hover span on the minimap. Splits into
1447/// two cases: the span intersects the currently-visible minimap
1448/// window (-> highlight the matching rows), or the span is off-screen
1449/// (-> small caret at the top or bottom edge pointing toward it).
1450#[allow(clippy::too_many_arguments)]
1451fn paint_hover_span_on_minimap(
1452    painter: &egui::Painter,
1453    minimap_rect: Rect,
1454    span: ByteRange,
1455    cols: u64,
1456    cell_h: f32,
1457    window_top_row: u64,
1458    shown_rows: u64,
1459    accent: Color32,
1460) {
1461    let start = span.start().get();
1462    let end_exclusive = span.end().get();
1463    if end_exclusive <= start || cols == 0 {
1464        return;
1465    }
1466    let span_first_row = start / cols;
1467    let span_last_row_inclusive = (end_exclusive - 1) / cols;
1468    let window_end_row = window_top_row.saturating_add(shown_rows);
1469
1470    // Out-of-window markers: draw a thin caret at the edge the span
1471    // lies beyond so the user knows which way to scroll.
1472    if span_last_row_inclusive < window_top_row {
1473        let top = minimap_rect.top();
1474        let caret = Rect::from_min_size(Pos2::new(minimap_rect.right() - 6.0, top), Vec2::new(6.0, 4.0));
1475        painter.rect_filled(caret, 0.0, accent);
1476        return;
1477    }
1478    if span_first_row >= window_end_row {
1479        let bottom = minimap_rect.bottom();
1480        let caret = Rect::from_min_size(Pos2::new(minimap_rect.right() - 6.0, bottom - 4.0), Vec2::new(6.0, 4.0));
1481        painter.rect_filled(caret, 0.0, accent);
1482        return;
1483    }
1484
1485    // Span intersects the window -- shade the overlap rows.
1486    let shaded_top_row = span_first_row.max(window_top_row);
1487    let shaded_bot_row_inclusive = span_last_row_inclusive.min(window_end_row.saturating_sub(1));
1488    let rel_top = (shaded_top_row - window_top_row) as f32 * cell_h;
1489    let rel_bot = ((shaded_bot_row_inclusive + 1) - window_top_row) as f32 * cell_h;
1490    let rect = Rect::from_min_max(
1491        Pos2::new(minimap_rect.left(), minimap_rect.top() + rel_top),
1492        Pos2::new(minimap_rect.right(), minimap_rect.top() + rel_bot),
1493    );
1494    // Translucent fill on top of the minimap cells, plus an accent
1495    // line along the left edge so single-row spans still register.
1496    painter.rect_filled(rect, 0.0, accent.gamma_multiply(0.35));
1497    let edge = Rect::from_min_size(rect.left_top(), Vec2::new(2.0, rect.height()));
1498    painter.rect_filled(edge, 0.0, accent);
1499}
1500
1501/// Custom vertical scrollbar rendered in the strip to the right of the
1502/// minimap. The inner scroll area's own bar is hidden, so this is the
1503/// only visible scroll indicator. Click/drag maps pointer y linearly to
1504/// the file's full scroll range, like the minimap's interaction.
1505fn draw_scrollbar(
1506    ui: &mut Ui,
1507    scroll_id: egui::Id,
1508    rect: Rect,
1509    current_offset: f32,
1510    viewport_height: f32,
1511    content_height: f32,
1512) {
1513    if rect.width() < 1.0 || rect.height() < 1.0 {
1514        return;
1515    }
1516    let response = ui.allocate_rect(rect, Sense::click_and_drag());
1517    let painter = ui.painter_at(rect);
1518
1519    let track_color = ui.visuals().extreme_bg_color;
1520    painter.rect_filled(rect, 3.0, track_color);
1521
1522    if content_height <= viewport_height {
1523        return;
1524    }
1525
1526    let viewport_frac = (viewport_height / content_height).clamp(0.05, 1.0);
1527    let max_scroll = (content_height - viewport_height).max(1.0);
1528    let scroll_frac = (current_offset / max_scroll).clamp(0.0, 1.0);
1529
1530    let thumb_h = (viewport_frac * rect.height()).max(18.0);
1531    let thumb_top = rect.top() + scroll_frac * (rect.height() - thumb_h);
1532    let thumb_rect =
1533        Rect::from_min_size(Pos2::new(rect.left() + 2.0, thumb_top), Vec2::new(rect.width() - 4.0, thumb_h));
1534
1535    let widget_visuals = if response.is_pointer_button_down_on() {
1536        ui.visuals().widgets.active
1537    } else if response.hovered() {
1538        ui.visuals().widgets.hovered
1539    } else {
1540        ui.visuals().widgets.inactive
1541    };
1542    painter.rect_filled(thumb_rect, 3.0, widget_visuals.bg_fill);
1543
1544    let pointer = response
1545        .interact_pointer_pos()
1546        .or_else(|| response.hover_pos().filter(|_| response.is_pointer_button_down_on()));
1547    if let Some(pos) = pointer.filter(|_| response.dragged() || response.clicked() || response.drag_started()) {
1548        let y = (pos.y - rect.top() - thumb_h * 0.5).clamp(0.0, rect.height() - thumb_h);
1549        let frac = if rect.height() > thumb_h { y / (rect.height() - thumb_h) } else { 0.0 };
1550        let target = (frac * max_scroll).clamp(0.0, max_scroll);
1551        ui.ctx().data_mut(|d| d.insert_temp(scroll_id, target));
1552        ui.ctx().request_repaint();
1553    }
1554}
1555
1556/// Uncoloured minimap fallback. Byte value 0x00 maps to the theme's
1557/// darkest content shade and 0xFF to near-white (or the opposite on
1558/// light mode), giving a faint brightness gradient that still reveals
1559/// structure without dragging in the palette.
1560/// Look up the template-field colour for `byte_offset` by binary-
1561/// searching the sorted `boundaries`. Returns `None` when the offset
1562/// doesn't fall inside any field or when `colors` is too short.
1563fn field_color_for(boundaries: &[(ByteOffset, ByteLen)], colors: &[Color32], byte_offset: u64) -> Option<Color32> {
1564    let idx = boundaries.partition_point(|(start, _)| start.get() <= byte_offset);
1565    if idx == 0 {
1566        return None;
1567    }
1568    let (start, len) = boundaries[idx - 1];
1569    let end = start.get().saturating_add(len.get());
1570    if byte_offset < end { colors.get(idx - 1).copied() } else { None }
1571}
1572
1573fn grayscale_for_byte(byte: u8, dark: bool) -> Color32 {
1574    let t = f32::from(byte) / 255.0;
1575    let (lo, hi) = if dark { (40.0, 230.0) } else { (40.0, 220.0) };
1576    let v = (lo * (1.0 - t) + hi * t).round() as u8;
1577    Color32::from_rgb(v, v, v)
1578}
1579
1580/// Paint a one-row header with column indices ("0" through "f" in a 16-
1581/// column view) aligned with each hex cell. Rendered outside the scroll
1582/// area so it stays in view while scrolling.
1583fn paint_column_header(
1584    ui: &mut Ui,
1585    layout: &RowLayout,
1586    font_id: &FontId,
1587    row_height: f32,
1588    formatter: Option<&dyn Fn(usize) -> String>,
1589) {
1590    let cols = usize::from(layout.columns.get());
1591    let header_height = row_height * 0.75;
1592    let (header_rect, _) = ui.allocate_exact_size(Vec2::new(layout.total_width, header_height), Sense::empty());
1593    let painter = ui.painter_at(header_rect);
1594    let color = ui.visuals().weak_text_color();
1595    let origin = header_rect.min;
1596    for col in 0..cols {
1597        let label = match formatter {
1598            Some(f) => f(col),
1599            None => format!("{col:X}"),
1600        };
1601        let cell = layout.hex_cell_rect(origin, col, header_height);
1602        painter.text(cell.center(), Align2::CENTER_CENTER, &label, font_id.clone(), color);
1603        let ascii_cell = layout.ascii_cell_rect(origin, col, header_height);
1604        painter.text(ascii_cell.center(), Align2::CENTER_CENTER, &label, font_id.clone(), color);
1605    }
1606    let divider_y = header_rect.bottom();
1607    painter.line_segment(
1608        [Pos2::new(header_rect.left(), divider_y), Pos2::new(header_rect.right(), divider_y)],
1609        Stroke::new(1.0, ui.visuals().weak_text_color().gamma_multiply(0.5)),
1610    );
1611}
1612
1613fn format_address(offset: ByteOffset, width: usize) -> String {
1614    format!("{:0width$X}", offset.get(), width = width)
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619    use super::*;
1620
1621    #[test]
1622    fn address_width_scales_with_length() {
1623        assert_eq!(address_hex_width(ByteLen::new(0)), 8);
1624        assert_eq!(address_hex_width(ByteLen::new(256)), 8);
1625        assert_eq!(address_hex_width(ByteLen::new(1u64 << 32)), 8);
1626        assert_eq!(address_hex_width(ByteLen::new((1u64 << 32) + 1)), 9);
1627    }
1628
1629    #[test]
1630    fn row_count_handles_partial_row() {
1631        let cols = ColumnCount::new(16).unwrap();
1632        assert_eq!(row_count(ByteLen::new(0), cols), 0);
1633        assert_eq!(row_count(ByteLen::new(1), cols), 1);
1634        assert_eq!(row_count(ByteLen::new(16), cols), 1);
1635        assert_eq!(row_count(ByteLen::new(17), cols), 2);
1636    }
1637
1638    #[test]
1639    fn format_address_zero_pads() {
1640        assert_eq!(format_address(ByteOffset::new(0x1a), 8), "0000001A");
1641    }
1642
1643    #[test]
1644    fn hex_span_covers_contiguous_columns() {
1645        let cols = ColumnCount::new(16).unwrap();
1646        let layout = RowLayout::compute(10.0, 8, cols);
1647        let origin = Pos2::ZERO;
1648        let span = layout.hex_span_rect(origin, 3, 7, 20.0);
1649        let c3 = layout.hex_cell_rect(origin, 3, 20.0);
1650        let c7 = layout.hex_cell_rect(origin, 7, 20.0);
1651        assert_eq!(span.left(), c3.left());
1652        assert_eq!(span.right(), c7.right());
1653    }
1654
1655    #[test]
1656    fn hit_test_clamps_row_past_last_visible() {
1657        let cols = ColumnCount::new(16).unwrap();
1658        let layout = RowLayout::compute(10.0, 8, cols);
1659        let block = Rect::from_min_size(Pos2::ZERO, Vec2::new(layout.total_width, 60.0));
1660        // Pointer below the last row should clamp to last row (num_rows=3).
1661        let below = Pos2::new(layout.hex_start_x + 5.0, 1000.0);
1662        let hit = layout.hit_test(block, below, 20.0, 3).unwrap();
1663        assert_eq!(hit.row, 2);
1664    }
1665}