tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 modules/fif/src/lib.rs Rust · 838 líneas · 29.6 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains).
2//!
3//! Módulo Llimphi con dos vistas independientes:
4//!
5//! - [`view_dialog`] — popup compacto (header + input) que el host pinta
6//!   como overlay modal centrado. Sólo visible cuando
7//!   [`FifState::dialog_open`] es `true`.
8//! - [`view_results_bar`] — barra inferior persistente con la lista de
9//!   matches. El host la pinta como tool window al pie (estilo JetBrains
10//!   "Find" tool window). Sobrevive al cierre del dialog: el user puede
11//!   Esc-cerrar el popup y seguir clickeando los resultados.
12//!
13//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter
14//! ejecuta `search` → resultados aparecen en la barra inferior → Esc
15//! cierra el popup pero la barra queda → click en una fila abre el
16//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los
17//! últimos resultados.
18//!
19//! ## Cómo lo enchufa una app
20//!
21//! ```ignore
22//! struct AppModel {
23//!     all_files: Vec<PathBuf>,
24//!     fif: Option<FifState>,
25//!     // …
26//! }
27//!
28//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … }
29//!
30//! // En update(model, msg):
31//! AppMsg::Fif(fm) => {
32//!     // Lazy-init en Open:
33//!     if matches!(fm, FifMsg::Open) && model.fif.is_none() {
34//!         model.fif = Some(FifState::new());
35//!     } else if matches!(fm, FifMsg::Open) {
36//!         model.fif.as_mut().unwrap().dialog_open = true;
37//!     }
38//!     let action = match model.fif.as_mut() {
39//!         Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files),
40//!         None => FifAction::None,
41//!     };
42//!     match action {
43//!         FifAction::None => {}
44//!         FifAction::CloseDialog => {
45//!             if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
46//!         }
47//!         FifAction::CloseAll => model.fif = None,
48//!         FifAction::Searched { .. } => { /* actualizar status bar */ }
49//!         FifAction::OpenAt { path, line, col } => {
50//!             if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
51//!             open_path_in_app(path, line, col);
52//!         }
53//!     }
54//! }
55//!
56//! // En on_key(model, event): solo rutea cuando el dialog está visible.
57//! if let Some(state) = model.fif.as_ref() {
58//!     if let Some(fm) = llimphi_module_fif::on_key(state, event) {
59//!         return Some(AppMsg::Fif(fm));
60//!     }
61//! }
62//! if llimphi_module_fif::open_shortcut(event) {
63//!     return Some(AppMsg::Fif(FifMsg::Open));
64//! }
65//!
66//! // En view(model):
67//! //   - dialog como overlay arriba del editor:
68//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) {
69//!     overlay_children.push(view_dialog(s, &palette, AppMsg::Fif));
70//! }
71//! //   - barra de resultados como panel inferior persistente:
72//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) {
73//!     bottom_panels.push(view_results_bar(
74//!         s, &model.all_files, &model.root, &palette, AppMsg::Fif,
75//!     ));
76//! }
77//! ```
78//!
79//! ## Por qué Action en lugar de un trait `FifHost`
80//!
81//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait
82//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa
83//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`]
84//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene
85//! al módulo libre de cualquier conocimiento sobre el host.
86
87#![forbid(unsafe_code)]
88
89use std::path::{Path, PathBuf};
90use std::time::Duration;
91
92use llimphi_ui::llimphi_layout::taffy::{
93    prelude::{length, percent, FlexDirection, Size, Style},
94    AlignItems, JustifyContent, Rect,
95};
96use llimphi_ui::llimphi_raster::peniko::Color;
97use llimphi_ui::llimphi_text::Alignment;
98use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
99use llimphi_widget_text_input::{
100    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
101};
102
103/// Capabilities que este módulo aporta al host. Convención del protocolo
104/// Brahman Card aplicada a módulos compile-time: el host (cuando construye
105/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar
106/// — vía broker — que su instancia ofrece find-in-files al ecosistema.
107pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
108
109/// Caps razonables para que un workspace grande no funda el UI.
110pub const MAX_RESULTS: usize = 1000;
111pub const MAX_FILE_SIZE: u64 = 2_000_000;
112pub const SNIPPET_MAX_CHARS: usize = 160;
113pub const MIN_QUERY_LEN: usize = 2;
114
115const DIALOG_W: f32 = 560.0;
116const DIALOG_H: f32 = 116.0;
117const BAR_H: f32 = 220.0;
118const ROW_H: f32 = 20.0;
119const MAX_VISIBLE: usize = 9;
120
121/// Qué input tiene el foco dentro del dialog. `Tab` alterna.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum FifFocus {
124    Search,
125    Replace,
126}
127
128/// Un match individual.
129#[derive(Debug, Clone)]
130pub struct FifMatch {
131    /// Índice dentro del slice de paths que el host pasa a [`apply`] y
132    /// las vistas. Convención: el host no debe reordenar/mutar el slice
133    /// entre frames mientras el módulo esté abierto.
134    pub file_idx: usize,
135    /// 0-based.
136    pub line: usize,
137    /// 0-based, en chars (no bytes).
138    pub col: usize,
139    /// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`].
140    pub snippet: String,
141}
142
143/// Estado interno del módulo.
144pub struct FifState {
145    pub input: TextInputState,
146    /// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches.
147    pub replace: TextInputState,
148    pub focus: FifFocus,
149    pub results: Vec<FifMatch>,
150    pub selected: usize,
151    /// Última query realmente ejecutada (puede diferir del input si el
152    /// user siguió tipeando sin re-Enter).
153    pub last_query: String,
154    /// `true` cuando el popup modal está visible. La barra de resultados
155    /// se pinta independientemente de esto: sobrevive al cierre del popup.
156    pub dialog_open: bool,
157    /// Clipboard en memoria compartido por ambos inputs (search y replace)
158    /// para copiar/cortar/pegar.
159    clipboard: MemClipboard,
160}
161
162impl Default for FifState {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl FifState {
169    pub fn new() -> Self {
170        Self {
171            input: TextInputState::new(),
172            replace: TextInputState::new(),
173            focus: FifFocus::Search,
174            results: Vec::new(),
175            selected: 0,
176            last_query: String::new(),
177            dialog_open: true,
178            clipboard: MemClipboard::new(),
179        }
180    }
181}
182
183/// Vocabulario interno. El host lo wrapea en su propio Msg.
184#[derive(Clone)]
185pub enum FifMsg {
186    /// El host detectó el atajo de apertura (o un comando). Lazy-init del
187    /// state lo hace el host; `apply` sólo marca `dialog_open = true`.
188    Open,
189    /// El user pidió cerrar el popup (Esc). Los resultados quedan en la
190    /// barra inferior.
191    CloseDialog,
192    /// Cerrar todo: el host debería tirar el `FifState` completo.
193    CloseAll,
194    /// Tecla rumbo al input.
195    KeyInput(KeyEvent),
196    /// Evento de **mouse** de uno de los inputs (click→caret,
197    /// doble/triple-click→palabra/todo, arrastre→selección), emitido por el
198    /// `text_input_view_full`. El [`FifFocus`] identifica cuál de los dos campos
199    /// (search / replace) lo emitió. Las teclas van aparte por [`FifMsg::KeyInput`].
200    MouseInput(FifFocus, TextInputEvent),
201    /// Navegación dentro de la lista de resultados.
202    Nav(i32),
203    /// Enter: la primera vez ejecuta search; subsiguientes abren el
204    /// match seleccionado.
205    Submit,
206    /// Click en una fila de la barra inferior: selecciona y abre.
207    ActivateAt(usize),
208    /// Alterna el foco entre los inputs search ↔ replace (Tab).
209    ToggleFocus,
210    /// Reemplaza el texto matcheado por `replace.text()` en todos los
211    /// matches actuales. Idempotente: re-leer el archivo, sustituir
212    /// case-insensitive por la query, escribir. Vacía `results` para
213    /// forzar nueva búsqueda si el user quiere ver el estado posterior.
214    ReplaceAll,
215}
216
217/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del
218/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica.
219#[derive(Debug, Clone)]
220pub enum FifAction {
221    None,
222    /// El host debería marcar `state.dialog_open = false` y dejar el
223    /// resto del state intacto (resultados visibles en la barra).
224    CloseDialog,
225    /// El host debería remover el state del modelo entero.
226    CloseAll,
227    /// Tras un Submit que ejecutó search.
228    Searched { matches: usize, elapsed: Duration, query: String },
229    /// El host debería abrir `path` y posicionar el caret en `(line, col)`.
230    /// El módulo NO se cierra automáticamente: el host decide si ocultar
231    /// el dialog tras abrir el match.
232    OpenAt { path: PathBuf, line: usize, col: usize },
233    /// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches
234    /// sustituidos, cuántos fallaron. El host debería refrescar buffers
235    /// abiertos (recargar de disco si no-dirty) y mostrar status.
236    Replaced {
237        files_changed: usize,
238        replacements: usize,
239        failures: usize,
240        query: String,
241        replacement: String,
242    },
243}
244
245/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar.
246///
247/// `paths` es la lista canónica de archivos sobre la que buscar. El host
248/// la pasa por referencia; cuando Submit dispara una búsqueda, este
249/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE).
250pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
251    match msg {
252        FifMsg::Open => {
253            state.dialog_open = true;
254            FifAction::None
255        }
256        FifMsg::CloseDialog => FifAction::CloseDialog,
257        FifMsg::CloseAll => FifAction::CloseAll,
258        FifMsg::KeyInput(ev) => {
259            // Ambos inputs comparten el mismo clipboard del estado; tomamos
260            // el campo por separado para no chocar con el borrow checker.
261            let clip = &mut state.clipboard;
262            let _ = match state.focus {
263                FifFocus::Search => state.input.apply_key_with_clipboard(&ev, clip),
264                FifFocus::Replace => state.replace.apply_key_with_clipboard(&ev, clip),
265            };
266            FifAction::None
267        }
268        FifMsg::MouseInput(which, ev) => {
269            let clip = &mut state.clipboard;
270            let _ = match which {
271                FifFocus::Search => state.input.handle(ev, clip),
272                FifFocus::Replace => state.replace.handle(ev, clip),
273            };
274            FifAction::None
275        }
276        FifMsg::ToggleFocus => {
277            state.focus = match state.focus {
278                FifFocus::Search => FifFocus::Replace,
279                FifFocus::Replace => FifFocus::Search,
280            };
281            FifAction::None
282        }
283        FifMsg::ReplaceAll => {
284            let query = state.last_query.clone();
285            if query.is_empty() || state.results.is_empty() {
286                return FifAction::None;
287            }
288            let replacement = state.replace.text();
289            let (files_changed, replacements, failures) =
290                replace_all(paths, &state.results, &query, &replacement);
291            // Invalidamos resultados: las posiciones (line, col) ya no
292            // necesariamente apuntan al mismo texto. El user puede re-Enter.
293            state.results.clear();
294            state.selected = 0;
295            FifAction::Replaced {
296                files_changed,
297                replacements,
298                failures,
299                query,
300                replacement,
301            }
302        }
303        FifMsg::Nav(d) => {
304            let n = state.results.len() as i32;
305            if n > 0 {
306                state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
307            }
308            FifAction::None
309        }
310        FifMsg::Submit => {
311            let query = state.input.text();
312            let needs_search = query != state.last_query || state.results.is_empty();
313            if needs_search {
314                if query.len() < MIN_QUERY_LEN {
315                    return FifAction::None;
316                }
317                let started = std::time::Instant::now();
318                let results = search(paths, &query);
319                let elapsed = started.elapsed();
320                let n = results.len();
321                state.results = results;
322                state.selected = 0;
323                state.last_query = query.clone();
324                FifAction::Searched { matches: n, elapsed, query }
325            } else {
326                let Some(fm) = state.results.get(state.selected).cloned() else {
327                    return FifAction::None;
328                };
329                let Some(path) = paths.get(fm.file_idx).cloned() else {
330                    return FifAction::None;
331                };
332                FifAction::OpenAt { path, line: fm.line, col: fm.col }
333            }
334        }
335        FifMsg::ActivateAt(idx) => {
336            if idx >= state.results.len() {
337                return FifAction::None;
338            }
339            state.selected = idx;
340            let fm = state.results[idx].clone();
341            let Some(path) = paths.get(fm.file_idx).cloned() else {
342                return FifAction::None;
343            };
344            FifAction::OpenAt { path, line: fm.line, col: fm.col }
345        }
346    }
347}
348
349/// Routing de teclas cuando el dialog está abierto. Si el popup está
350/// cerrado, devuelve `None` y el host puede seguir routeando al editor.
351pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
352    if !state.dialog_open {
353        return None;
354    }
355    if event.state != KeyState::Pressed {
356        return None;
357    }
358    Some(match &event.key {
359        Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
360        Key::Named(NamedKey::Enter) => FifMsg::Submit,
361        Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
362        Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
363        Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
364        _ => FifMsg::KeyInput(event.clone()),
365    })
366}
367
368/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El
369/// host puede ignorar esto y definir su propio binding.
370pub fn open_shortcut(event: &KeyEvent) -> bool {
371    event.state == KeyState::Pressed
372        && event.modifiers.ctrl
373        && event.modifiers.shift
374        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
375}
376
377/// Paleta visual. Construible desde un [`llimphi_theme::Theme`].
378#[derive(Debug, Clone)]
379pub struct FifPalette {
380    pub bg_panel: Color,
381    pub bg_header: Color,
382    pub bg_selected: Color,
383    pub fg_text: Color,
384    pub fg_muted: Color,
385    pub border: Color,
386    /// Theme cacheado para reusar en `TextInputPalette::from_theme`.
387    theme: llimphi_theme::Theme,
388}
389
390impl FifPalette {
391    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
392        Self {
393            bg_panel: t.bg_panel,
394            bg_header: t.bg_panel_alt,
395            bg_selected: t.bg_selected,
396            fg_text: t.fg_text,
397            fg_muted: t.fg_muted,
398            border: t.border,
399            theme: t.clone(),
400        }
401    }
402}
403
404/// Popup modal compacto: header + input. Sin lista de resultados — esa
405/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado.
406///
407/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si
408/// el host quiere centrarlo, debe envolverlo en un container con
409/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay.
410pub fn view_dialog<HostMsg, F>(
411    state: &FifState,
412    palette: &FifPalette,
413    to_host: F,
414) -> View<HostMsg>
415where
416    HostMsg: Clone + 'static,
417    F: Fn(FifMsg) -> HostMsg + Copy + Send + Sync + 'static,
418{
419    let dirty_query = state.input.text() != state.last_query;
420    let header = if state.last_query.is_empty() {
421        "find in files · Enter busca · Esc cierra".to_string()
422    } else if state.results.is_empty() {
423        format!("«{}» · sin matches · Esc cierra", state.last_query)
424    } else {
425        let staleness = if dirty_query { " · Enter re-busca" } else { "" };
426        format!(
427            "«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
428            state.last_query,
429            state.results.len(),
430        )
431    };
432
433    let header_view = View::new(Style {
434        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
435        padding: Rect {
436            left: length(10.0_f32),
437            right: length(10.0_f32),
438            top: length(0.0_f32),
439            bottom: length(0.0_f32),
440        },
441        align_items: Some(AlignItems::Center),
442        flex_shrink: 0.0,
443        ..Default::default()
444    })
445    .fill(palette.bg_header)
446    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
447
448    let tp = TextInputPalette::from_theme(&palette.theme);
449    let search_focus = state.focus == FifFocus::Search;
450    let search_view = labelled_input(
451        "buscar",
452        &state.input,
453        "buscar en archivos…",
454        search_focus,
455        palette,
456        &tp,
457        move |ev| to_host(FifMsg::MouseInput(FifFocus::Search, ev)),
458    );
459    let replace_view = labelled_input(
460        "reemplazar",
461        &state.replace,
462        "(vacío para borrar)",
463        !search_focus,
464        palette,
465        &tp,
466        move |ev| to_host(FifMsg::MouseInput(FifFocus::Replace, ev)),
467    );
468
469    let replace_btn = View::new(Style {
470        size: Size { width: length(118.0_f32), height: length(20.0_f32) },
471        padding: Rect {
472            left: length(6.0_f32),
473            right: length(6.0_f32),
474            top: length(0.0_f32),
475            bottom: length(0.0_f32),
476        },
477        align_items: Some(AlignItems::Center),
478        flex_shrink: 0.0,
479        ..Default::default()
480    })
481    .fill(palette.bg_header)
482    .radius(3.0)
483    .text_aligned(
484        "reemplazar todo".to_string(),
485        10.0,
486        palette.fg_muted,
487        Alignment::Center,
488    )
489    .on_click(to_host(FifMsg::ReplaceAll));
490
491    let hint = View::new(Style {
492        flex_grow: 1.0,
493        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
494        padding: Rect {
495            left: length(8.0_f32),
496            right: length(8.0_f32),
497            top: length(0.0_f32),
498            bottom: length(0.0_f32),
499        },
500        align_items: Some(AlignItems::Center),
501        ..Default::default()
502    })
503    .text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
504
505    let actions = View::new(Style {
506        flex_direction: FlexDirection::Row,
507        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
508        padding: Rect {
509            left: length(8.0_f32),
510            right: length(8.0_f32),
511            top: length(0.0_f32),
512            bottom: length(0.0_f32),
513        },
514        align_items: Some(AlignItems::Center),
515        flex_shrink: 0.0,
516        ..Default::default()
517    })
518    .fill(palette.bg_panel)
519    .children(vec![hint, replace_btn]);
520
521    // Wrapper exterior: tamaño fijo del dialog + borde sutil.
522    let dialog = View::new(Style {
523        flex_direction: FlexDirection::Column,
524        size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
525        flex_shrink: 0.0,
526        ..Default::default()
527    })
528    .fill(palette.bg_panel)
529    .radius(6.0)
530    .children(vec![header_view, search_view, replace_view, actions]);
531
532    // Container que centra el dialog horizontalmente — el host pone esto
533    // como overlay arriba del editor; un click en zona vacía no hace nada
534    // (no cerramos por click-outside, sería sorpresivo si el user está
535    // ojeando resultados en la barra).
536    View::new(Style {
537        flex_direction: FlexDirection::Row,
538        size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
539        padding: Rect {
540            left: length(0.0_f32),
541            right: length(0.0_f32),
542            top: length(12.0_f32),
543            bottom: length(4.0_f32),
544        },
545        justify_content: Some(JustifyContent::Center),
546        align_items: Some(AlignItems::Start),
547        flex_shrink: 0.0,
548        ..Default::default()
549    })
550    .children(vec![dialog])
551}
552
553/// Barra inferior persistente con los matches. Filas clickeables (click
554/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
555/// del editor, hermana del terminal/output (estilo JetBrains).
556///
557/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
558/// host puede usar `state.results.is_empty()` para no renderizarla.
559pub fn view_results_bar<HostMsg, F>(
560    state: &FifState,
561    paths: &[PathBuf],
562    root: &Path,
563    palette: &FifPalette,
564    to_host: F,
565) -> View<HostMsg>
566where
567    HostMsg: Clone + 'static,
568    F: Fn(FifMsg) -> HostMsg + Copy + 'static,
569{
570    let header_text = if state.results.is_empty() {
571        format!("find · «{}» · sin matches", state.last_query)
572    } else {
573        format!(
574            "find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
575            state.last_query,
576            state.selected + 1,
577            state.results.len(),
578        )
579    };
580
581    let close_btn = View::new(Style {
582        size: Size { width: length(54.0_f32), height: length(18.0_f32) },
583        padding: Rect {
584            left: length(8.0_f32),
585            right: length(8.0_f32),
586            top: length(0.0_f32),
587            bottom: length(0.0_f32),
588        },
589        align_items: Some(AlignItems::Center),
590        flex_shrink: 0.0,
591        ..Default::default()
592    })
593    .fill(palette.bg_header)
594    .text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
595    .on_click(to_host(FifMsg::CloseAll));
596
597    let header_label = View::new(Style {
598        flex_grow: 1.0,
599        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
600        padding: Rect {
601            left: length(10.0_f32),
602            right: length(8.0_f32),
603            top: length(0.0_f32),
604            bottom: length(0.0_f32),
605        },
606        align_items: Some(AlignItems::Center),
607        ..Default::default()
608    })
609    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
610
611    let header_bar = View::new(Style {
612        flex_direction: FlexDirection::Row,
613        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
614        align_items: Some(AlignItems::Center),
615        flex_shrink: 0.0,
616        ..Default::default()
617    })
618    .fill(palette.bg_header)
619    .children(vec![header_label, close_btn]);
620
621    let visible_start = state
622        .selected
623        .saturating_sub(MAX_VISIBLE.saturating_sub(1));
624    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
625    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
626    for i in visible_start..visible_end {
627        let Some(fm) = state.results.get(i) else { continue };
628        let Some(path) = paths.get(fm.file_idx) else { continue };
629        let rel = relative_to(root, path);
630        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
631        let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
632        let dir_label = if dir.is_empty() { String::new() } else { format!("  {dir}") };
633        let label = format!("{name}:{}{dir_label}    {}", fm.line + 1, fm.snippet);
634        let selected = i == state.selected;
635        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
636        let fg = if selected { palette.fg_text } else { palette.fg_muted };
637        rows.push(
638            View::new(Style {
639                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
640                padding: Rect {
641                    left: length(12.0_f32),
642                    right: length(8.0_f32),
643                    top: length(0.0_f32),
644                    bottom: length(0.0_f32),
645                },
646                align_items: Some(AlignItems::Center),
647                flex_shrink: 0.0,
648                ..Default::default()
649            })
650            .fill(bg)
651            .text_aligned(label, 11.0, fg, Alignment::Start)
652            .on_click(to_host(FifMsg::ActivateAt(i))),
653        );
654    }
655
656    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
657    children.push(header_bar);
658    children.extend(rows);
659
660    View::new(Style {
661        flex_direction: FlexDirection::Column,
662        size: Size { width: percent(1.0_f32), height: length(BAR_H) },
663        flex_shrink: 0.0,
664        ..Default::default()
665    })
666    .fill(palette.bg_panel)
667    .children(children)
668}
669
670/// Búsqueda substring case-insensitive. Pública para tests / hosts que
671/// quieran disparar una búsqueda sin pasar por el state machine.
672pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
673    let mut out: Vec<FifMatch> = Vec::new();
674    let q_lc = query.to_lowercase();
675    for (file_idx, path) in paths.iter().enumerate() {
676        if out.len() >= MAX_RESULTS {
677            break;
678        }
679        if let Ok(meta) = std::fs::metadata(path) {
680            if meta.len() > MAX_FILE_SIZE {
681                continue;
682            }
683        }
684        let Ok(content) = std::fs::read_to_string(path) else { continue };
685        for (line_idx, line) in content.lines().enumerate() {
686            if out.len() >= MAX_RESULTS {
687                break;
688            }
689            let line_lc = line.to_ascii_lowercase();
690            let Some(byte_off) = line_lc.find(&q_lc) else { continue };
691            let col = line[..byte_off.min(line.len())].chars().count();
692            let trimmed = line.trim_start();
693            let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
694                trimmed.to_string()
695            } else {
696                let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
697                format!("{cut}…")
698            };
699            out.push(FifMatch { file_idx, line: line_idx, col, snippet });
700        }
701    }
702    out
703}
704
705/// Reemplazo case-insensitive sobre los archivos involucrados en
706/// `results`. Devuelve `(files_changed, replacements, failures)`.
707/// Lee cada archivo una sola vez, sustituye todas las apariciones de
708/// `query` por `replacement` (case-insensitive, preservando el resto), y
709/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
710/// el host es responsable de recargar tabs si quiere ver los cambios.
711pub fn replace_all(
712    paths: &[PathBuf],
713    results: &[FifMatch],
714    query: &str,
715    replacement: &str,
716) -> (usize, usize, usize) {
717    if query.is_empty() {
718        return (0, 0, 0);
719    }
720    let mut touched: std::collections::BTreeSet<usize> =
721        std::collections::BTreeSet::new();
722    for fm in results {
723        touched.insert(fm.file_idx);
724    }
725    let mut files_changed = 0usize;
726    let mut total_replacements = 0usize;
727    let mut failures = 0usize;
728    let q_lc = query.to_lowercase();
729    for idx in touched {
730        let Some(path) = paths.get(idx) else { continue };
731        let Ok(content) = std::fs::read_to_string(path) else {
732            failures += 1;
733            continue;
734        };
735        let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
736        if n == 0 {
737            continue;
738        }
739        if std::fs::write(path, new_content).is_err() {
740            failures += 1;
741            continue;
742        }
743        files_changed += 1;
744        total_replacements += n;
745    }
746    (files_changed, total_replacements, failures)
747}
748
749/// Reemplazo case-insensitive preservando los bytes no-matchados.
750fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
751    let hay_lc = haystack.to_lowercase();
752    let mut out = String::with_capacity(haystack.len());
753    let mut count = 0usize;
754    let mut i = 0usize;
755    while i <= hay_lc.len() {
756        if let Some(pos) = hay_lc[i..].find(needle_lc) {
757            let abs = i + pos;
758            out.push_str(&haystack[i..abs]);
759            out.push_str(repl);
760            i = abs + needle_lc.len();
761            count += 1;
762        } else {
763            out.push_str(&haystack[i..]);
764            break;
765        }
766    }
767    (out, count)
768}
769
770// ---------------------------------------------------------------------
771// Helpers internos
772// ---------------------------------------------------------------------
773
774/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
775/// el fondo se realza para que el user vea dónde está tipeando.
776fn labelled_input<HostMsg, W>(
777    label: &str,
778    state: &TextInputState,
779    placeholder: &str,
780    focus: bool,
781    palette: &FifPalette,
782    tp: &TextInputPalette,
783    wrap: W,
784) -> View<HostMsg>
785where
786    HostMsg: Clone + 'static,
787    W: Fn(TextInputEvent) -> HostMsg + Send + Sync + 'static,
788{
789    let bg = if focus { palette.bg_selected } else { palette.bg_panel };
790    let label_view = View::new(Style {
791        size: Size { width: length(82.0_f32), height: length(28.0_f32) },
792        padding: Rect {
793            left: length(10.0_f32),
794            right: length(4.0_f32),
795            top: length(0.0_f32),
796            bottom: length(0.0_f32),
797        },
798        align_items: Some(AlignItems::Center),
799        flex_shrink: 0.0,
800        ..Default::default()
801    })
802    .text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
803
804    let input_view = View::new(Style {
805        flex_grow: 1.0,
806        size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
807        padding: Rect {
808            left: length(4.0_f32),
809            right: length(10.0_f32),
810            top: length(2.0_f32),
811            bottom: length(2.0_f32),
812        },
813        ..Default::default()
814    })
815    .children(vec![text_input_view_full(
816        state,
817        placeholder,
818        focus,
819        tp,
820        wrap,
821    )]);
822
823    View::new(Style {
824        flex_direction: FlexDirection::Row,
825        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
826        align_items: Some(AlignItems::Center),
827        flex_shrink: 0.0,
828        ..Default::default()
829    })
830    .fill(bg)
831    .children(vec![label_view, input_view])
832}
833
834fn relative_to(root: &Path, path: &Path) -> String {
835    path.strip_prefix(root)
836        .map(|p| p.display().to_string())
837        .unwrap_or_else(|_| path.display().to_string())
838}