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:
2026-03-31 14:20:11 -07:00
commit 1ad67aea20
8 changed files with 4953 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)"
]
}
}

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/.direnv

4309
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "redactor"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.31"

82
flake.lock generated Normal file
View File

@@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1774709303,
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1774926780,
"narHash": "sha256-JMdDYn0F+swYBILlpCeHDbCSyzqkeSGNxZ/Q5J584jM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "962a0934d0e32f42d1b5e49186f9595f9b178d2d",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

57
flake.nix Normal file
View File

@@ -0,0 +1,57 @@
{
description = "A simple text redaction GUI tool";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rust = pkgs.rust-bin.stable.latest.default;
nativeBuildInputs = with pkgs; [
rust
pkg-config
];
buildInputs = with pkgs; [
# egui/eframe dependencies
libxkbcommon
libGL
wayland
libx11
libxcursor
libxrandr
libxi
vulkan-loader
];
in
{
devShells.default = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
};
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "redactor";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
inherit nativeBuildInputs buildInputs;
postFixup = ''
patchelf --set-rpath "${pkgs.lib.makeLibraryPath buildInputs}" $out/bin/redactor
'';
};
}
);
}

487
src/main.rs Normal file
View 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)
}