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:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo init:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/.direnv
|
||||||
4309
Cargo.lock
generated
Normal file
4309
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "redactor"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.31"
|
||||||
|
|
||||||
82
flake.lock
generated
Normal file
82
flake.lock
generated
Normal 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
57
flake.nix
Normal 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
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