feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-fif"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-fif — find-in-files reusable (estilo JetBrains). Módulo Llimphi: state + Msg + Action + apply/on_key/view. Cualquier app que mantenga una lista de paths puede enchufarlo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files de [llimphi](../../README.md).
Buscar en todos los archivos del workspace con regex + glob de filenames. Streaming de resultados (no espera al fin del scan). Click en resultado abre el archivo en la línea.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files of [llimphi](../../README.md).
Search across all workspace files with regex + filename glob. Streamed results (doesn't wait for scan end). Click on result opens the file at the line.
+815
View File
@@ -0,0 +1,815 @@
//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains).
//!
//! Módulo Llimphi con dos vistas independientes:
//!
//! - [`view_dialog`] — popup compacto (header + input) que el host pinta
//! como overlay modal centrado. Sólo visible cuando
//! [`FifState::dialog_open`] es `true`.
//! - [`view_results_bar`] — barra inferior persistente con la lista de
//! matches. El host la pinta como tool window al pie (estilo JetBrains
//! "Find" tool window). Sobrevive al cierre del dialog: el user puede
//! Esc-cerrar el popup y seguir clickeando los resultados.
//!
//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter
//! ejecuta `search` → resultados aparecen en la barra inferior → Esc
//! cierra el popup pero la barra queda → click en una fila abre el
//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los
//! últimos resultados.
//!
//! ## Cómo lo enchufa una app
//!
//! ```ignore
//! struct AppModel {
//! all_files: Vec<PathBuf>,
//! fif: Option<FifState>,
//! // …
//! }
//!
//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … }
//!
//! // En update(model, msg):
//! AppMsg::Fif(fm) => {
//! // Lazy-init en Open:
//! if matches!(fm, FifMsg::Open) && model.fif.is_none() {
//! model.fif = Some(FifState::new());
//! } else if matches!(fm, FifMsg::Open) {
//! model.fif.as_mut().unwrap().dialog_open = true;
//! }
//! let action = match model.fif.as_mut() {
//! Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files),
//! None => FifAction::None,
//! };
//! match action {
//! FifAction::None => {}
//! FifAction::CloseDialog => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! }
//! FifAction::CloseAll => model.fif = None,
//! FifAction::Searched { .. } => { /* actualizar status bar */ }
//! FifAction::OpenAt { path, line, col } => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! open_path_in_app(path, line, col);
//! }
//! }
//! }
//!
//! // En on_key(model, event): solo rutea cuando el dialog está visible.
//! if let Some(state) = model.fif.as_ref() {
//! if let Some(fm) = llimphi_module_fif::on_key(state, event) {
//! return Some(AppMsg::Fif(fm));
//! }
//! }
//! if llimphi_module_fif::open_shortcut(event) {
//! return Some(AppMsg::Fif(FifMsg::Open));
//! }
//!
//! // En view(model):
//! // - dialog como overlay arriba del editor:
//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) {
//! overlay_children.push(view_dialog(s, &palette, AppMsg::Fif));
//! }
//! // - barra de resultados como panel inferior persistente:
//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) {
//! bottom_panels.push(view_results_bar(
//! s, &model.all_files, &model.root, &palette, AppMsg::Fif,
//! ));
//! }
//! ```
//!
//! ## Por qué Action en lugar de un trait `FifHost`
//!
//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait
//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa
//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`]
//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene
//! al módulo libre de cualquier conocimiento sobre el host.
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Capabilities que este módulo aporta al host. Convención del protocolo
/// Brahman Card aplicada a módulos compile-time: el host (cuando construye
/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar
/// — vía broker — que su instancia ofrece find-in-files al ecosistema.
pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
/// Caps razonables para que un workspace grande no funda el UI.
pub const MAX_RESULTS: usize = 1000;
pub const MAX_FILE_SIZE: u64 = 2_000_000;
pub const SNIPPET_MAX_CHARS: usize = 160;
pub const MIN_QUERY_LEN: usize = 2;
const DIALOG_W: f32 = 560.0;
const DIALOG_H: f32 = 116.0;
const BAR_H: f32 = 220.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 9;
/// Qué input tiene el foco dentro del dialog. `Tab` alterna.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FifFocus {
Search,
Replace,
}
/// Un match individual.
#[derive(Debug, Clone)]
pub struct FifMatch {
/// Índice dentro del slice de paths que el host pasa a [`apply`] y
/// las vistas. Convención: el host no debe reordenar/mutar el slice
/// entre frames mientras el módulo esté abierto.
pub file_idx: usize,
/// 0-based.
pub line: usize,
/// 0-based, en chars (no bytes).
pub col: usize,
/// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`].
pub snippet: String,
}
/// Estado interno del módulo.
pub struct FifState {
pub input: TextInputState,
/// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches.
pub replace: TextInputState,
pub focus: FifFocus,
pub results: Vec<FifMatch>,
pub selected: usize,
/// Última query realmente ejecutada (puede diferir del input si el
/// user siguió tipeando sin re-Enter).
pub last_query: String,
/// `true` cuando el popup modal está visible. La barra de resultados
/// se pinta independientemente de esto: sobrevive al cierre del popup.
pub dialog_open: bool,
}
impl Default for FifState {
fn default() -> Self {
Self::new()
}
}
impl FifState {
pub fn new() -> Self {
Self {
input: TextInputState::new(),
replace: TextInputState::new(),
focus: FifFocus::Search,
results: Vec::new(),
selected: 0,
last_query: String::new(),
dialog_open: true,
}
}
}
/// Vocabulario interno. El host lo wrapea en su propio Msg.
#[derive(Clone)]
pub enum FifMsg {
/// El host detectó el atajo de apertura (o un comando). Lazy-init del
/// state lo hace el host; `apply` sólo marca `dialog_open = true`.
Open,
/// El user pidió cerrar el popup (Esc). Los resultados quedan en la
/// barra inferior.
CloseDialog,
/// Cerrar todo: el host debería tirar el `FifState` completo.
CloseAll,
/// Tecla rumbo al input.
KeyInput(KeyEvent),
/// Navegación dentro de la lista de resultados.
Nav(i32),
/// Enter: la primera vez ejecuta search; subsiguientes abren el
/// match seleccionado.
Submit,
/// Click en una fila de la barra inferior: selecciona y abre.
ActivateAt(usize),
/// Alterna el foco entre los inputs search ↔ replace (Tab).
ToggleFocus,
/// Reemplaza el texto matcheado por `replace.text()` en todos los
/// matches actuales. Idempotente: re-leer el archivo, sustituir
/// case-insensitive por la query, escribir. Vacía `results` para
/// forzar nueva búsqueda si el user quiere ver el estado posterior.
ReplaceAll,
}
/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del
/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica.
#[derive(Debug, Clone)]
pub enum FifAction {
None,
/// El host debería marcar `state.dialog_open = false` y dejar el
/// resto del state intacto (resultados visibles en la barra).
CloseDialog,
/// El host debería remover el state del modelo entero.
CloseAll,
/// Tras un Submit que ejecutó search.
Searched { matches: usize, elapsed: Duration, query: String },
/// El host debería abrir `path` y posicionar el caret en `(line, col)`.
/// El módulo NO se cierra automáticamente: el host decide si ocultar
/// el dialog tras abrir el match.
OpenAt { path: PathBuf, line: usize, col: usize },
/// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches
/// sustituidos, cuántos fallaron. El host debería refrescar buffers
/// abiertos (recargar de disco si no-dirty) y mostrar status.
Replaced {
files_changed: usize,
replacements: usize,
failures: usize,
query: String,
replacement: String,
},
}
/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar.
///
/// `paths` es la lista canónica de archivos sobre la que buscar. El host
/// la pasa por referencia; cuando Submit dispara una búsqueda, este
/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE).
pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
match msg {
FifMsg::Open => {
state.dialog_open = true;
FifAction::None
}
FifMsg::CloseDialog => FifAction::CloseDialog,
FifMsg::CloseAll => FifAction::CloseAll,
FifMsg::KeyInput(ev) => {
let _ = match state.focus {
FifFocus::Search => state.input.apply_key(&ev),
FifFocus::Replace => state.replace.apply_key(&ev),
};
FifAction::None
}
FifMsg::ToggleFocus => {
state.focus = match state.focus {
FifFocus::Search => FifFocus::Replace,
FifFocus::Replace => FifFocus::Search,
};
FifAction::None
}
FifMsg::ReplaceAll => {
let query = state.last_query.clone();
if query.is_empty() || state.results.is_empty() {
return FifAction::None;
}
let replacement = state.replace.text();
let (files_changed, replacements, failures) =
replace_all(paths, &state.results, &query, &replacement);
// Invalidamos resultados: las posiciones (line, col) ya no
// necesariamente apuntan al mismo texto. El user puede re-Enter.
state.results.clear();
state.selected = 0;
FifAction::Replaced {
files_changed,
replacements,
failures,
query,
replacement,
}
}
FifMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
FifAction::None
}
FifMsg::Submit => {
let query = state.input.text();
let needs_search = query != state.last_query || state.results.is_empty();
if needs_search {
if query.len() < MIN_QUERY_LEN {
return FifAction::None;
}
let started = std::time::Instant::now();
let results = search(paths, &query);
let elapsed = started.elapsed();
let n = results.len();
state.results = results;
state.selected = 0;
state.last_query = query.clone();
FifAction::Searched { matches: n, elapsed, query }
} else {
let Some(fm) = state.results.get(state.selected).cloned() else {
return FifAction::None;
};
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
FifMsg::ActivateAt(idx) => {
if idx >= state.results.len() {
return FifAction::None;
}
state.selected = idx;
let fm = state.results[idx].clone();
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
}
/// Routing de teclas cuando el dialog está abierto. Si el popup está
/// cerrado, devuelve `None` y el host puede seguir routeando al editor.
pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
if !state.dialog_open {
return None;
}
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
Key::Named(NamedKey::Enter) => FifMsg::Submit,
Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
_ => FifMsg::KeyInput(event.clone()),
})
}
/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El
/// host puede ignorar esto y definir su propio binding.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
}
/// Paleta visual. Construible desde un [`llimphi_theme::Theme`].
#[derive(Debug, Clone)]
pub struct FifPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub border: Color,
/// Theme cacheado para reusar en `TextInputPalette::from_theme`.
theme: llimphi_theme::Theme,
}
impl FifPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
border: t.border,
theme: t.clone(),
}
}
}
/// Popup modal compacto: header + input. Sin lista de resultados — esa
/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado.
///
/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si
/// el host quiere centrarlo, debe envolverlo en un container con
/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay.
pub fn view_dialog<HostMsg, F>(
state: &FifState,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let dirty_query = state.input.text() != state.last_query;
let header = if state.last_query.is_empty() {
"find in files · Enter busca · Esc cierra".to_string()
} else if state.results.is_empty() {
format!("«{}» · sin matches · Esc cierra", state.last_query)
} else {
let staleness = if dirty_query { " · Enter re-busca" } else { "" };
format!(
"«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
state.last_query,
state.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let search_focus = state.focus == FifFocus::Search;
let search_view = labelled_input(
"buscar",
&state.input,
"buscar en archivos…",
search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_view = labelled_input(
"reemplazar",
&state.replace,
"(vacío para borrar)",
!search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_btn = View::new(Style {
size: Size { width: length(118.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.radius(3.0)
.text_aligned(
"reemplazar todo".to_string(),
10.0,
palette.fg_muted,
Alignment::Center,
)
.on_click(to_host(FifMsg::ReplaceAll));
let hint = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
let actions = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![hint, replace_btn]);
// Wrapper exterior: tamaño fijo del dialog + borde sutil.
let dialog = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.radius(6.0)
.children(vec![header_view, search_view, replace_view, actions]);
// Container que centra el dialog horizontalmente — el host pone esto
// como overlay arriba del editor; un click en zona vacía no hace nada
// (no cerramos por click-outside, sería sorpresivo si el user está
// ojeando resultados en la barra).
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(12.0_f32),
bottom: length(4.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Start),
flex_shrink: 0.0,
..Default::default()
})
.children(vec![dialog])
}
/// Barra inferior persistente con los matches. Filas clickeables (click
/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
/// del editor, hermana del terminal/output (estilo JetBrains).
///
/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
/// host puede usar `state.results.is_empty()` para no renderizarla.
pub fn view_results_bar<HostMsg, F>(
state: &FifState,
paths: &[PathBuf],
root: &Path,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let header_text = if state.results.is_empty() {
format!("find · «{}» · sin matches", state.last_query)
} else {
format!(
"find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
state.last_query,
state.selected + 1,
state.results.len(),
)
};
let close_btn = View::new(Style {
size: Size { width: length(54.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
.on_click(to_host(FifMsg::CloseAll));
let header_label = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let header_bar = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.children(vec![header_label, close_btn]);
let visible_start = state
.selected
.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(fm) = state.results.get(i) else { continue };
let Some(path) = paths.get(fm.file_idx) else { continue };
let rel = relative_to(root, path);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
let dir_label = if dir.is_empty() { String::new() } else { format!(" {dir}") };
let label = format!("{name}:{}{dir_label} {}", fm.line + 1, fm.snippet);
let selected = i == state.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(12.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 11.0, fg, Alignment::Start)
.on_click(to_host(FifMsg::ActivateAt(i))),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header_bar);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
/// Búsqueda substring case-insensitive. Pública para tests / hosts que
/// quieran disparar una búsqueda sin pasar por el state machine.
pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
let mut out: Vec<FifMatch> = Vec::new();
let q_lc = query.to_lowercase();
for (file_idx, path) in paths.iter().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_FILE_SIZE {
continue;
}
}
let Ok(content) = std::fs::read_to_string(path) else { continue };
for (line_idx, line) in content.lines().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
let line_lc = line.to_ascii_lowercase();
let Some(byte_off) = line_lc.find(&q_lc) else { continue };
let col = line[..byte_off.min(line.len())].chars().count();
let trimmed = line.trim_start();
let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
trimmed.to_string()
} else {
let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
format!("{cut}")
};
out.push(FifMatch { file_idx, line: line_idx, col, snippet });
}
}
out
}
/// Reemplazo case-insensitive sobre los archivos involucrados en
/// `results`. Devuelve `(files_changed, replacements, failures)`.
/// Lee cada archivo una sola vez, sustituye todas las apariciones de
/// `query` por `replacement` (case-insensitive, preservando el resto), y
/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
/// el host es responsable de recargar tabs si quiere ver los cambios.
pub fn replace_all(
paths: &[PathBuf],
results: &[FifMatch],
query: &str,
replacement: &str,
) -> (usize, usize, usize) {
if query.is_empty() {
return (0, 0, 0);
}
let mut touched: std::collections::BTreeSet<usize> =
std::collections::BTreeSet::new();
for fm in results {
touched.insert(fm.file_idx);
}
let mut files_changed = 0usize;
let mut total_replacements = 0usize;
let mut failures = 0usize;
let q_lc = query.to_lowercase();
for idx in touched {
let Some(path) = paths.get(idx) else { continue };
let Ok(content) = std::fs::read_to_string(path) else {
failures += 1;
continue;
};
let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
if n == 0 {
continue;
}
if std::fs::write(path, new_content).is_err() {
failures += 1;
continue;
}
files_changed += 1;
total_replacements += n;
}
(files_changed, total_replacements, failures)
}
/// Reemplazo case-insensitive preservando los bytes no-matchados.
fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
let hay_lc = haystack.to_lowercase();
let mut out = String::with_capacity(haystack.len());
let mut count = 0usize;
let mut i = 0usize;
while i <= hay_lc.len() {
if let Some(pos) = hay_lc[i..].find(needle_lc) {
let abs = i + pos;
out.push_str(&haystack[i..abs]);
out.push_str(repl);
i = abs + needle_lc.len();
count += 1;
} else {
out.push_str(&haystack[i..]);
break;
}
}
(out, count)
}
// ---------------------------------------------------------------------
// Helpers internos
// ---------------------------------------------------------------------
/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
/// el fondo se realza para que el user vea dónde está tipeando.
fn labelled_input<HostMsg>(
label: &str,
state: &TextInputState,
placeholder: &str,
focus: bool,
palette: &FifPalette,
tp: &TextInputPalette,
fallback_msg: HostMsg,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let bg = if focus { palette.bg_selected } else { palette.bg_panel };
let label_view = View::new(Style {
size: Size { width: length(82.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
let input_view = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(4.0_f32),
right: length(10.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(vec![text_input_view(
state,
placeholder,
focus,
tp,
fallback_msg,
)]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.children(vec![label_view, input_view])
}
fn relative_to(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}