Skip to main content

matchmaker/render/
mod.rs

1mod dynamic;
2mod state;
3
4use crossterm::event::{MouseButton, MouseEventKind};
5pub use dynamic::*;
6pub use state::*;
7// ------------------------------
8
9use std::io::Write;
10
11use log::{info, warn};
12use ratatui::Frame;
13use ratatui::layout::{Position, Rect};
14use tokio::sync::mpsc;
15
16#[cfg(feature = "bracketed-paste")]
17use crate::PasteHandler;
18use crate::action::{Action, ActionExt};
19use crate::config::{CursorSetting, ExitConfig, RowConnectionStyle};
20use crate::message::{Event, Interrupt, RenderCommand};
21use crate::tui::Tui;
22use crate::ui::{DisplayUI, InputUI, OverlayUI, PickerUI, PreviewUI, ResultsUI, UI};
23use crate::{ActionAliaser, ActionExtHandler, MatchError, SSS, Selection};
24
25fn apply_aliases<T: SSS, S: Selection, A: ActionExt>(
26    buffer: &mut Vec<RenderCommand<A>>,
27    aliaser: ActionAliaser<T, S, A>,
28    dispatcher: &mut MMState<'_, '_, T, S>,
29) {
30    let mut out = Vec::new();
31
32    for cmd in buffer.drain(..) {
33        match cmd {
34            RenderCommand::Action(a) => out.extend(
35                aliaser(a, dispatcher)
36                    .into_iter()
37                    .map(RenderCommand::Action),
38            ),
39            other => out.push(other),
40        }
41    }
42
43    *buffer = out;
44}
45
46#[allow(clippy::too_many_arguments)]
47pub(crate) async fn render_loop<'a, W: Write, T: SSS, S: Selection, A: ActionExt>(
48    mut ui: UI,
49    mut picker_ui: PickerUI<'a, T, S>,
50    mut footer_ui: DisplayUI,
51    mut preview_ui: Option<PreviewUI>,
52    mut tui: Tui<W>,
53
54    mut overlay_ui: Option<OverlayUI<A>>,
55    exit_config: ExitConfig,
56
57    mut render_rx: mpsc::UnboundedReceiver<RenderCommand<A>>,
58    controller_tx: mpsc::UnboundedSender<Event>,
59
60    dynamic_handlers: DynamicHandlers<T, S>,
61    ext_handler: Option<ActionExtHandler<T, S, A>>,
62    ext_aliaser: Option<ActionAliaser<T, S, A>>,
63    #[cfg(feature = "bracketed-paste")] paste_handler: Option<PasteHandler<T, S>>,
64) -> Result<Vec<S>, MatchError> {
65    let mut buffer = Vec::with_capacity(256);
66
67    let mut state = State::new();
68    let mut click = Click::None;
69
70    // place the initial command in the state where the preview listener can access
71    if let Some(ref preview_ui) = preview_ui
72        && !preview_ui.command().is_empty()
73    {
74        state.update_preview(preview_ui.command());
75    }
76
77    while render_rx.recv_many(&mut buffer, 256).await > 0 {
78        let mut did_pause = false;
79        let mut did_exit = false;
80        let mut did_resize = false;
81
82        // todo: why exactly can we not borrow the picker_ui mutably?
83        if let Some(aliaser) = ext_aliaser {
84            apply_aliases(
85                &mut buffer,
86                aliaser,
87                &mut state.dispatcher(&mut ui, &mut picker_ui, &mut footer_ui, &mut preview_ui),
88            )
89            // effects could be moved out for efficiency, but it seems more logical to add them as they come so that we can trigger interrupts
90        };
91
92        if state.should_quit {
93            log::debug!("Exiting due to should_quit");
94            let ret = picker_ui.selector.output().collect::<Vec<S>>();
95            return if picker_ui.selector.is_disabled()
96                && let Some((_, item)) = get_current(&picker_ui)
97            {
98                Ok(vec![item])
99            } else if ret.is_empty() {
100                Err(MatchError::Abort(0))
101            } else {
102                Ok(ret)
103            };
104        } else if state.should_quit_nomatch {
105            log::debug!("Exiting due to should_quit_no_match");
106            return Err(MatchError::NoMatch);
107        }
108
109        for event in buffer.drain(..) {
110            state.clear_interrupt();
111
112            if !matches!(event, RenderCommand::Tick) {
113                info!("Recieved {event:?}");
114            }
115
116            match event {
117                RenderCommand::Action(Action::Input(c)) => {
118                    // btw, why can't we do let input = picker_ui.input without running into issues?
119                    if let Some(x) = overlay_ui.as_mut()
120                        && x.handle_input(c)
121                    {
122                        continue;
123                    }
124                    picker_ui.input.push_char(c);
125                }
126                #[cfg(feature = "bracketed-paste")]
127                RenderCommand::Paste(content) => {
128                    if let Some(handler) = paste_handler {
129                        let content = {
130                            handler(
131                                content,
132                                &state.dispatcher(
133                                    &mut ui,
134                                    &mut picker_ui,
135                                    &mut footer_ui,
136                                    &mut preview_ui,
137                                ),
138                            )
139                        };
140                        if !content.is_empty() {
141                            picker_ui.input.push_str(&content);
142                        }
143                    }
144                }
145                RenderCommand::Resize(area) => {
146                    tui.resize(area);
147                    ui.area = area;
148                }
149                RenderCommand::Refresh => {
150                    tui.redraw();
151                }
152                RenderCommand::HeaderColumns(columns) => {
153                    picker_ui.header.header_columns(columns);
154                }
155                RenderCommand::Mouse(mouse) => {
156                    // we could also impl this in the aliasing step
157                    let pos = Position::from((mouse.column, mouse.row));
158                    let [preview, input, status, result] = state.layout;
159
160                    match mouse.kind {
161                        MouseEventKind::Down(MouseButton::Left) => {
162                            // todo: clickable column headers, clickable results, also, grouping?
163                            if result.contains(pos) {
164                                click = Click::ResultPos(mouse.row - result.top());
165                            } else if input.contains(pos) {
166                                // The X offset of the start of the visible text relative to the terminal
167                                let text_start_x = input.x
168                                    + picker_ui.input.prompt.width() as u16
169                                    + picker_ui.input.config.border.left();
170
171                                if pos.x >= text_start_x {
172                                    let visual_offset = pos.x - text_start_x;
173                                    picker_ui.input.set_at_visual_offset(visual_offset);
174                                } else {
175                                    picker_ui.input.set(None, 0);
176                                }
177                            } else if status.contains(pos) {
178                                // todo
179                            }
180                        }
181                        MouseEventKind::ScrollDown => {
182                            if preview.contains(pos) {
183                                if let Some(p) = preview_ui.as_mut() {
184                                    p.down(1)
185                                }
186                            } else {
187                                picker_ui.results.cursor_next()
188                            }
189                        }
190                        MouseEventKind::ScrollUp => {
191                            if preview.contains(pos) {
192                                if let Some(p) = preview_ui.as_mut() {
193                                    p.up(1)
194                                }
195                            } else {
196                                picker_ui.results.cursor_prev()
197                            }
198                        }
199                        MouseEventKind::ScrollLeft => {
200                            // todo
201                        }
202                        MouseEventKind::ScrollRight => {
203                            // todo
204                        }
205                        // Drag tracking: todo
206                        _ => {}
207                    }
208                }
209                RenderCommand::QuitEmpty => {
210                    return Ok(vec![]);
211                }
212                RenderCommand::Action(action) => {
213                    if let Some(x) = overlay_ui.as_mut()
214                        && x.handle_action(&action)
215                    {
216                        continue;
217                    }
218                    let PickerUI {
219                        input,
220                        results,
221                        worker,
222                        selector: selections,
223                        header,
224                        ..
225                    } = &mut picker_ui;
226                    match action {
227                        Action::Select => {
228                            if let Some(item) = worker.get_nth(results.index()) {
229                                selections.sel(item);
230                            }
231                        }
232                        Action::Deselect => {
233                            if let Some(item) = worker.get_nth(results.index()) {
234                                selections.desel(item);
235                            }
236                        }
237                        Action::Toggle => {
238                            if let Some(item) = worker.get_nth(results.index()) {
239                                selections.toggle(item);
240                            }
241                        }
242                        Action::CycleAll => {
243                            selections.cycle_all_bg(worker.raw_results());
244                        }
245                        Action::ClearSelections => {
246                            selections.clear();
247                        }
248                        Action::Accept => {
249                            let ret = if selections.is_empty() {
250                                if let Some(item) = get_current(&picker_ui) {
251                                    vec![item.1]
252                                } else if exit_config.allow_empty {
253                                    vec![]
254                                } else {
255                                    continue;
256                                }
257                            } else {
258                                selections.output().collect::<Vec<S>>()
259                            };
260                            return Ok(ret);
261                        }
262                        Action::Quit(code) => {
263                            return Err(MatchError::Abort(code));
264                        }
265
266                        // UI
267                        Action::SetHeader(context) => {
268                            if let Some(s) = context {
269                                header.set(s, true);
270                            } else {
271                                header.clear(true);
272                            }
273                        }
274                        Action::SetFooter(context) => {
275                            if let Some(s) = context {
276                                footer_ui.set(s, false);
277                            } else {
278                                footer_ui.clear(false);
279                            }
280                        }
281                        // this sometimes aborts the viewer on some files, why?
282                        Action::CyclePreview => {
283                            if let Some(p) = preview_ui.as_mut() {
284                                p.cycle_layout();
285                                if !p.command().is_empty() {
286                                    state.update_preview(p.command());
287                                }
288                            }
289                        }
290
291                        Action::PreviewHScroll(x) | Action::PreviewScroll(x) => {
292                            if let Some(p) = preview_ui.as_mut() {
293                                p.scroll(matches!(action, Action::PreviewHScroll(_)), x);
294                            }
295                        }
296                        Action::Preview(context) => {
297                            if let Some(p) = preview_ui.as_mut() {
298                                if !state.update_preview(context.as_str()) {
299                                    p.toggle_show()
300                                } else {
301                                    p.show(true);
302                                }
303                            };
304                        }
305                        Action::Help(context) => {
306                            if let Some(p) = preview_ui.as_mut() {
307                                // empty payload signifies help
308                                if !state.update_preview_set(context) {
309                                    state.update_preview_unset()
310                                } else {
311                                    p.show(true);
312                                }
313                            };
314                        }
315                        Action::SwitchPreview(idx) => {
316                            if let Some(p) = preview_ui.as_mut() {
317                                if let Some(idx) = idx {
318                                    if !p.set_layout(idx) && !state.update_preview(p.command()) {
319                                        p.toggle_show();
320                                    }
321                                } else {
322                                    p.toggle_show()
323                                }
324                            }
325                        }
326                        Action::SetPreview(idx) => {
327                            if let Some(p) = preview_ui.as_mut() {
328                                if let Some(idx) = idx {
329                                    p.set_layout(idx);
330                                } else {
331                                    state.update_preview(p.command());
332                                }
333                            }
334                        }
335                        Action::ToggleWrap => {
336                            results.wrap(!results.is_wrap());
337                        }
338                        Action::ToggleWrapPreview => {
339                            if let Some(p) = preview_ui.as_mut() {
340                                p.wrap(!p.is_wrap());
341                            }
342                        }
343
344                        // Programmable
345                        Action::Execute(payload) => {
346                            state.set_interrupt(Interrupt::Execute, payload);
347                        }
348                        Action::Become(payload) => {
349                            state.set_interrupt(Interrupt::Become, payload);
350                        }
351                        Action::Reload(payload) => {
352                            state.set_interrupt(Interrupt::Reload, payload);
353                        }
354                        Action::Print(payload) => {
355                            state.set_interrupt(Interrupt::Print, payload);
356                        }
357
358                        Action::SetInput(context) => {
359                            input.set(context, u16::MAX);
360                        }
361                        Action::Column(context) => {
362                            results.toggle_col(context);
363                        }
364                        Action::CycleColumn => {
365                            results.cycle_col();
366                        }
367                        // Edit
368                        Action::ForwardChar => input.forward_char(),
369                        Action::BackwardChar => input.backward_char(),
370                        Action::ForwardWord => input.forward_word(),
371                        Action::BackwardWord => input.backward_word(),
372                        Action::DeleteChar => input.delete(),
373                        Action::DeleteWord => input.delete_word(),
374                        Action::DeleteLineStart => input.delete_line_start(),
375                        Action::DeleteLineEnd => input.delete_line_end(),
376                        Action::Cancel => input.cancel(),
377
378                        // Navigation
379                        Action::Up(x) | Action::Down(x) => {
380                            let next = matches!(action, Action::Down(_)) ^ results.reverse();
381                            for _ in 0..x.into() {
382                                if next {
383                                    results.cursor_next();
384                                } else {
385                                    results.cursor_prev();
386                                }
387                            }
388                        }
389                        Action::PreviewUp(n) => {
390                            if let Some(p) = preview_ui.as_mut() {
391                                p.up(n)
392                            }
393                        }
394                        Action::PreviewDown(n) => {
395                            if let Some(p) = preview_ui.as_mut() {
396                                p.down(n)
397                            }
398                        }
399                        Action::PreviewHalfPageUp => todo!(),
400                        Action::PreviewHalfPageDown => todo!(),
401                        Action::Pos(pos) => {
402                            let pos = if pos >= 0 {
403                                pos as u32
404                            } else {
405                                results.status.matched_count.saturating_sub((-pos) as u32)
406                            };
407                            results.cursor_jump(pos);
408                        }
409                        Action::InputPos(pos) => {
410                            let pos = if pos >= 0 {
411                                pos as u16
412                            } else {
413                                (input.len() as u16).saturating_sub((-pos) as u16)
414                            };
415                            input.set(None, pos);
416                        }
417
418                        // Experimental/Debugging
419                        Action::Redraw => {
420                            tui.redraw();
421                        }
422                        Action::Overlay(index) => {
423                            if let Some(x) = overlay_ui.as_mut() {
424                                x.enable(index, &ui.area);
425                                tui.redraw();
426                            };
427                        }
428                        Action::Custom(e) => {
429                            if let Some(handler) = ext_handler {
430                                handler(
431                                    e,
432                                    &mut state.dispatcher(
433                                        &mut ui,
434                                        &mut picker_ui,
435                                        &mut footer_ui,
436                                        &mut preview_ui,
437                                    ),
438                                );
439                            }
440                        }
441                        _ => {}
442                    }
443                }
444                _ => {}
445            }
446
447            let interrupt = state.interrupt();
448
449            match interrupt {
450                Interrupt::None => continue,
451                Interrupt::Execute => {
452                    if controller_tx.send(Event::Pause).is_err() {
453                        break;
454                    }
455                    tui.enter_execute();
456                    did_exit = true;
457                    did_pause = true;
458                }
459                Interrupt::Reload => {
460                    picker_ui.worker.restart(false);
461                }
462                Interrupt::Become => {
463                    tui.exit();
464                }
465                _ => {}
466            }
467            // Apply interrupt effect
468            {
469                let mut dispatcher =
470                    state.dispatcher(&mut ui, &mut picker_ui, &mut footer_ui, &mut preview_ui);
471                for h in dynamic_handlers.1.get(interrupt) {
472                    h(&mut dispatcher);
473                }
474
475                if matches!(interrupt, Interrupt::Become) {
476                    return Err(MatchError::Become(state.payload().clone()));
477                }
478            }
479
480            if state.should_quit {
481                log::debug!("Exiting due to should_quit");
482                let ret = picker_ui.selector.output().collect::<Vec<S>>();
483                return if picker_ui.selector.is_disabled()
484                    && let Some((_, item)) = get_current(&picker_ui)
485                {
486                    Ok(vec![item])
487                } else if ret.is_empty() {
488                    Err(MatchError::Abort(0))
489                } else {
490                    Ok(ret)
491                };
492            } else if state.should_quit_nomatch {
493                log::debug!("Exiting due to should_quit_nomatch");
494                return Err(MatchError::NoMatch);
495            }
496        }
497
498        // debug!("{state:?}");
499
500        // ------------- update state + render ------------------------
501        picker_ui.update();
502        // process exit conditions
503        if exit_config.select_1
504            && picker_ui.results.status.matched_count == 1
505            && let Some((_, item)) = get_current(&picker_ui)
506        {
507            return Ok(vec![item]);
508        }
509
510        // resume tui
511        if did_exit {
512            tui.return_execute()
513                .map_err(|e| MatchError::TUIError(e.to_string()))?;
514            tui.redraw();
515        }
516
517        let mut overlay_ui_ref = overlay_ui.as_mut();
518        tui.terminal
519            .draw(|frame| {
520                let mut area = frame.area();
521
522                render_ui(frame, &mut area, &ui);
523
524                let mut _area = area;
525
526                let full_width_footer = footer_ui.single()
527                    && footer_ui.config.row_connection_style == RowConnectionStyle::Full;
528
529                let mut footer =
530                    if full_width_footer || preview_ui.as_ref().is_none_or(|p| !p.is_show()) {
531                        split(&mut _area, footer_ui.height(), picker_ui.reverse())
532                    } else {
533                        Rect::default()
534                    };
535
536                let [preview, picker_area, footer] = if let Some(preview_ui) = preview_ui.as_mut()
537                    && let Some(layout) = preview_ui.layout()
538                {
539                    let [preview, mut picker_area] = layout.split(_area);
540
541                    if state.iterations == 0 && picker_area.width <= 5 {
542                        warn!("UI too narrow, hiding preview");
543                        preview_ui.show(false);
544
545                        [Rect::default(), _area, footer]
546                    } else {
547                        if !full_width_footer {
548                            footer =
549                                split(&mut picker_area, footer_ui.height(), picker_ui.reverse());
550                        }
551
552                        [preview, picker_area, footer]
553                    }
554                } else {
555                    [Rect::default(), _area, footer]
556                };
557
558                let [input, status, header, results] = picker_ui.layout(picker_area);
559
560                // compare and save dimensions
561                did_resize = state.update_layout([preview, input, status, results]);
562
563                if did_resize {
564                    picker_ui.results.update_dimensions(&results);
565                    picker_ui.input.update_width(input.width);
566                    footer_ui.update_width(
567                        if footer_ui.config.row_connection_style == RowConnectionStyle::Capped {
568                            area.width
569                        } else {
570                            footer.width
571                        },
572                    );
573                    picker_ui.header.update_width(header.width);
574                    // although these only want update when the whole ui change
575                    ui.update_dimensions(area);
576                    if let Some(x) = overlay_ui_ref.as_deref_mut() {
577                        x.update_dimensions(&area);
578                    }
579                };
580
581                render_input(frame, input, &mut picker_ui.input);
582                render_status(frame, status, &picker_ui.results);
583                render_results(frame, results, &mut picker_ui, &mut click);
584                render_display(frame, header, &mut picker_ui.header, &picker_ui.results);
585                render_display(frame, footer, &mut footer_ui, &picker_ui.results);
586                if let Some(preview_ui) = preview_ui.as_mut() {
587                    state.update_preview_ui(preview_ui);
588                    if did_resize {
589                        preview_ui.update_dimensions(&preview);
590                    }
591                    render_preview(frame, preview, preview_ui);
592                }
593                if let Some(x) = overlay_ui_ref {
594                    x.draw(frame);
595                }
596            })
597            .map_err(|e| MatchError::TUIError(e.to_string()))?;
598
599        // useful to clear artifacts
600        if did_resize && tui.config.redraw_on_resize && !did_exit {
601            tui.redraw();
602        }
603        buffer.clear();
604
605        // note: the remainder could be scoped by a conditional on having run?
606        // ====== Event handling ==========
607        state.update(&picker_ui, &overlay_ui);
608        let events = state.events();
609
610        // ---- Invoke handlers -------
611        let mut dispatcher =
612            state.dispatcher(&mut ui, &mut picker_ui, &mut footer_ui, &mut preview_ui);
613        // if let Some((signal, handler)) = signal_handler &&
614        // let s = signal.load(std::sync::atomic::Ordering::Acquire) &&
615        // s > 0
616        // {
617        //     handler(s, &mut dispatcher);
618        //     signal.store(0, std::sync::atomic::Ordering::Release);
619        // };
620
621        // ping handlers with events
622        for e in events.iter() {
623            for h in dynamic_handlers.0.get(e) {
624                h(&mut dispatcher, &e)
625            }
626        }
627
628        // ------------------------------
629        // send events into controller
630        for e in events.iter() {
631            controller_tx
632                .send(e)
633                .unwrap_or_else(|err| eprintln!("send failed: {:?}", err));
634        }
635        // =================================
636
637        if did_pause {
638            log::debug!("Waiting for ack response to pause");
639            if controller_tx.send(Event::Resume).is_err() {
640                break;
641            };
642            // due to control flow, this does nothing, but is anyhow a useful safeguard to guarantee the pause
643            while let Some(msg) = render_rx.recv().await {
644                if matches!(msg, RenderCommand::Ack) {
645                    log::debug!("Recieved ack response to pause");
646                    break;
647                }
648            }
649        }
650
651        click.process(&mut buffer);
652    }
653
654    Err(MatchError::EventLoopClosed)
655}
656
657// ------------------------- HELPERS ----------------------------
658
659pub enum Click {
660    None,
661    ResultPos(u16),
662    ResultIdx(u32),
663}
664
665impl Click {
666    fn process<A: ActionExt>(&mut self, buffer: &mut Vec<RenderCommand<A>>) {
667        match self {
668            Self::ResultIdx(u) => {
669                buffer.push(RenderCommand::Action(Action::Pos(*u as i32)));
670            }
671            _ => {
672                // todo
673            }
674        }
675        *self = Click::None
676    }
677}
678
679fn render_preview(frame: &mut Frame, area: Rect, ui: &mut PreviewUI) {
680    // if ui.view.changed() {
681    // doesn't work, use resize
682    //     frame.render_widget(Clear, area);
683    // } else {
684    //     let widget = ui.make_preview();
685    //     frame.render_widget(widget, area);
686    // }
687    let widget = ui.make_preview();
688    frame.render_widget(widget, area);
689}
690
691fn render_results<T: SSS, S: Selection>(
692    frame: &mut Frame,
693    mut area: Rect,
694    ui: &mut PickerUI<T, S>,
695    click: &mut Click,
696) {
697    let cap = matches!(
698        ui.results.config.row_connection_style,
699        RowConnectionStyle::Capped
700    );
701    let (widget, table_width) = ui.make_table(click);
702
703    if cap {
704        area.width = area.width.min(table_width);
705    }
706
707    frame.render_widget(widget, area);
708}
709
710fn render_input(frame: &mut Frame, area: Rect, ui: &mut InputUI) {
711    ui.scroll_to_cursor();
712    let widget = ui.make_input();
713    if let CursorSetting::Default = ui.config.cursor {
714        frame.set_cursor_position(ui.cursor_offset(&area))
715    };
716
717    frame.render_widget(widget, area);
718}
719
720fn render_status(frame: &mut Frame, area: Rect, ui: &ResultsUI) {
721    if ui.config.status_show {
722        let widget = ui.make_status();
723        frame.render_widget(widget, area);
724    }
725}
726
727fn render_display(frame: &mut Frame, area: Rect, ui: &mut DisplayUI, results_ui: &ResultsUI) {
728    if !ui.show {
729        return;
730    }
731    let widget = ui.make_display(
732        results_ui.indentation() as u16,
733        results_ui.widths().to_vec(),
734        results_ui.config.column_spacing.0,
735    );
736
737    frame.render_widget(widget, area);
738
739    if ui.single() {
740        let widget = ui.make_full_width_row(results_ui.indentation() as u16);
741        frame.render_widget(widget, area);
742    }
743}
744
745fn render_ui(frame: &mut Frame, area: &mut Rect, ui: &UI) {
746    let widget = ui.make_ui();
747    frame.render_widget(widget, *area);
748    *area = ui.inner_area(area);
749}
750
751fn split(rect: &mut Rect, height: u16, cut_top: bool) -> Rect {
752    let h = height.min(rect.height);
753
754    if cut_top {
755        let offshoot = Rect {
756            x: rect.x,
757            y: rect.y,
758            width: rect.width,
759            height: h,
760        };
761
762        rect.y += h;
763        rect.height -= h;
764
765        offshoot
766    } else {
767        let offshoot = Rect {
768            x: rect.x,
769            y: rect.y + rect.height - h,
770            width: rect.width,
771            height: h,
772        };
773
774        rect.height -= h;
775
776        offshoot
777    }
778}
779
780// -----------------------------------------------------------------------------------
781
782#[cfg(test)]
783mod test {}
784
785// #[cfg(test)]
786// async fn send_every_second(tx: mpsc::UnboundedSender<RenderCommand>) {
787//     let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
788
789//     loop {
790//         interval.tick().await;
791//         if tx.send(RenderCommand::quit()).is_err() {
792//             break;
793//         }
794//     }
795// }