finery 0.9.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::{
    cell::{Cell, RefCell},
    rc::Rc,
    time::Duration,
};

use ratatui::{Frame, layout::Rect};
use tuicore::{
    AnimationSettings, Dropdown, DropdownCommitMode, DropdownLabelPosition, DropdownSearchMode,
    DropdownVariant, EventCtx, EventOutcome, EventRoute, FocusCtx, FocusId, FocusTarget, LayoutCtx,
    LayoutProposal, LayoutResult, LayoutSizeHint, LifecycleCtx, RenderCtx, TickResult, TuiEvent,
    TuiNode, keybindings,
};

const MENU_HOST_WIDTH: u16 = 46;
const MENU_HOST_HEIGHT: u16 = 10;
const MENU_FIELD_WIDTH: u16 = 36;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(in crate::pages::backlog) enum BacklogQuickAction {
    MoveToTop,
    MoveToBottom,
    MoveToSection(BacklogDestination),
    MoveToDestinationTop(BacklogDestination),
    MoveToDestinationBottom(BacklogDestination),
}

impl BacklogQuickAction {
    pub(in crate::pages::backlog) fn top_bottom() -> [Self; 2] {
        [Self::MoveToTop, Self::MoveToBottom]
    }

    pub(in crate::pages::backlog) fn label(&self) -> String {
        match self {
            Self::MoveToTop => "Move to top".into(),
            Self::MoveToBottom => "Move to bottom".into(),
            Self::MoveToSection(destination) => format!("Move to {}", destination.label),
            Self::MoveToDestinationTop(destination) => format!("Top of {}", destination.label),
            Self::MoveToDestinationBottom(destination) => {
                format!("Bottom of {}", destination.label)
            }
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(in crate::pages::backlog) struct BacklogDestination {
    pub section_id: String,
    pub label: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::pages::backlog) enum BacklogQuickMenuEvent {
    MoveToTop {
        section_id: String,
        keys: Vec<String>,
        source_order: Vec<String>,
    },
    MoveToBottom {
        section_id: String,
        keys: Vec<String>,
        source_order: Vec<String>,
    },
    MoveToSection {
        source_section_id: String,
        destination: BacklogDestination,
        keys: Vec<String>,
        to_top: bool,
    },
    MoveLocked,
    Closed,
}

pub(in crate::pages::backlog) struct BacklogQuickMenu {
    dropdown: Dropdown<BacklogQuickAction, BacklogQuickAction>,
    selected: Rc<RefCell<Vec<BacklogQuickAction>>>,
    keys: Vec<String>,
    section_id: Option<String>,
    source_order: Vec<String>,
    events: Vec<BacklogQuickMenuEvent>,
    field_area: Rect,
    move_locked: Rc<Cell<bool>>,
}

impl BacklogQuickMenu {
    pub(in crate::pages::backlog) fn new(move_locked: Rc<Cell<bool>>) -> Self {
        let selected = Rc::new(RefCell::new(Vec::new()));
        let selected_actions = Rc::clone(&selected);
        let dropdown = Dropdown::single(
            BacklogQuickAction::top_bottom(),
            |action| action.clone(),
            |action| action.label(),
        )
        .variant(DropdownVariant::Filled)
        .label("Backlog actions")
        .label_position(DropdownLabelPosition::Inline)
        .search_mode(DropdownSearchMode::Fuzzy)
        .commit_mode(DropdownCommitMode::Explicit)
        .centered(true)
        .show_field_when_open(false)
        .backdrop_amount(0.0)
        .tab_stop(false)
        .max_popup_height(8)
        .on_select(move |actions| {
            if let Some(action) = actions.first() {
                selected_actions.borrow_mut().push(action.clone());
            }
        });
        Self {
            dropdown,
            selected,
            keys: Vec::new(),
            section_id: None,
            source_order: Vec::new(),
            events: Vec::new(),
            field_area: Rect::default(),
            move_locked,
        }
    }

    pub(in crate::pages::backlog) fn open(
        &mut self,
        section_id: String,
        keys: Vec<String>,
        source_order: Vec<String>,
        destinations: Vec<BacklogDestination>,
        ctx: &mut EventCtx<()>,
    ) -> bool {
        if self.move_locked.get() {
            self.events.push(BacklogQuickMenuEvent::MoveLocked);
            return false;
        }
        self.section_id = Some(section_id);
        self.keys = keys;
        self.source_order = source_order;
        self.selected.borrow_mut().clear();
        self.dropdown.clear_selection();
        self.dropdown.set_rows(
            BacklogQuickAction::top_bottom().into_iter().chain(
                destinations
                    .into_iter()
                    .map(BacklogQuickAction::MoveToSection),
            ),
        );
        self.dropdown.set_search_query("");
        self.dropdown.open_with_context(ctx);
        true
    }

    pub(in crate::pages::backlog) fn take_events(&mut self) -> Vec<BacklogQuickMenuEvent> {
        std::mem::take(&mut self.events)
    }

    fn centered_field_area(&self, area: Rect) -> Rect {
        let width = MENU_FIELD_WIDTH.min(area.width);
        let hint = <Dropdown<BacklogQuickAction, BacklogQuickAction> as TuiNode<()>>::measure(
            &self.dropdown,
            LayoutProposal::at_most(width, area.height),
        );
        let height = hint.preferred.height.min(area.height);
        Rect::new(
            area.x.saturating_add(area.width.saturating_sub(width) / 2),
            area.y
                .saturating_add(area.height.saturating_sub(height) / 2),
            width,
            height,
        )
    }

    fn finish_event(
        &mut self,
        was_open: bool,
        outcome: EventOutcome,
        ctx: &mut EventCtx<()>,
    ) -> EventOutcome {
        if self.move_locked.get() {
            self.selected.borrow_mut().clear();
            self.events.push(BacklogQuickMenuEvent::MoveLocked);
            return outcome;
        }
        for action in self.selected.borrow_mut().drain(..) {
            let Some(section_id) = self.section_id.clone() else {
                continue;
            };
            let event = match action {
                BacklogQuickAction::MoveToTop => BacklogQuickMenuEvent::MoveToTop {
                    section_id,
                    keys: self.keys.clone(),
                    source_order: self.source_order.clone(),
                },
                BacklogQuickAction::MoveToBottom => BacklogQuickMenuEvent::MoveToBottom {
                    section_id,
                    keys: self.keys.clone(),
                    source_order: self.source_order.clone(),
                },
                BacklogQuickAction::MoveToSection(destination) => {
                    self.dropdown.clear_selection();
                    self.dropdown.set_rows([
                        BacklogQuickAction::MoveToDestinationTop(destination.clone()),
                        BacklogQuickAction::MoveToDestinationBottom(destination),
                    ]);
                    self.dropdown.set_search_query("");
                    self.dropdown.open_with_context(ctx);
                    continue;
                }
                BacklogQuickAction::MoveToDestinationTop(destination) => {
                    BacklogQuickMenuEvent::MoveToSection {
                        source_section_id: section_id,
                        destination,
                        keys: self.keys.clone(),
                        to_top: true,
                    }
                }
                BacklogQuickAction::MoveToDestinationBottom(destination) => {
                    BacklogQuickMenuEvent::MoveToSection {
                        source_section_id: section_id,
                        destination,
                        keys: self.keys.clone(),
                        to_top: false,
                    }
                }
            };
            self.events.push(event);
        }
        if was_open && !self.dropdown.is_open() && self.events.is_empty() {
            self.events.push(BacklogQuickMenuEvent::Closed);
        }
        outcome
    }
}

impl TuiNode for BacklogQuickMenu {
    fn measure(&self, proposal: LayoutProposal) -> LayoutSizeHint {
        LayoutSizeHint::content(MENU_HOST_WIDTH, MENU_HOST_HEIGHT).normalized(proposal)
    }

    fn layout(&mut self, area: Rect, ctx: &mut LayoutCtx) -> LayoutResult {
        self.field_area = self.centered_field_area(area);
        <Dropdown<BacklogQuickAction, BacklogQuickAction> as TuiNode<()>>::layout(
            &mut self.dropdown,
            self.field_area,
            ctx,
        );
        LayoutResult::new(area)
    }

    fn render<'a>(&'a self, frame: &mut Frame, _area: Rect, ctx: &mut RenderCtx<'a>) {
        self.dropdown.render(frame, self.field_area, ctx);
    }

    fn event(&mut self, event: &TuiEvent, ctx: &mut EventCtx<()>) -> EventOutcome {
        if let TuiEvent::Key(key) = event
            && keybindings().focus().unfocus_matches(*key)
        {
            self.events.push(BacklogQuickMenuEvent::Closed);
            ctx.stop_propagation();
            return EventOutcome::Handled;
        }
        let was_open = self.dropdown.is_open();
        let outcome = self.dropdown.event(event, ctx);
        self.finish_event(was_open, outcome, ctx)
    }

    fn dispatch_event(
        &mut self,
        route: &EventRoute,
        event: &TuiEvent,
        ctx: &mut EventCtx<()>,
    ) -> EventOutcome {
        let was_open = self.dropdown.is_open();
        let outcome = self.dropdown.dispatch_event(route, event, ctx);
        self.finish_event(was_open, outcome, ctx)
    }

    fn focus(&mut self, target: Option<&FocusId>, focused: bool, ctx: &mut FocusCtx<()>) {
        self.dropdown.focus(target, focused, ctx);
    }

    fn dispatch_focus(&mut self, target: &FocusTarget, focused: bool, ctx: &mut FocusCtx<()>) {
        self.dropdown.dispatch_focus(target, focused, ctx);
    }

    fn tick(&mut self, dt: Duration, settings: AnimationSettings) -> TickResult {
        <Dropdown<BacklogQuickAction, BacklogQuickAction> as TuiNode<()>>::tick(
            &mut self.dropdown,
            dt,
            settings,
        )
    }

    fn init(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.dropdown.init(ctx);
    }

    fn mount(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.dropdown.mount(ctx);
    }

    fn unmount(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.dropdown.unmount(ctx);
    }

    fn destroy(&mut self, ctx: &mut LifecycleCtx<()>) {
        self.dropdown.destroy(ctx);
    }
}