use eframe::egui; use std::ops::Range; // Nord palette const NORD0: egui::Color32 = egui::Color32::from_rgb(0x2E, 0x34, 0x40); const NORD1: egui::Color32 = egui::Color32::from_rgb(0x3B, 0x42, 0x52); const NORD2: egui::Color32 = egui::Color32::from_rgb(0x43, 0x4C, 0x5E); const NORD3: egui::Color32 = egui::Color32::from_rgb(0x4C, 0x56, 0x6A); const NORD4: egui::Color32 = egui::Color32::from_rgb(0xD8, 0xDE, 0xE9); const NORD6: egui::Color32 = egui::Color32::from_rgb(0xEC, 0xEF, 0xF4); const NORD8: egui::Color32 = egui::Color32::from_rgb(0x88, 0xC0, 0xD0); const NORD9: egui::Color32 = egui::Color32::from_rgb(0x81, 0xA1, 0xC1); const NORD11: egui::Color32 = egui::Color32::from_rgb(0xBF, 0x61, 0x6A); const NORD13: egui::Color32 = egui::Color32::from_rgb(0xEB, 0xCB, 0x8B); const BRASS_MONO: &[u8] = include_bytes!("../fonts/BrassMonoCode-Regular.ttf"); fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 600.0]), ..Default::default() }; eframe::run_native( "Redactor", options, Box::new(|cc| { configure_fonts(&cc.egui_ctx); apply_nord_theme(&cc.egui_ctx); Ok(Box::new(RedactorApp::default())) }), ) } fn configure_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "BrassMono".to_owned(), egui::FontData::from_static(BRASS_MONO).into(), ); // Use Brass Mono as the primary monospace and proportional font fonts .families .entry(egui::FontFamily::Monospace) .or_default() .insert(0, "BrassMono".to_owned()); fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "BrassMono".to_owned()); ctx.set_fonts(fonts); } fn apply_nord_theme(ctx: &egui::Context) { ctx.set_theme(egui::Theme::Dark); let mut style = (*ctx.style()).clone(); let v = &mut style.visuals; v.dark_mode = true; v.override_text_color = Some(NORD4); // Backgrounds v.panel_fill = NORD0; v.window_fill = NORD0; v.extreme_bg_color = NORD1; v.faint_bg_color = NORD1; // Widgets v.widgets.noninteractive.bg_fill = NORD1; v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, NORD4); v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, NORD2); v.widgets.inactive.bg_fill = NORD2; v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, NORD4); v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, NORD3); v.widgets.hovered.bg_fill = NORD3; v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, NORD6); v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, NORD8); v.widgets.active.bg_fill = NORD3; v.widgets.active.fg_stroke = egui::Stroke::new(1.0, NORD6); v.widgets.active.bg_stroke = egui::Stroke::new(1.0, NORD9); v.selection.bg_fill = egui::Color32::from_rgba_unmultiplied(0x81, 0xA1, 0xC1, 100); v.selection.stroke = egui::Stroke::new(1.0, NORD8); v.window_stroke = egui::Stroke::new(1.0, NORD2); v.hyperlink_color = NORD8; ctx.set_style(style); } const FONT_SIZE: f32 = 14.0; #[derive(Default)] struct RedactorApp { source: String, /// Byte ranges in `source` that are redacted. Kept sorted and non-overlapping. redactions: Vec>, } impl RedactorApp { fn toggle_redaction(&mut self, sel: Range) { if sel.is_empty() { return; } let sel = self.snap_to_char_boundaries(sel); if self.fully_redacted(&sel) { self.remove_redaction(&sel); } else { self.add_redaction(sel); } } fn snap_to_char_boundaries(&self, r: Range) -> Range { let start = { let mut i = r.start.min(self.source.len()); while i > 0 && !self.source.is_char_boundary(i) { i -= 1; } i }; let end = { let mut i = r.end.min(self.source.len()); while i < self.source.len() && !self.source.is_char_boundary(i) { i += 1; } i }; start..end } fn fully_redacted(&self, sel: &Range) -> bool { let mut pos = sel.start; for r in &self.redactions { if r.end <= pos { continue; } if r.start > pos { return false; } pos = r.end; if pos >= sel.end { return true; } } pos >= sel.end } fn add_redaction(&mut self, sel: Range) { let mut merged = Vec::new(); let mut new = sel.clone(); let mut inserted = false; for r in self.redactions.drain(..) { if r.end < new.start { merged.push(r); } else if r.start > new.end { if !inserted { merged.push(new.clone()); inserted = true; } merged.push(r); } else { new.start = new.start.min(r.start); new.end = new.end.max(r.end); } } if !inserted { merged.push(new); } self.redactions = merged; } fn remove_redaction(&mut self, sel: &Range) { let mut result = Vec::new(); for r in self.redactions.drain(..) { if r.end <= sel.start || r.start >= sel.end { result.push(r); } else { if r.start < sel.start { result.push(r.start..sel.start); } if r.end > sel.end { result.push(sel.end..r.end); } } } self.redactions = result; } fn redacted_text(&self) -> String { if self.redactions.is_empty() { return self.source.clone(); } let mut out = String::with_capacity(self.source.len()); let mut pos = 0; for r in &self.redactions { if pos < r.start { out.push_str(&self.source[pos..r.start]); } for ch in self.source[r.start..r.end].chars() { if ch.is_whitespace() { out.push(ch); } else { out.push('\u{2588}'); } } pos = r.end; } if pos < self.source.len() { out.push_str(&self.source[pos..]); } out } fn reconcile_redactions(&mut self, old_source: &str) { if old_source == self.source { return; } let old_bytes = old_source.as_bytes(); let new_bytes = self.source.as_bytes(); let prefix_len = old_bytes .iter() .zip(new_bytes.iter()) .take_while(|(a, b)| a == b) .count(); let suffix_len = old_bytes .iter() .rev() .zip(new_bytes.iter().rev()) .take_while(|(a, b)| a == b) .count() .min(old_bytes.len() - prefix_len) .min(new_bytes.len() - prefix_len); let old_end = old_bytes.len() - suffix_len; let new_end = new_bytes.len() - suffix_len; let delta = new_end as isize - old_end as isize; let mut adjusted = Vec::new(); for r in &self.redactions { if r.end <= prefix_len { adjusted.push(r.clone()); } else if r.start >= old_end { let start = (r.start as isize + delta) as usize; let end = (r.end as isize + delta) as usize; adjusted.push(start..end); } } self.redactions = adjusted; } } impl eframe::App for RedactorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if ctx.input(|i| i.key_pressed(egui::Key::Q) && i.modifiers.command) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { ui.horizontal(|ui| { ui.label("Select text then Ctrl+R to toggle redaction"); if ui.button("Copy Redacted").clicked() { ctx.copy_text(self.redacted_text()); } }); }); let panel_width = ctx.screen_rect().width() / 2.0 - 12.0; egui::SidePanel::left("source_panel") .exact_width(panel_width) .show(ctx, |ui| { ui.heading("Source"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { let old_source = self.source.clone(); let redactions = self.redactions.clone(); let mut layouter = |ui: &egui::Ui, text: &str, wrap_width: f32| { let mut job = egui::text::LayoutJob::default(); let font_id = egui::FontId::monospace(FONT_SIZE); let normal = egui::TextFormat { font_id: font_id.clone(), color: NORD4, ..Default::default() }; let redacted_fmt = egui::TextFormat { font_id: font_id.clone(), color: NORD13, background: egui::Color32::from_rgba_unmultiplied(0xBF, 0x61, 0x6A, 60), strikethrough: egui::Stroke::new(1.0, NORD11), ..Default::default() }; let mut pos = 0; for r in &redactions { let rs = r.start.min(text.len()); let re = r.end.min(text.len()); if pos < rs { job.append(&text[pos..rs], 0.0, normal.clone()); } if rs < re { job.append(&text[rs..re], 0.0, redacted_fmt.clone()); } pos = re; } if pos < text.len() { job.append(&text[pos..], 0.0, normal); } job.wrap = egui::text::TextWrapping { max_width: wrap_width, ..Default::default() }; ui.fonts(|f| f.layout_job(job)) }; let response = egui::TextEdit::multiline(&mut self.source) .font(egui::FontId::monospace(FONT_SIZE)) .desired_width(f32::INFINITY) .layouter(&mut layouter) .show(ui); self.reconcile_redactions(&old_source); let ctrl_r = ui.input(|i| { i.key_pressed(egui::Key::R) && i.modifiers.command }); if ctrl_r && let Some(cursor_range) = response.cursor_range { let a = cursor_range.primary.ccursor.index; let b = cursor_range.secondary.ccursor.index; let (start, end) = if a <= b { (a, b) } else { (b, a) }; let byte_start = self .source .char_indices() .nth(start) .map(|(i, _)| i) .unwrap_or(self.source.len()); let byte_end = self .source .char_indices() .nth(end) .map(|(i, _)| i) .unwrap_or(self.source.len()); self.toggle_redaction(byte_start..byte_end); } }); }); egui::CentralPanel::default().show(ctx, |ui| { ui.heading("Redacted Preview"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { let preview = self.redacted_text(); let self_redactions = self.redactions.clone(); let self_source = self.source.clone(); let mut layouter = |ui: &egui::Ui, text: &str, wrap_width: f32| { let mut job = egui::text::LayoutJob::default(); let font_id = egui::FontId::monospace(FONT_SIZE); let normal = egui::TextFormat { font_id: font_id.clone(), color: NORD4, ..Default::default() }; let block_fmt = egui::TextFormat { font_id: font_id.clone(), color: NORD0, background: NORD0, ..Default::default() }; let mut pos = 0; for r in &self_redactions { let out_start = byte_to_preview_byte_static( &self_source, &self_redactions, r.start, ); let out_end = byte_to_preview_byte_static( &self_source, &self_redactions, r.end, ); let os = snap_to_char_boundary(text, out_start.min(text.len())); let oe = snap_to_char_boundary(text, out_end.min(text.len())); if pos < os { job.append(&text[pos..os], 0.0, normal.clone()); } if os < oe { job.append(&text[os..oe], 0.0, block_fmt.clone()); } pos = oe; } if pos < text.len() { job.append(&text[pos..], 0.0, normal); } job.wrap = egui::text::TextWrapping { max_width: wrap_width, ..Default::default() }; ui.fonts(|f| f.layout_job(job)) }; let mut preview_text = preview; egui::TextEdit::multiline(&mut preview_text) .font(egui::FontId::monospace(FONT_SIZE)) .desired_width(f32::INFINITY) .layouter(&mut layouter) .interactive(false) .show(ui); }); }); } } fn snap_to_char_boundary(s: &str, mut i: usize) -> usize { while i > 0 && !s.is_char_boundary(i) { i -= 1; } i } #[allow(unused_assignments)] fn byte_to_preview_byte_static( source: &str, redactions: &[Range], source_byte: usize, ) -> usize { let mut src_pos = 0; let mut preview_pos = 0; for r in redactions { let r_start = r.start.min(source.len()); let r_end = r.end.min(source.len()); if source_byte <= r_start { break; } if src_pos < r_start { preview_pos += r_start - src_pos; src_pos = r_start; } if source_byte <= r_end { for ch in source[r_start..source_byte].chars() { if ch.is_whitespace() { preview_pos += ch.len_utf8(); } else { preview_pos += '\u{2588}'.len_utf8(); } } return preview_pos; } for ch in source[r_start..r_end].chars() { if ch.is_whitespace() { preview_pos += ch.len_utf8(); } else { preview_pos += '\u{2588}'.len_utf8(); } } src_pos = r_end; } preview_pos + (source_byte - src_pos) }