Snap byte offsets to character boundaries in the preview layout to prevent slicing inside multi-byte characters. Preserve all whitespace (not just newlines) in redacted regions. Force dark theme explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
493 lines
16 KiB
Rust
493 lines
16 KiB
Rust
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<Range<usize>>,
|
|
}
|
|
|
|
impl RedactorApp {
|
|
fn toggle_redaction(&mut self, sel: Range<usize>) {
|
|
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<usize>) -> Range<usize> {
|
|
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<usize>) -> 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<usize>) {
|
|
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<usize>) {
|
|
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<usize>],
|
|
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)
|
|
}
|