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

⎇ main modules/fif/src/lib.rs Rust · 842 líneas · 29.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · 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_compositor::llimphi_layout::taffy::{
93    prelude::{length, percent, FlexDirection, Size, Style},
94    AlignItems, JustifyContent, Rect,
95};
96use llimphi_compositor::peniko::Color;
97use llimphi_compositor::llimphi_text::Alignment;
98use llimphi_compositor::{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    .role(llimphi_compositor::Role::Button)
490    .aria_label("Reemplazar todo")
491    .on_click(to_host(FifMsg::ReplaceAll));
492
493    let hint = View::new(Style {
494        flex_grow: 1.0,
495        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
496        padding: Rect {
497            left: length(8.0_f32),
498            right: length(8.0_f32),
499            top: length(0.0_f32),
500            bottom: length(0.0_f32),
501        },
502        align_items: Some(AlignItems::CENTER),
503        ..Default::default()
504    })
505    .text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
506
507    let actions = View::new(Style {
508        flex_direction: FlexDirection::Row,
509        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
510        padding: Rect {
511            left: length(8.0_f32),
512            right: length(8.0_f32),
513            top: length(0.0_f32),
514            bottom: length(0.0_f32),
515        },
516        align_items: Some(AlignItems::CENTER),
517        flex_shrink: 0.0,
518        ..Default::default()
519    })
520    .fill(palette.bg_panel)
521    .children(vec![hint, replace_btn]);
522
523    // Wrapper exterior: tamaño fijo del dialog + borde sutil.
524    let dialog = View::new(Style {
525        flex_direction: FlexDirection::Column,
526        size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
527        flex_shrink: 0.0,
528        ..Default::default()
529    })
530    .fill(palette.bg_panel)
531    .radius(6.0)
532    .children(vec![header_view, search_view, replace_view, actions]);
533
534    // Container que centra el dialog horizontalmente — el host pone esto
535    // como overlay arriba del editor; un click en zona vacía no hace nada
536    // (no cerramos por click-outside, sería sorpresivo si el user está
537    // ojeando resultados en la barra).
538    View::new(Style {
539        flex_direction: FlexDirection::Row,
540        size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
541        padding: Rect {
542            left: length(0.0_f32),
543            right: length(0.0_f32),
544            top: length(12.0_f32),
545            bottom: length(4.0_f32),
546        },
547        justify_content: Some(JustifyContent::CENTER),
548        align_items: Some(AlignItems::START),
549        flex_shrink: 0.0,
550        ..Default::default()
551    })
552    .children(vec![dialog])
553}
554
555/// Barra inferior persistente con los matches. Filas clickeables (click
556/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
557/// del editor, hermana del terminal/output (estilo JetBrains).
558///
559/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
560/// host puede usar `state.results.is_empty()` para no renderizarla.
561pub fn view_results_bar<HostMsg, F>(
562    state: &FifState,
563    paths: &[PathBuf],
564    root: &Path,
565    palette: &FifPalette,
566    to_host: F,
567) -> View<HostMsg>
568where
569    HostMsg: Clone + 'static,
570    F: Fn(FifMsg) -> HostMsg + Copy + 'static,
571{
572    let header_text = if state.results.is_empty() {
573        format!("find · «{}» · sin matches", state.last_query)
574    } else {
575        format!(
576            "find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
577            state.last_query,
578            state.selected + 1,
579            state.results.len(),
580        )
581    };
582
583    let close_btn = View::new(Style {
584        size: Size { width: length(54.0_f32), height: length(18.0_f32) },
585        padding: Rect {
586            left: length(8.0_f32),
587            right: length(8.0_f32),
588            top: length(0.0_f32),
589            bottom: length(0.0_f32),
590        },
591        align_items: Some(AlignItems::CENTER),
592        flex_shrink: 0.0,
593        ..Default::default()
594    })
595    .fill(palette.bg_header)
596    .text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
597    .role(llimphi_compositor::Role::Button)
598    .aria_label("Cerrar los resultados")
599    .on_click(to_host(FifMsg::CloseAll));
600
601    let header_label = View::new(Style {
602        flex_grow: 1.0,
603        size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
604        padding: Rect {
605            left: length(10.0_f32),
606            right: length(8.0_f32),
607            top: length(0.0_f32),
608            bottom: length(0.0_f32),
609        },
610        align_items: Some(AlignItems::CENTER),
611        ..Default::default()
612    })
613    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
614
615    let header_bar = View::new(Style {
616        flex_direction: FlexDirection::Row,
617        size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
618        align_items: Some(AlignItems::CENTER),
619        flex_shrink: 0.0,
620        ..Default::default()
621    })
622    .fill(palette.bg_header)
623    .children(vec![header_label, close_btn]);
624
625    let visible_start = state
626        .selected
627        .saturating_sub(MAX_VISIBLE.saturating_sub(1));
628    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
629    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
630    for i in visible_start..visible_end {
631        let Some(fm) = state.results.get(i) else { continue };
632        let Some(path) = paths.get(fm.file_idx) else { continue };
633        let rel = relative_to(root, path);
634        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
635        let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
636        let dir_label = if dir.is_empty() { String::new() } else { format!("  {dir}") };
637        let label = format!("{name}:{}{dir_label}    {}", fm.line + 1, fm.snippet);
638        let selected = i == state.selected;
639        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
640        let fg = if selected { palette.fg_text } else { palette.fg_muted };
641        rows.push(
642            View::new(Style {
643                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
644                padding: Rect {
645                    left: length(12.0_f32),
646                    right: length(8.0_f32),
647                    top: length(0.0_f32),
648                    bottom: length(0.0_f32),
649                },
650                align_items: Some(AlignItems::CENTER),
651                flex_shrink: 0.0,
652                ..Default::default()
653            })
654            .fill(bg)
655            .text_aligned(label, 11.0, fg, Alignment::Start)
656            .on_click(to_host(FifMsg::ActivateAt(i))),
657        );
658    }
659
660    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
661    children.push(header_bar);
662    children.extend(rows);
663
664    View::new(Style {
665        flex_direction: FlexDirection::Column,
666        size: Size { width: percent(1.0_f32), height: length(BAR_H) },
667        flex_shrink: 0.0,
668        ..Default::default()
669    })
670    .fill(palette.bg_panel)
671    .children(children)
672}
673
674/// Búsqueda substring case-insensitive. Pública para tests / hosts que
675/// quieran disparar una búsqueda sin pasar por el state machine.
676pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
677    let mut out: Vec<FifMatch> = Vec::new();
678    let q_lc = query.to_lowercase();
679    for (file_idx, path) in paths.iter().enumerate() {
680        if out.len() >= MAX_RESULTS {
681            break;
682        }
683        if let Ok(meta) = std::fs::metadata(path) {
684            if meta.len() > MAX_FILE_SIZE {
685                continue;
686            }
687        }
688        let Ok(content) = std::fs::read_to_string(path) else { continue };
689        for (line_idx, line) in content.lines().enumerate() {
690            if out.len() >= MAX_RESULTS {
691                break;
692            }
693            let line_lc = line.to_ascii_lowercase();
694            let Some(byte_off) = line_lc.find(&q_lc) else { continue };
695            let col = line[..byte_off.min(line.len())].chars().count();
696            let trimmed = line.trim_start();
697            let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
698                trimmed.to_string()
699            } else {
700                let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
701                format!("{cut}…")
702            };
703            out.push(FifMatch { file_idx, line: line_idx, col, snippet });
704        }
705    }
706    out
707}
708
709/// Reemplazo case-insensitive sobre los archivos involucrados en
710/// `results`. Devuelve `(files_changed, replacements, failures)`.
711/// Lee cada archivo una sola vez, sustituye todas las apariciones de
712/// `query` por `replacement` (case-insensitive, preservando el resto), y
713/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
714/// el host es responsable de recargar tabs si quiere ver los cambios.
715pub fn replace_all(
716    paths: &[PathBuf],
717    results: &[FifMatch],
718    query: &str,
719    replacement: &str,
720) -> (usize, usize, usize) {
721    if query.is_empty() {
722        return (0, 0, 0);
723    }
724    let mut touched: std::collections::BTreeSet<usize> =
725        std::collections::BTreeSet::new();
726    for fm in results {
727        touched.insert(fm.file_idx);
728    }
729    let mut files_changed = 0usize;
730    let mut total_replacements = 0usize;
731    let mut failures = 0usize;
732    let q_lc = query.to_lowercase();
733    for idx in touched {
734        let Some(path) = paths.get(idx) else { continue };
735        let Ok(content) = std::fs::read_to_string(path) else {
736            failures += 1;
737            continue;
738        };
739        let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
740        if n == 0 {
741            continue;
742        }
743        if std::fs::write(path, new_content).is_err() {
744            failures += 1;
745            continue;
746        }
747        files_changed += 1;
748        total_replacements += n;
749    }
750    (files_changed, total_replacements, failures)
751}
752
753/// Reemplazo case-insensitive preservando los bytes no-matchados.
754fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
755    let hay_lc = haystack.to_lowercase();
756    let mut out = String::with_capacity(haystack.len());
757    let mut count = 0usize;
758    let mut i = 0usize;
759    while i <= hay_lc.len() {
760        if let Some(pos) = hay_lc[i..].find(needle_lc) {
761            let abs = i + pos;
762            out.push_str(&haystack[i..abs]);
763            out.push_str(repl);
764            i = abs + needle_lc.len();
765            count += 1;
766        } else {
767            out.push_str(&haystack[i..]);
768            break;
769        }
770    }
771    (out, count)
772}
773
774// ---------------------------------------------------------------------
775// Helpers internos
776// ---------------------------------------------------------------------
777
778/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
779/// el fondo se realza para que el user vea dónde está tipeando.
780fn labelled_input<HostMsg, W>(
781    label: &str,
782    state: &TextInputState,
783    placeholder: &str,
784    focus: bool,
785    palette: &FifPalette,
786    tp: &TextInputPalette,
787    wrap: W,
788) -> View<HostMsg>
789where
790    HostMsg: Clone + 'static,
791    W: Fn(TextInputEvent) -> HostMsg + Send + Sync + 'static,
792{
793    let bg = if focus { palette.bg_selected } else { palette.bg_panel };
794    let label_view = View::new(Style {
795        size: Size { width: length(82.0_f32), height: length(28.0_f32) },
796        padding: Rect {
797            left: length(10.0_f32),
798            right: length(4.0_f32),
799            top: length(0.0_f32),
800            bottom: length(0.0_f32),
801        },
802        align_items: Some(AlignItems::CENTER),
803        flex_shrink: 0.0,
804        ..Default::default()
805    })
806    .text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
807
808    let input_view = View::new(Style {
809        flex_grow: 1.0,
810        size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
811        padding: Rect {
812            left: length(4.0_f32),
813            right: length(10.0_f32),
814            top: length(2.0_f32),
815            bottom: length(2.0_f32),
816        },
817        ..Default::default()
818    })
819    .children(vec![text_input_view_full(
820        state,
821        placeholder,
822        focus,
823        tp,
824        wrap,
825    )]);
826
827    View::new(Style {
828        flex_direction: FlexDirection::Row,
829        size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
830        align_items: Some(AlignItems::CENTER),
831        flex_shrink: 0.0,
832        ..Default::default()
833    })
834    .fill(bg)
835    .children(vec![label_view, input_view])
836}
837
838fn relative_to(root: &Path, path: &Path) -> String {
839    path.strip_prefix(root)
840        .map(|p| p.display().to_string())
841        .unwrap_or_else(|_| path.display().to_string())
842}