From d8c50c7d04bf9245cca6c4faa84a4444bcb01228 Mon Sep 17 00:00:00 2001 From: boxdot Date: Fri, 2 Dec 2022 19:03:54 +0100 Subject: [PATCH] Select channel popup (#203) --- CHANGELOG.md | 5 ++ README.md | 2 +- src/app.rs | 113 ++++++++++++++++++------------------ src/channels.rs | 89 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 19 ++++-- src/shortcuts.rs | 6 +- src/ui/coords.rs | 44 ++------------ src/ui/draw.rs | 103 ++++++++++++++++++++------------- src/util.rs | 146 +---------------------------------------------- 10 files changed, 240 insertions(+), 288 deletions(-) create mode 100644 src/channels.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index adc4462..ed50ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## Unreleased +### Changed + +- Replace search box by channel selection popup (Ctrl+p) ([#203]) + ### Fixed - Do not create log file when logging is disabled ([#204]) +[#203]: https://github.com/boxdot/gurk-rs/pull/203 [#204]: https://github.com/boxdot/gurk-rs/pull/204 ## 0.3.0 diff --git a/README.md b/README.md index 7ab3082..efa90fa 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,6 @@ libraries that are not available on crates.io. * App navigation * `f1` Toggle help panel. - * `alt+tab` Switch between message input box and search bar. * Message input * `tab` Send emoji from input line as reaction on selected message. * `alt+enter` Switch between multi-line and singl-line input modes. @@ -103,6 +102,7 @@ libraries that are not available on crates.io. * `alt+Down / PgDown` Select next message. * `ctrl+j / Up` Select previous channel. * `ctrl+k / Down` Select next channel. + * `ctrl+p` Open / close channel selection popup. ## License diff --git a/src/app.rs b/src/app.rs index 11c30f1..0a39069 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::channels::SelectChannel; use crate::config::Config; use crate::data::{Channel, ChannelId, Message, TypingAction, TypingSet}; use crate::input::Input; @@ -6,9 +7,7 @@ use crate::signal::{ Attachment, GroupIdentifierBytes, GroupMasterKeyBytes, ProfileKey, ResolvedGroup, SignalManager, }; use crate::storage::{MessageId, Storage}; -use crate::util::{ - self, FilteredStatefulList, LazyRegex, StatefulList, ATTACHMENT_REGEX, URL_REGEX, -}; +use crate::util::{self, LazyRegex, StatefulList, ATTACHMENT_REGEX, URL_REGEX}; use anyhow::{anyhow, bail, Context as _}; use chrono::{Duration, Utc}; @@ -43,19 +42,17 @@ pub struct App { pub config: Config, signal_manager: Box, pub storage: Box, - pub channels: FilteredStatefulList, + pub channels: StatefulList, pub messages: BTreeMap>, pub user_id: Uuid, pub should_quit: bool, url_regex: LazyRegex, attachment_regex: LazyRegex, display_help: bool, - pub is_searching: bool, - pub channel_text_width: usize, receipt_handler: ReceiptHandler, pub input: Input, - pub search_box: Input, pub is_multiline_input: bool, + pub(crate) select_channel: SelectChannel, } impl App { @@ -67,7 +64,7 @@ impl App { let user_id = signal_manager.user_id(); // build index of channels and messages for using them as lists content - let mut channels: FilteredStatefulList = Default::default(); + let mut channels: StatefulList = Default::default(); let mut messages: BTreeMap<_, StatefulList<_>> = BTreeMap::new(); for channel in storage.channels() { channels.items.push(channel.id); @@ -87,6 +84,7 @@ impl App { .map(|channel| channel.name.clone()); (Reverse(last_message_arrived_at), channel_name) }); + channels.next(); Ok(Self { config, @@ -99,18 +97,16 @@ impl App { url_regex: LazyRegex::new(URL_REGEX), attachment_regex: LazyRegex::new(ATTACHMENT_REGEX), display_help: false, - is_searching: false, - channel_text_width: 0, receipt_handler: ReceiptHandler::new(), input: Default::default(), - search_box: Default::default(), is_multiline_input: false, + select_channel: Default::default(), }) } pub fn get_input(&mut self) -> &mut Input { - if self.is_searching { - &mut self.search_box + if self.select_channel.is_shown { + &mut self.select_channel.input } else { &mut self.input } @@ -179,20 +175,33 @@ impl App { pub fn on_key(&mut self, key: KeyEvent) -> anyhow::Result<()> { match key.code { KeyCode::Char('\r') => self.get_input().put_char('\n'), - KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) && !self.is_searching => { - self.is_multiline_input = !self.is_multiline_input; - } - KeyCode::Enter if self.is_multiline_input && !self.is_searching => { - self.get_input().new_line(); - } - KeyCode::Enter if !self.get_input().data.is_empty() && !self.is_searching => { - if let Some(idx) = self.channels.state.selected() { - self.send_input(self.channels.filtered_items[idx])?; - } - } KeyCode::Enter => { - // input is empty - self.try_open_url(); + if !self.select_channel.is_shown { + if key.modifiers.contains(KeyModifiers::ALT) { + self.is_multiline_input = !self.is_multiline_input; + } else if self.is_multiline_input { + self.get_input().new_line(); + } else if !self.input.data.is_empty() { + if let Some(idx) = self.channels.state.selected() { + self.send_input(idx)?; + } + } else { + // input is empty + self.try_open_url(); + } + } else if self.select_channel.is_shown { + if let Some(channel_id) = self.select_channel.selected_channel_id().copied() { + self.select_channel.is_shown = false; + let (idx, _) = self + .channels + .items + .iter() + .enumerate() + .find(|(_, &id)| id == channel_id) + .context("channel disappeared during channel select popup")?; + self.channels.state.select(Some(idx)); + } + } } KeyCode::Home => { self.get_input().on_home(); @@ -209,7 +218,19 @@ impl App { KeyCode::Backspace => { self.get_input().on_backspace(); } - KeyCode::Esc => self.reset_message_selection(), + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !self.select_channel.is_shown { + self.select_channel.reset(&*self.storage); + } + self.select_channel.is_shown = !self.select_channel.is_shown; + } + KeyCode::Esc => { + if self.select_channel.is_shown { + self.select_channel.is_shown = false; + } else { + self.reset_message_selection(); + } + } KeyCode::Char(c) => self.get_input().put_char(c), KeyCode::Tab => { if let Some(idx) = self.channels.state.selected() { @@ -1241,10 +1262,6 @@ impl App { self.display_help = !self.display_help; } - pub fn toggle_search(&mut self) { - self.is_searching = !self.is_searching; - } - pub fn is_help(&self) -> bool { self.display_help } @@ -1266,32 +1283,16 @@ impl App { Ok(()) } - /// Filters visible channel based on the provided `pattern` - /// - /// `pattern` is compared to channel name or channel member contact names, case insensitively. - pub(crate) fn filter_channels(&mut self, pattern: &str) { - let pattern = pattern.to_lowercase(); + pub fn is_select_channel_shown(&self) -> bool { + self.select_channel.is_shown + } - // move out `channels` temporarily to make borrow checker happy - let mut channels = std::mem::take(&mut self.channels); - channels.filter(|channel_id: &ChannelId| { - let channel = self - .storage - .channel(*channel_id) - .expect("non-existent channel"); - match pattern.chars().next() { - None => true, - Some('@') => match channel.group_data.as_ref() { - Some(group_data) => group_data - .members - .iter() - .any(|&id| self.name_by_id(id).to_lowercase().contains(&pattern[1..])), - None => channel.name.to_lowercase().contains(&pattern[1..]), - }, - _ => channel.name.to_lowercase().contains(&pattern), - } - }); - self.channels = channels; + pub fn select_channel_prev(&mut self) { + self.select_channel.prev(); + } + + pub fn select_channel_next(&mut self) { + self.select_channel.next(); } } diff --git a/src/channels.rs b/src/channels.rs new file mode 100644 index 0000000..24288b9 --- /dev/null +++ b/src/channels.rs @@ -0,0 +1,89 @@ +use std::cmp::Reverse; + +use tui::widgets::ListState; + +use crate::data::ChannelId; +use crate::input::Input; +use crate::storage::Storage; + +#[derive(Default)] +pub(crate) struct SelectChannel { + pub is_shown: bool, + pub input: Input, + pub state: ListState, + items: Vec, + filtered_index: Vec, +} + +pub(crate) struct ItemData { + pub channel_id: ChannelId, + pub name: String, +} + +impl SelectChannel { + pub fn reset(&mut self, storage: &dyn Storage) { + self.input.take(); + self.state = Default::default(); + + let items = storage.channels().map(|channel| ItemData { + channel_id: channel.id, + name: channel.name.clone(), + }); + self.items.clear(); + self.items.extend(items); + + self.items.sort_unstable_by_key(|item| { + let last_message_arrived_at = storage + .messages(item.channel_id) + .last() + .map(|message| message.arrived_at); + (Reverse(last_message_arrived_at), item.name.clone()) + }); + + self.filtered_index.clear(); + } + + pub fn prev(&mut self) { + let selected = self + .state + .selected() + .map(|idx| idx.saturating_sub(1)) + .unwrap_or(0); + self.state.select(Some(selected)); + } + + pub fn next(&mut self) { + let selected = self.state.selected().map(|idx| idx + 1).unwrap_or(0); + self.state.select(Some(selected)); + } + + fn filter_by_input(&mut self) { + let index = self.items.iter().enumerate().filter_map(|(idx, item)| { + if item + .name + .to_ascii_lowercase() + .contains(&self.input.data.to_ascii_lowercase()) + { + Some(idx) + } else { + None + } + }); + self.filtered_index.clear(); + self.filtered_index.extend(index); + } + + pub fn filtered_names(&mut self) -> impl Iterator + '_ { + self.filter_by_input(); + self.filtered_index + .iter() + .map(|&idx| self.items[idx].name.clone()) + } + + pub fn selected_channel_id(&self) -> Option<&ChannelId> { + let idx = self.state.selected()?; + let item_idx = self.filtered_index[idx]; + let item = &self.items[item_idx]; + Some(&item.channel_id) + } +} diff --git a/src/lib.rs b/src/lib.rs index 968e200..d85af2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Signal Messenger client for terminal pub mod app; +mod channels; pub mod config; pub mod cursor; pub mod data; diff --git a/src/main.rs b/src/main.rs index b1f6c08..24fde6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -218,7 +218,7 @@ async fn run_single_threaded(relink: bool) -> anyhow::Result<()> { let col = event.column; let row = event.row; if let Some(channel_idx) = - ui::coords_within_channels_view(&terminal.get_frame(), &app, col, row) + ui::coords_within_channels_view(terminal.get_frame().size(), col, row) .map(|(_, row)| row as usize) .filter(|&idx| idx < app.channels.items.len()) { @@ -278,7 +278,6 @@ async fn run_single_threaded(relink: bool) -> anyhow::Result<()> { KeyCode::Down if event.modifiers.contains(KeyModifiers::ALT) => app.on_pgdn(), KeyCode::PageUp => app.on_pgup(), KeyCode::PageDown => app.on_pgdn(), - KeyCode::Tab if event.modifiers.contains(KeyModifiers::ALT) => app.toggle_search(), KeyCode::Char('f') if event.modifiers.contains(KeyModifiers::ALT) => { app.get_input().move_forward_word(); } @@ -289,28 +288,36 @@ async fn run_single_threaded(relink: bool) -> anyhow::Result<()> { app.get_input().on_delete_word(); } KeyCode::Down => { - if app.is_multiline_input { + if app.is_select_channel_shown() { + app.select_channel_next() + } else if app.is_multiline_input { app.input.move_line_down(); } else { app.select_next_channel(); } } KeyCode::Char('j') if event.modifiers.contains(KeyModifiers::CONTROL) => { - if app.is_multiline_input { + if app.is_select_channel_shown() { + app.select_channel_next() + } else if app.is_multiline_input { app.input.move_line_down(); } else { app.select_next_channel(); } } KeyCode::Up => { - if app.is_multiline_input { + if app.is_select_channel_shown() { + app.select_channel_prev() + } else if app.is_multiline_input { app.input.move_line_up(); } else { app.select_previous_channel(); } } KeyCode::Char('k') if event.modifiers.contains(KeyModifiers::CONTROL) => { - if app.is_multiline_input { + if app.is_select_channel_shown() { + app.select_channel_prev() + } else if app.is_multiline_input { app.input.move_line_up(); } else { app.select_previous_channel(); diff --git a/src/shortcuts.rs b/src/shortcuts.rs index c93a4cc..3c38878 100644 --- a/src/shortcuts.rs +++ b/src/shortcuts.rs @@ -19,8 +19,8 @@ pub static SHORTCUTS: &[ShortCut] = &[ description: "Switch between single-line and multi-line modes.", }, ShortCut { - event: "alt+tab", - description: "Switch between message input box and search bar.", + event: "ctrl+p", + description: "Open pop-up for selecting a channel", }, ShortCut { event: "ctrl+w / ctrl+backspace / alt+backspace", @@ -56,7 +56,7 @@ pub static SHORTCUTS: &[ShortCut] = &[ }, ShortCut { event: "Esc", - description: "Reset message selection.", + description: "Reset message selection / Close popup.", }, ShortCut { event: "alt+Up / PgUp", diff --git a/src/ui/coords.rs b/src/ui/coords.rs index 64321e4..feb0500 100644 --- a/src/ui/coords.rs +++ b/src/ui/coords.rs @@ -1,46 +1,14 @@ -use tui::backend::Backend; -use tui::Frame; - -use crate::app::App; +use tui::layout::Rect; use super::CHANNEL_VIEW_RATIO; -pub fn coords_within_channels_view( - f: &Frame, - app: &App, - x: u16, - y: u16, -) -> Option<(u16, u16)> { - let rect = f.size(); - - // Compute the offset due to the lines in the search bar - let text_width = app.channel_text_width; - let lines: Vec = - app.search_box - .data - .chars() - .enumerate() - .fold(Vec::new(), |mut lines, (idx, c)| { - if idx % text_width == 0 { - lines.push(String::new()); - } - match c { - '\n' => { - lines.last_mut().unwrap().push('\n'); - lines.push(String::new()) - } - _ => lines.last_mut().unwrap().push(c), - } - lines - }); - let num_input_lines = lines.len().max(1); - - if y < 3 + num_input_lines as u16 { - return None; +pub fn coords_within_channels_view(area: Rect, x: u16, y: u16) -> Option<(u16, u16)> { + if y < 1 { + None } // 1 offset around the view for taking the border into account - if 0 < x && x < rect.width / CHANNEL_VIEW_RATIO as u16 && 0 < y && y + 1 < rect.height { - Some((x - 1, y - (3 + num_input_lines as u16))) + else if 0 < x && x < area.width / CHANNEL_VIEW_RATIO as u16 && 0 < y && y + 1 < area.height { + Some((x - 1, y - 1)) } else { None } diff --git a/src/ui/draw.rs b/src/ui/draw.rs index 10582eb..b7e2d67 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -8,12 +8,13 @@ use tui::backend::Backend; use tui::layout::{Constraint, Corner, Direction, Layout, Rect}; use tui::style::{Color, Style}; use tui::text::{Span, Spans, Text}; -use tui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use tui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use tui::Frame; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use uuid::Uuid; use crate::app::App; +use crate::channels::SelectChannel; use crate::cursor::Cursor; use crate::data::Message; use crate::receipt::{Receipt, ReceiptEvent}; @@ -50,50 +51,53 @@ pub fn draw(f: &mut Frame, app: &mut App) { .direction(Direction::Horizontal) .split(f.size()); - draw_channels_column(f, app, chunks[0]); + draw_channels(f, app, chunks[0]); draw_chat(f, app, chunks[1]); -} -fn draw_channels_column(f: &mut Frame, app: &mut App, area: Rect) { - let text_width = area.width.saturating_sub(2) as usize; - let (wrapped_input, cursor, num_input_lines) = wrap( - &app.search_box.data, - app.search_box.cursor.clone(), - text_width, - ); + if app.select_channel.is_shown { + draw_select_channel_popup(f, &mut app.select_channel); + } +} +fn draw_select_channel_popup(f: &mut Frame, select_channel: &mut SelectChannel) { + let area = centered_rect(60, 60, f.size()); let chunks = Layout::default() - .constraints( - [ - Constraint::Length(num_input_lines as u16 + 2), - Constraint::Min(0), - ] - .as_ref(), - ) + .constraints([Constraint::Length(1 + 2), Constraint::Min(0)].as_ref()) .direction(Direction::Vertical) .split(area); - - draw_channels(f, app, chunks[1]); - - let input = Paragraph::new(Text::from(wrapped_input)) - .block(Block::default().borders(Borders::ALL).title("Search")); + f.render_widget(Clear, area); + let input = Paragraph::new(Text::from(select_channel.input.data.clone())).block( + Block::default() + .borders(Borders::ALL) + .title("Select channel"), + ); f.render_widget(input, chunks[0]); - if app.is_searching { - f.set_cursor( - chunks[0].x + cursor.col as u16 + 1, - chunks[0].y + cursor.line as u16 + 1, - ); + let cursor = &select_channel.input.cursor; + f.set_cursor( + chunks[0].x + cursor.col as u16 + 1, + chunks[0].y + cursor.line as u16 + 1, + ); + let items: Vec<_> = select_channel.filtered_names().map(ListItem::new).collect(); + match select_channel.state.selected() { + Some(idx) if items.len() <= idx => { + select_channel.state.select(items.len().checked_sub(1)); + } + None if !items.is_empty() => { + select_channel.state.select(Some(0)); + } + _ => (), } + let list = List::new(items) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(Style::default().fg(Color::Black).bg(Color::Gray)); + f.render_stateful_widget(list, chunks[1], &mut select_channel.state); } fn draw_channels(f: &mut Frame, app: &mut App, area: Rect) { let channel_list_width = area.width.saturating_sub(2) as usize; - let pattern = app.search_box.data.clone(); - app.channel_text_width = channel_list_width; - app.filter_channels(&pattern); - let channels: Vec = app .channels + .items .iter() .filter_map(|&channel_id| app.storage.channel(channel_id)) .map(|channel| { @@ -196,7 +200,7 @@ fn draw_chat(f: &mut Frame, app: &mut App, area: Rect) { let input = Paragraph::new(Text::from(wrapped_input)) .block(Block::default().borders(Borders::ALL).title(title)); f.render_widget(input, chunks[1]); - if !app.is_searching { + if !app.select_channel.is_shown { f.set_cursor( chunks[1].x + cursor.col as u16 + 1, // +1 for frame chunks[1].y + cursor.line as u16 + 1, // +1 for frame @@ -263,13 +267,8 @@ fn draw_messages(f: &mut Frame, app: &mut App, area: Rect) { prepare_receipts(app, height); - let channel_id = app.channels.state.selected().and_then(|idx| { - let idx = *app.channels.filtered_items.get(idx).unwrap(); - app.channels.items.get(idx) - }); - let channel_id = match channel_id { - Some(id) => *id, - _ => return, + let Some(&channel_id) = app.channels.selected_item() else { + return }; let channel = app .storage @@ -674,6 +673,32 @@ fn displayed_quote(names: &NameResolver, quote: &Message) -> Option { Some(format!("({}) {}", name, quote.message.as_ref()?)) } +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} + #[cfg(test)] mod tests { use crate::signal::Attachment; diff --git a/src/util.rs b/src/util.rs index 74b7cd9..98339e4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -15,17 +15,6 @@ pub struct StatefulList { pub rendered: Rendered, } -#[derive(Debug, Serialize, Deserialize)] -pub struct FilteredStatefulList { - #[serde(skip)] - pub state: ListState, - pub items: Vec, - #[serde(skip)] - pub filtered_items: Vec, - #[serde(skip)] - pub rendered: Rendered, -} - impl PartialEq for StatefulList { fn eq(&self, other: &Self) -> bool { self.items == other.items @@ -34,14 +23,6 @@ impl PartialEq for StatefulList { impl Eq for StatefulList {} -impl PartialEq for FilteredStatefulList { - fn eq(&self, other: &Self) -> bool { - self.items == other.items - } -} - -impl Eq for FilteredStatefulList {} - #[derive(Debug, Clone, Default)] pub struct Rendered { pub offset: usize, @@ -57,48 +38,6 @@ impl Default for StatefulList { } } -impl FilteredStatefulList { - pub fn filter(&mut self, filter: impl Fn(&T) -> bool) { - self.filter_elements(filter); - // Update the selected message to not got past the bound of `self.filtered_items` - self.state.select(if self.filtered_items.is_empty() { - None - } else { - Some((self.filtered_items.len().max(1) - 1).min(self.state.selected().unwrap_or(0))) - }); - } -} - -impl Default for FilteredStatefulList { - fn default() -> Self { - Self { - state: Default::default(), - items: Vec::new(), - rendered: Default::default(), - filtered_items: Vec::new(), - } - } -} - -pub struct StatefulIterator<'a, T> { - index: usize, - list: &'a FilteredStatefulList, -} - -impl<'a, T> Iterator for StatefulIterator<'a, T> { - type Item = &'a T; - - fn next(&mut self) -> Option { - if self.index >= self.list.filtered_items.len() { - None - } else { - let res = self.list.items.get(self.list.filtered_items[self.index]); - self.index += 1; - res - } - } -} - impl StatefulList { pub fn next(&mut self) { let i = match self.state.selected() { @@ -147,91 +86,8 @@ impl StatefulList { }; self.state.select(Some(i)); } -} - -impl FilteredStatefulList { - pub fn iter(&'_ self) -> StatefulIterator { - StatefulIterator { - index: 0, - list: self, - } - } - - pub fn _get(&self, index: usize) -> Option<&T> { - if let Some(i) = self.filtered_items.get(index) { - self.items.get(*i) - } else { - None - } - } - - pub fn _get_mut(&self, index: usize) -> Option<&T> { - if let Some(i) = self.filtered_items.get(index) { - self.items.get(*i) - } else { - None - } - } - - pub fn filter_elements(&mut self, lambda: impl Fn(&T) -> bool) { - self.filtered_items = self - .items - .iter() - .enumerate() - .filter(|(_, c)| lambda(c)) - .map(|(i, _)| i) - .collect(); - } - - pub fn next(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i + 1 >= self.filtered_items.len() { - if MESSAGE_SCROLL_BACK { - 0 - } else { - i - } - } else { - i + 1 - } - } - None => { - if !self.filtered_items.is_empty() { - 0 - } else { - return; // nothing to select - } - } - }; - self.state.select(Some(i)); - } - - pub fn previous(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - if MESSAGE_SCROLL_BACK { - self.filtered_items.len() - 1 - } else { - 0 - } - } else { - i - 1 - } - } - None => { - if !self.filtered_items.is_empty() { - 0 - } else { - return; // nothing to select - } - } - }; - self.state.select(Some(i)); - } - pub fn selected_item(&self) -> Option<&T> { + pub(crate) fn selected_item(&self) -> Option<&T> { let idx = self.state.selected()?; Some(&self.items[idx]) }