Initial commit: two-pane text redaction GUI
Rust/egui app with Nord theme and embedded Brass Mono font. Left pane for editable source text, right pane shows redacted preview. Select text and Ctrl+R to toggle redaction (whitespace preserved). Ctrl+Q to quit, copy-redacted button for clipboard export. Includes Nix flake devshell and direnv integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
487
src/main.rs
Normal file
487
src/main.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
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!(concat!(
|
||||
env!("HOME"),
|
||||
"/.local/share/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) {
|
||||
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 = out_start.min(text.len());
|
||||
let oe = 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[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 == '\n' || ch == '\r' {
|
||||
preview_pos += ch.len_utf8();
|
||||
} else {
|
||||
preview_pos += '\u{2588}'.len_utf8();
|
||||
}
|
||||
}
|
||||
src_pos = r_end;
|
||||
}
|
||||
|
||||
preview_pos + (source_byte - src_pos)
|
||||
}
|
||||
Reference in New Issue
Block a user