From 176b2aa51236530914e13b3a476f2f33f1591bc4 Mon Sep 17 00:00:00 2001 From: Franco M Date: Sat, 29 Nov 2025 20:48:10 -0300 Subject: [PATCH 1/4] Init --- objdiff-gui/src/views/diff.rs | 51 ++++++- objdiff-gui/src/views/function_diff.rs | 192 ++++++++++++++++++++++--- objdiff-gui/src/views/symbol_diff.rs | 57 +++++++- 3 files changed, 277 insertions(+), 23 deletions(-) diff --git a/objdiff-gui/src/views/diff.rs b/objdiff-gui/src/views/diff.rs index 4d55bc97..628905e5 100644 --- a/objdiff-gui/src/views/diff.rs +++ b/objdiff-gui/src/views/diff.rs @@ -1,4 +1,4 @@ -use std::cmp::Ordering; +use std::{cmp::Ordering, collections::BTreeSet}; use egui::{Id, Layout, RichText, ScrollArea, TextEdit, Ui, Widget, text::LayoutJob}; use objdiff_core::{ @@ -115,6 +115,55 @@ fn get_asm_text( asm_text } +/// Obtains the assembly text for selected rows only, suitable for copying to clipboard. +pub fn get_selected_asm_text( + obj: &Object, + symbol_diff: &SymbolDiff, + symbol_idx: usize, + diff_config: &DiffObjConfig, + selected_rows: &BTreeSet, +) -> String { + let mut asm_text = String::new(); + + for (row_idx, ins_row) in symbol_diff.instruction_rows.iter().enumerate() { + if !selected_rows.contains(&row_idx) { + continue; + } + let mut line = String::new(); + let result = display_row(obj, symbol_idx, ins_row, diff_config, |segment| { + let text = match segment.text { + DiffText::Basic(text) => text.to_string(), + DiffText::Line(num) => format!("{num} "), + DiffText::Address(addr) => format!("{addr:x}:"), + DiffText::Opcode(mnemonic, _op) => format!("{mnemonic} "), + DiffText::Argument(arg) => match arg { + InstructionArgValue::Signed(v) => format!("{:#x}", ReallySigned(v)), + InstructionArgValue::Unsigned(v) => format!("{v:#x}"), + InstructionArgValue::Opaque(v) => v.into_owned(), + }, + DiffText::BranchDest(addr) => format!("{addr:x}"), + DiffText::Symbol(sym) => sym.demangled_name.as_ref().unwrap_or(&sym.name).clone(), + DiffText::Addend(addend) => match addend.cmp(&0i64) { + Ordering::Greater => format!("+{addend:#x}"), + Ordering::Less => format!("-{:#x}", -addend), + _ => String::new(), + }, + DiffText::Spacing(n) => " ".repeat(n.into()), + DiffText::Eol => "\n".to_string(), + }; + line.push_str(&text); + Ok(()) + }); + + if result.is_ok() { + asm_text.push_str(line.trim_end()); + asm_text.push('\n'); + } + } + + asm_text +} + #[must_use] pub fn diff_view_ui( ui: &mut Ui, diff --git a/objdiff-gui/src/views/function_diff.rs b/objdiff-gui/src/views/function_diff.rs index dca1ca70..6b942917 100644 --- a/objdiff-gui/src/views/function_diff.rs +++ b/objdiff-gui/src/views/function_diff.rs @@ -1,4 +1,4 @@ -use std::{cmp::Ordering, default::Default}; +use std::{cmp::Ordering, collections::BTreeSet, default::Default}; use egui::{Label, Response, Sense, Widget, text::LayoutJob}; use egui_extras::TableRow; @@ -24,6 +24,12 @@ use crate::views::{ pub struct FunctionViewState { left_highlight: HighlightKind, right_highlight: HighlightKind, + /// Selected row indices for the left column + pub left_selected_rows: BTreeSet, + /// Selected row indices for the right column + pub right_selected_rows: BTreeSet, + /// Last clicked row index for shift-click range selection + last_selected_row: Option<(usize, usize)>, // (column, row_index) } impl FunctionViewState { @@ -69,6 +75,93 @@ impl FunctionViewState { self.left_highlight = HighlightKind::None; self.right_highlight = HighlightKind::None; } + + /// Get selected rows for a column + pub fn selected_rows(&self, column: usize) -> &BTreeSet { + match column { + 0 => &self.left_selected_rows, + 1 => &self.right_selected_rows, + _ => &self.left_selected_rows, // fallback + } + } + + /// Check if a row is selected in a column + pub fn is_row_selected(&self, column: usize, row_index: usize) -> bool { + match column { + 0 => self.left_selected_rows.contains(&row_index), + 1 => self.right_selected_rows.contains(&row_index), + _ => false, + } + } + + /// Toggle selection of a single row + pub fn toggle_row_selection(&mut self, column: usize, row_index: usize, shift_held: bool) { + let selected_rows = match column { + 0 => &mut self.left_selected_rows, + 1 => &mut self.right_selected_rows, + _ => return, + }; + + if shift_held { + // Range selection: select all rows between last selected and current + if let Some((last_col, last_row)) = self.last_selected_row { + if last_col == column { + let start = last_row.min(row_index); + let end = last_row.max(row_index); + for i in start..=end { + selected_rows.insert(i); + } + } else { + // Different column, just toggle the current row + if selected_rows.contains(&row_index) { + selected_rows.remove(&row_index); + } else { + selected_rows.insert(row_index); + } + } + } else { + // No previous selection, just select the current row + selected_rows.insert(row_index); + } + } else { + // Single toggle + if selected_rows.contains(&row_index) { + selected_rows.remove(&row_index); + } else { + selected_rows.insert(row_index); + } + } + + self.last_selected_row = Some((column, row_index)); + } + + /// Clear all row selections for a column + pub fn clear_row_selection(&mut self, column: usize) { + match column { + 0 => self.left_selected_rows.clear(), + 1 => self.right_selected_rows.clear(), + _ => {} + } + if self.last_selected_row.map_or(false, |(col, _)| col == column) { + self.last_selected_row = None; + } + } + + /// Clear all row selections for all columns + pub fn clear_all_row_selections(&mut self) { + self.left_selected_rows.clear(); + self.right_selected_rows.clear(); + self.last_selected_row = None; + } + + /// Check if any rows are selected in a column + pub fn has_selected_rows(&self, column: usize) -> bool { + match column { + 0 => !self.left_selected_rows.is_empty(), + 1 => !self.right_selected_rows.is_empty(), + _ => false, + } + } } fn ins_hover_ui( @@ -109,10 +202,26 @@ fn ins_context_menu( column: usize, diff_config: &DiffObjConfig, appearance: &Appearance, -) { + has_selection: bool, +) -> Option { + let mut ret = None; + + // Add copy/clear selection options if there are selections + if has_selection { + if ui.button("📋 Copy selected rows").clicked() { + ret = Some(DiffViewAction::CopySelectedRows(column)); + ui.close(); + } + if ui.button("✖ Clear selection").clicked() { + ret = Some(DiffViewAction::ClearRowSelection(column)); + ui.close(); + } + ui.separator(); + } + let Some(resolved) = obj.resolve_instruction_ref(symbol_idx, ins_ref) else { ui.colored_label(appearance.delete_color, "Failed to resolve instruction"); - return; + return ret; }; let ins = match obj.arch.process_instruction(resolved, diff_config) { Ok(ins) => ins, @@ -121,15 +230,19 @@ fn ins_context_menu( appearance.delete_color, format!("Failed to process instruction: {e}"), ); - return; + return ret; } }; ui.scope(|ui| { ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); - context_menu_items_ui(ui, instruction_context(obj, resolved, &ins), column, appearance); + if let Some(action) = context_menu_items_ui(ui, instruction_context(obj, resolved, &ins), column, appearance) { + ret = Some(action); + } }); + + ret } #[must_use] @@ -209,14 +322,25 @@ fn asm_row_ui( ins_view_state: &FunctionViewState, diff_config: &DiffObjConfig, column: usize, + row_index: usize, response_cb: impl Fn(Response) -> Response, ) -> Option { let mut ret = None; ui.spacing_mut().item_spacing.x = 0.0; ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - if ins_diff.kind != InstructionDiffKind::None { + + // Show selection highlight + let is_selected = ins_view_state.is_row_selected(column, row_index); + if is_selected { + ui.painter().rect_filled( + ui.available_rect_before_wrap(), + 0.0, + appearance.highlight_color.gamma_multiply(0.3), + ); + } else if ins_diff.kind != InstructionDiffKind::None { ui.painter().rect_filled(ui.available_rect_before_wrap(), 0.0, ui.visuals().faint_bg_color); } + let space_width = ui.fonts_mut(|f| f.glyph_width(&appearance.code_font, ' ')); display_row(obj, symbol_idx, ins_diff, diff_config, |segment| { if let Some(action) = @@ -241,19 +365,10 @@ pub(crate) fn asm_col_ui( ) -> Option { let mut ret = None; let symbol_ref = ctx.symbol_ref?; - let ins_row = &ctx.diff.symbols[symbol_ref].instruction_rows[row.index()]; - let response_cb = |response: Response| { - if let Some(ins_ref) = ins_row.ins_ref { - response.context_menu(|ui| { - ins_context_menu(ui, ctx.obj, symbol_ref, ins_ref, column, diff_config, appearance) - }); - response.on_hover_ui_at_pointer(|ui| { - ins_hover_ui(ui, ctx.obj, symbol_ref, ins_ref, diff_config, appearance) - }) - } else { - response - } - }; + let row_index = row.index(); + let ins_row = &ctx.diff.symbols[symbol_ref].instruction_rows[row_index]; + let has_selection = ins_view_state.has_selected_rows(column); + let (_, response) = row.col(|ui| { if let Some(action) = asm_row_ui( ui, @@ -264,12 +379,47 @@ pub(crate) fn asm_col_ui( ins_view_state, diff_config, column, - response_cb, + row_index, + |r| r, // Simple passthrough ) { ret = Some(action); } }); - response_cb(response); + + // Handle context menu + if let Some(ins_ref) = ins_row.ins_ref { + response.context_menu(|ui| { + if let Some(action) = ins_context_menu( + ui, ctx.obj, symbol_ref, ins_ref, column, diff_config, appearance, has_selection, + ) { + ret = Some(action); + } + }); + response.on_hover_ui_at_pointer(|ui| { + ins_hover_ui(ui, ctx.obj, symbol_ref, ins_ref, diff_config, appearance) + }); + } else if has_selection { + // Even rows without instructions can have context menu for copy/clear selected + response.context_menu(|ui| { + if ui.button("📋 Copy selected rows").clicked() { + ret = Some(DiffViewAction::CopySelectedRows(column)); + ui.close(); + } + if ui.button("✖ Clear selection").clicked() { + ret = Some(DiffViewAction::ClearRowSelection(column)); + ui.close(); + } + }); + } + + // Handle Ctrl+Click for row selection toggle + if response.clicked() { + let modifiers = response.ctx.input(|i| i.modifiers); + if modifiers.ctrl || modifiers.command { + ret = Some(DiffViewAction::ToggleRowSelection(column, row_index, modifiers.shift)); + } + } + ret } diff --git a/objdiff-gui/src/views/symbol_diff.rs b/objdiff-gui/src/views/symbol_diff.rs index abd49529..e55aaa95 100644 --- a/objdiff-gui/src/views/symbol_diff.rs +++ b/objdiff-gui/src/views/symbol_diff.rs @@ -23,7 +23,7 @@ use crate::{ jobs::{is_create_scratch_available, start_create_scratch}, views::{ appearance::Appearance, - diff::{context_menu_items_ui, hover_items_ui}, + diff::{context_menu_items_ui, get_selected_asm_text, hover_items_ui}, function_diff::FunctionViewState, write_text, }, @@ -81,6 +81,12 @@ pub enum DiffViewAction { SetShowMappedSymbols(bool), /// Set the show_data_flow flag SetShowDataFlow(bool), + /// Toggle row selection for multi-select copy (column, row_index, shift_held) + ToggleRowSelection(usize, usize, bool), + /// Clear row selection for a column + ClearRowSelection(usize), + /// Copy selected rows to clipboard (column) + CopySelectedRows(usize), } #[derive(Debug, Clone, Default, Eq, PartialEq)] @@ -362,7 +368,56 @@ impl DiffViewState { }; state.config.diff_obj_config.show_data_flow = value; } + DiffViewAction::ToggleRowSelection(column, row_index, shift_held) => { + self.function_state.toggle_row_selection(column, row_index, shift_held); + } + DiffViewAction::ClearRowSelection(column) => { + self.function_state.clear_row_selection(column); + } + DiffViewAction::CopySelectedRows(column) => { + let Ok(state) = state.read() else { + return; + }; + let selected_rows = self.function_state.selected_rows(column); + if selected_rows.is_empty() { + return; + } + // Get the selected ASM text + if let Some(text) = self.get_selected_asm_text(column, &state.config.diff_obj_config) + { + ctx.copy_text(text); + } + // Clear selection after copy + self.function_state.clear_row_selection(column); + } + } + } + + /// Get ASM text for selected rows in a column + fn get_selected_asm_text(&self, column: usize, diff_config: &DiffObjConfig) -> Option { + let result = self.build.as_deref()?; + let selected_rows = self.function_state.selected_rows(column); + if selected_rows.is_empty() { + return None; } + + let (obj, diff) = match column { + 0 => result.first_obj.as_ref()?, + 1 => result.second_obj.as_ref()?, + _ => return None, + }; + + // Find the currently viewed symbol + let symbol_ref = match column { + 0 => self.symbol_state.left_symbol.as_ref()?, + 1 => self.symbol_state.right_symbol.as_ref()?, + _ => return None, + }; + + let symbol_idx = obj.symbol_by_name(&symbol_ref.symbol_name)?; + let symbol_diff = diff.symbols.get(symbol_idx)?; + + Some(get_selected_asm_text(obj, symbol_diff, symbol_idx, diff_config, selected_rows)) } fn resolve_symbol( From a73b2cf6b7f5ff88a59a94811e5b0a4491d8c894 Mon Sep 17 00:00:00 2001 From: Franco M Date: Sat, 29 Nov 2025 20:56:20 -0300 Subject: [PATCH 2/4] Update function_diff.rs --- objdiff-gui/src/views/function_diff.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/objdiff-gui/src/views/function_diff.rs b/objdiff-gui/src/views/function_diff.rs index 6b942917..8978c8fd 100644 --- a/objdiff-gui/src/views/function_diff.rs +++ b/objdiff-gui/src/views/function_diff.rs @@ -369,7 +369,7 @@ pub(crate) fn asm_col_ui( let ins_row = &ctx.diff.symbols[symbol_ref].instruction_rows[row_index]; let has_selection = ins_view_state.has_selected_rows(column); - let (_, response) = row.col(|ui| { + let (_, mut response) = row.col(|ui| { if let Some(action) = asm_row_ui( ui, ctx.obj, @@ -388,19 +388,19 @@ pub(crate) fn asm_col_ui( // Handle context menu if let Some(ins_ref) = ins_row.ins_ref { - response.context_menu(|ui| { + response = response.context_menu(|ui| { if let Some(action) = ins_context_menu( ui, ctx.obj, symbol_ref, ins_ref, column, diff_config, appearance, has_selection, ) { ret = Some(action); } }); - response.on_hover_ui_at_pointer(|ui| { + response = response.on_hover_ui_at_pointer(|ui| { ins_hover_ui(ui, ctx.obj, symbol_ref, ins_ref, diff_config, appearance) }); } else if has_selection { // Even rows without instructions can have context menu for copy/clear selected - response.context_menu(|ui| { + response = response.context_menu(|ui| { if ui.button("📋 Copy selected rows").clicked() { ret = Some(DiffViewAction::CopySelectedRows(column)); ui.close(); From 0cba72bb5597240e64d71f9c045ff4e77a81c3bd Mon Sep 17 00:00:00 2001 From: Franco M Date: Sat, 29 Nov 2025 21:03:09 -0300 Subject: [PATCH 3/4] Update function_diff.rs --- objdiff-gui/src/views/function_diff.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/objdiff-gui/src/views/function_diff.rs b/objdiff-gui/src/views/function_diff.rs index 8978c8fd..d84225b9 100644 --- a/objdiff-gui/src/views/function_diff.rs +++ b/objdiff-gui/src/views/function_diff.rs @@ -388,7 +388,7 @@ pub(crate) fn asm_col_ui( // Handle context menu if let Some(ins_ref) = ins_row.ins_ref { - response = response.context_menu(|ui| { + response.context_menu(|ui| { if let Some(action) = ins_context_menu( ui, ctx.obj, symbol_ref, ins_ref, column, diff_config, appearance, has_selection, ) { @@ -400,7 +400,7 @@ pub(crate) fn asm_col_ui( }); } else if has_selection { // Even rows without instructions can have context menu for copy/clear selected - response = response.context_menu(|ui| { + response.context_menu(|ui| { if ui.button("📋 Copy selected rows").clicked() { ret = Some(DiffViewAction::CopySelectedRows(column)); ui.close(); From 30a50785640173e61fe8f448d214947f02d3f270 Mon Sep 17 00:00:00 2001 From: Franco M Date: Sat, 29 Nov 2025 21:10:13 -0300 Subject: [PATCH 4/4] Update function_diff.rs --- objdiff-gui/src/views/function_diff.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/objdiff-gui/src/views/function_diff.rs b/objdiff-gui/src/views/function_diff.rs index d84225b9..9d7cb82c 100644 --- a/objdiff-gui/src/views/function_diff.rs +++ b/objdiff-gui/src/views/function_diff.rs @@ -147,12 +147,6 @@ impl FunctionViewState { } } - /// Clear all row selections for all columns - pub fn clear_all_row_selections(&mut self) { - self.left_selected_rows.clear(); - self.right_selected_rows.clear(); - self.last_selected_row = None; - } /// Check if any rows are selected in a column pub fn has_selected_rows(&self, column: usize) -> bool {