use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::app::{AddResourceMode, AppState, BlockId};
use crate::input::{hit_test, HitTarget};
use crate::render::{ContentWidthMode, ScrollbarMode, SpacingMode, SymbolMode, ThemeName};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppIntent {
None,
Refresh,
LoadFullDepth,
OpenResource(crate::domain::ResourceId),
ReplaceResource(crate::domain::ResourceId),
Navigate(crate::domain::ResourceId),
OpenUrl(String),
CopyUrl(String),
Back,
SaveSettings,
Quit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Activate(HitTarget),
Tick,
}
pub fn apply_event(state: &mut AppState, event: AppEvent) -> AppIntent {
match event {
AppEvent::Key(key) => apply_key(state, key),
AppEvent::Mouse(mouse) => apply_mouse(state, mouse),
AppEvent::Activate(target) => apply_target(state, target),
AppEvent::Tick => AppIntent::None,
}
}
fn apply_key(state: &mut AppState, key: KeyEvent) -> AppIntent {
if state.quit_confirmation {
return apply_quit_confirmation_key(state, key);
}
if state.add_resource_prompt.is_some() {
return apply_add_resource_prompt_key(state, key);
}
if state.resource_link_prompt.is_some() {
return apply_resource_link_prompt_key(state, key);
}
match key.code {
KeyCode::Char('q') if is_plain_shortcut(key) => {
if close_active_overlay(state) {
AppIntent::None
} else {
state.request_quit_confirmation();
AppIntent::None
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.should_quit = true;
AppIntent::Quit
}
KeyCode::Char('r') if is_plain_shortcut(key) => {
state.refresh_requested = true;
AppIntent::Refresh
}
KeyCode::Char('n') if is_plain_shortcut(key) => {
state.open_add_resource_prompt();
AppIntent::None
}
KeyCode::Char('f')
if !state.show_help
&& !state.show_settings
&& state.resource.has_partial_depth_warning()
&& is_plain_shortcut(key) =>
{
AppIntent::LoadFullDepth
}
KeyCode::Char('o') if is_plain_shortcut(key) => {
state.open_replace_resource_prompt();
AppIntent::None
}
KeyCode::Char('y') if !state.show_settings && is_plain_shortcut(key) => {
AppIntent::CopyUrl(visible_or_current_url(state))
}
KeyCode::Char('?') if is_plain_shortcut(key) => {
state.toggle_help();
AppIntent::None
}
KeyCode::Char('s') if is_plain_shortcut(key) => {
state.toggle_settings();
AppIntent::None
}
KeyCode::Char('v') if is_plain_shortcut(key) => {
state.toggle_feed_order();
AppIntent::None
}
KeyCode::Char('a')
if !state.show_help && !state.show_settings && is_plain_shortcut(key) =>
{
state.toggle_active_tab_expansion();
AppIntent::None
}
KeyCode::Char('x')
if !state.show_help && !state.show_settings && is_plain_shortcut(key) =>
{
state.close_resource_tab(state.active_resource_tab);
AppIntent::None
}
KeyCode::Esc if state.show_settings => {
state.close_settings();
AppIntent::None
}
KeyCode::Char('t') if state.show_settings && is_plain_shortcut(key) => {
if state.cycle_theme() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('y') if state.show_settings && is_plain_shortcut(key) => {
if state.cycle_symbols() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('p') if state.show_settings && is_plain_shortcut(key) => {
if state.cycle_spacing() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('w') if state.show_settings && is_plain_shortcut(key) => {
if state.cycle_width_mode() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('b') if state.show_settings && is_plain_shortcut(key) => {
if state.cycle_scrollbar() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('+') | KeyCode::Char('=')
if state.show_settings && is_plain_shortcut(key) =>
{
if state.increase_fixed_width() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char('-') if state.show_settings && is_plain_shortcut(key) => {
if state.decrease_fixed_width() {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
KeyCode::Char(ch @ '1'..='6') if !state.show_settings && is_plain_shortcut(key) => {
if let Some(tab) = numbered_tab(ch, state.tabs()) {
state.set_tab(tab);
}
AppIntent::None
}
KeyCode::Tab | KeyCode::Char('\t') => {
state.next_resource_tab();
AppIntent::None
}
KeyCode::BackTab => {
state.previous_resource_tab();
AppIntent::None
}
KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.next_resource_tab();
AppIntent::None
}
KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
state.next_resource_tab();
AppIntent::None
}
KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
state.previous_resource_tab();
AppIntent::None
}
KeyCode::Right => {
state.next_tab();
AppIntent::None
}
KeyCode::Char('l') if is_plain_shortcut(key) => {
state.next_tab();
AppIntent::None
}
KeyCode::Left => {
state.previous_tab();
AppIntent::None
}
KeyCode::Char('h') if is_plain_shortcut(key) => {
state.previous_tab();
AppIntent::None
}
KeyCode::Backspace => AppIntent::Back,
KeyCode::Enter => {
let Some(target) = state
.hit_areas
.iter()
.map(|area| area.target.clone())
.find(HitTarget::is_content_action)
else {
return AppIntent::None;
};
apply_target(state, target)
}
KeyCode::Down => {
state.scroll_down(1);
AppIntent::None
}
KeyCode::Char('j') if is_plain_shortcut(key) => {
state.scroll_down(1);
AppIntent::None
}
KeyCode::Up => {
state.scroll_up(1);
AppIntent::None
}
KeyCode::Char('k') if is_plain_shortcut(key) => {
state.scroll_up(1);
AppIntent::None
}
KeyCode::PageDown => {
state.scroll_down(10);
AppIntent::None
}
KeyCode::PageUp => {
state.scroll_up(10);
AppIntent::None
}
KeyCode::Home => {
state.scroll_to_top();
AppIntent::None
}
KeyCode::End => {
state.scroll_to_bottom();
AppIntent::None
}
KeyCode::Char('e') if is_plain_shortcut(key) => {
state.toggle_block(BlockId::Body);
AppIntent::None
}
_ => AppIntent::None,
}
}
fn apply_quit_confirmation_key(state: &mut AppState, key: KeyEvent) -> AppIntent {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.should_quit = true;
AppIntent::Quit
}
KeyCode::Enter | KeyCode::Char('q') if is_plain_shortcut(key) => {
state.should_quit = true;
AppIntent::Quit
}
KeyCode::Esc | KeyCode::Char('n') if is_plain_shortcut(key) => {
state.close_quit_confirmation();
AppIntent::None
}
_ => AppIntent::None,
}
}
fn apply_add_resource_prompt_key(state: &mut AppState, key: KeyEvent) -> AppIntent {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.clear_add_resource_input_or_close();
AppIntent::None
}
KeyCode::Esc | KeyCode::Char('q') if is_plain_shortcut(key) => {
state.close_add_resource_prompt();
AppIntent::None
}
KeyCode::Enter => confirm_add_resource_prompt(state),
KeyCode::Backspace => {
if let Some(input) = state.add_resource_input_mut() {
input.pop();
}
state.clear_add_resource_error();
AppIntent::None
}
KeyCode::Char(ch) if is_plain_shortcut(key) => {
if let Some(input) = state.add_resource_input_mut() {
input.push(ch);
}
state.clear_add_resource_error();
AppIntent::None
}
_ => AppIntent::None,
}
}
fn apply_resource_link_prompt_key(state: &mut AppState, key: KeyEvent) -> AppIntent {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.close_resource_link_prompt();
AppIntent::None
}
KeyCode::Esc | KeyCode::Char('q') if is_plain_shortcut(key) => {
state.close_resource_link_prompt();
AppIntent::None
}
KeyCode::Enter => confirm_resource_link_here(state),
KeyCode::Char('h') if is_plain_shortcut(key) => confirm_resource_link_here(state),
KeyCode::Char('n') | KeyCode::Char('t') if is_plain_shortcut(key) => {
confirm_resource_link_new_tab(state)
}
_ => AppIntent::None,
}
}
fn confirm_add_resource_prompt(state: &mut AppState) -> AppIntent {
match state.parse_add_resource_input() {
Ok(id) => match state
.add_resource_prompt
.as_ref()
.map(|prompt| prompt.mode)
.unwrap_or(AddResourceMode::NewTab)
{
AddResourceMode::NewTab => AppIntent::OpenResource(id),
AddResourceMode::ReplaceCurrent => AppIntent::ReplaceResource(id),
},
Err(error) => {
state.set_add_resource_error(error.to_string());
AppIntent::None
}
}
}
fn confirm_resource_link_here(state: &mut AppState) -> AppIntent {
let Some(id) = state.resource_link_prompt_target() else {
return AppIntent::None;
};
state.close_resource_link_prompt();
AppIntent::Navigate(id)
}
fn confirm_resource_link_new_tab(state: &mut AppState) -> AppIntent {
let Some(id) = state.resource_link_prompt_target() else {
return AppIntent::None;
};
state.close_resource_link_prompt();
AppIntent::OpenResource(id)
}
fn is_plain_shortcut(key: KeyEvent) -> bool {
!key.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
}
fn numbered_tab(ch: char, tabs: &[crate::app::Tab]) -> Option<crate::app::Tab> {
let index = ch.to_digit(10)?.checked_sub(1)? as usize;
tabs.get(index).copied()
}
fn apply_mouse(state: &mut AppState, mouse: MouseEvent) -> AppIntent {
match mouse.kind {
MouseEventKind::ScrollDown => {
if state.add_resource_prompt.is_some() || state.resource_link_prompt.is_some() {
return AppIntent::None;
}
state.scroll_down(3);
AppIntent::None
}
MouseEventKind::ScrollUp => {
if state.add_resource_prompt.is_some() || state.resource_link_prompt.is_some() {
return AppIntent::None;
}
state.scroll_up(3);
AppIntent::None
}
MouseEventKind::Down(MouseButton::Left) => {
let Some(target) = hit_test(&state.hit_areas, mouse.column, mouse.row) else {
return AppIntent::None;
};
if let HitTarget::Scrollbar { top, height } = target {
state.begin_scrollbar_drag(top, height, mouse.row);
return AppIntent::None;
}
apply_target(state, target)
}
MouseEventKind::Drag(MouseButton::Left) => {
state.drag_scrollbar(mouse.row);
AppIntent::None
}
MouseEventKind::Up(MouseButton::Left) => {
state.end_scrollbar_drag();
AppIntent::None
}
_ => AppIntent::None,
}
}
fn apply_target(state: &mut AppState, target: HitTarget) -> AppIntent {
match target {
HitTarget::Tab(tab) => {
state.set_tab(tab);
AppIntent::None
}
HitTarget::ResourceTab(index) => {
state.switch_resource_tab(index);
AppIntent::None
}
HitTarget::CloseResourceTab(index) => {
state.close_resource_tab(index);
AppIntent::None
}
HitTarget::PreviousResourceTab => {
state.scroll_resource_tabs_previous();
AppIntent::None
}
HitTarget::NextResourceTab => {
state.scroll_resource_tabs_next();
AppIntent::None
}
HitTarget::ToggleBlock(id) => {
state.toggle_block(id);
AppIntent::None
}
HitTarget::ExpandBlocks(blocks) => {
state.expand_blocks(blocks);
AppIntent::None
}
HitTarget::CollapseBlocks(blocks) => {
state.collapse_blocks(blocks);
AppIntent::None
}
HitTarget::ResourceLink { id, url } => apply_resource_link(state, id, url),
HitTarget::Navigate(id) => AppIntent::Navigate(id),
HitTarget::OpenHeaderUrl(url) => AppIntent::OpenUrl(url),
HitTarget::OpenUrl(url) => AppIntent::OpenUrl(url),
HitTarget::CopyVisibleUrl => AppIntent::CopyUrl(visible_or_current_url(state)),
HitTarget::OpenVisibleUrl => AppIntent::OpenUrl(visible_or_current_url(state)),
HitTarget::Refresh => {
state.refresh_requested = true;
AppIntent::Refresh
}
HitTarget::OpenResourcePrompt => {
state.open_add_resource_prompt();
AppIntent::None
}
HitTarget::ConfirmResourcePrompt => confirm_add_resource_prompt(state),
HitTarget::CancelResourcePrompt => {
state.close_add_resource_prompt();
AppIntent::None
}
HitTarget::ConfirmQuit => {
state.should_quit = true;
AppIntent::Quit
}
HitTarget::CancelQuit => {
state.close_quit_confirmation();
AppIntent::None
}
HitTarget::OpenLinkHere => confirm_resource_link_here(state),
HitTarget::OpenLinkInNewTab => confirm_resource_link_new_tab(state),
HitTarget::CancelResourceLinkPrompt => {
state.close_resource_link_prompt();
AppIntent::None
}
HitTarget::ModalOverlay => {
state.close_add_resource_prompt();
state.close_resource_link_prompt();
state.close_quit_confirmation();
AppIntent::None
}
HitTarget::LoadFullDepth => AppIntent::LoadFullDepth,
HitTarget::Quit => {
state.request_quit_confirmation();
AppIntent::None
}
HitTarget::Help => {
state.toggle_help();
AppIntent::None
}
HitTarget::Settings => {
state.toggle_settings();
AppIntent::None
}
HitTarget::CloseSettings => {
state.close_settings();
AppIntent::None
}
HitTarget::SetTheme(theme) => match theme.parse::<ThemeName>() {
Ok(theme) if state.set_theme(theme) => AppIntent::SaveSettings,
_ => AppIntent::None,
},
HitTarget::SetSymbols(symbols) => match symbols.parse::<SymbolMode>() {
Ok(symbols) if state.set_symbols(symbols) => AppIntent::SaveSettings,
_ => AppIntent::None,
},
HitTarget::SetSpacing(spacing) => match spacing.parse::<SpacingMode>() {
Ok(spacing) if state.set_spacing(spacing) => AppIntent::SaveSettings,
_ => AppIntent::None,
},
HitTarget::SetWidthMode(width_mode) => match width_mode.parse::<ContentWidthMode>() {
Ok(width_mode) if state.set_width_mode(width_mode) => AppIntent::SaveSettings,
_ => AppIntent::None,
},
HitTarget::SetFixedWidth(width) => {
if state.set_fixed_width(width) {
AppIntent::SaveSettings
} else {
AppIntent::None
}
}
HitTarget::SetScrollbar(scrollbar) => match scrollbar.parse::<ScrollbarMode>() {
Ok(scrollbar) if state.set_scrollbar(scrollbar) => AppIntent::SaveSettings,
_ => AppIntent::None,
},
HitTarget::Scrollbar { .. } => AppIntent::None,
}
}
fn close_active_overlay(state: &mut AppState) -> bool {
if state.add_resource_prompt.is_some() {
state.close_add_resource_prompt();
return true;
}
if state.resource_link_prompt.is_some() {
state.close_resource_link_prompt();
return true;
}
if state.show_help {
state.toggle_help();
return true;
}
if state.show_settings {
state.close_settings();
return true;
}
false
}
fn apply_resource_link(
state: &mut AppState,
id: crate::domain::ResourceId,
url: Option<String>,
) -> AppIntent {
if id.canonical_name() == state.resource.id.canonical_name() {
if url
.as_deref()
.is_some_and(|url| state.focus_activity_url(url))
{
return AppIntent::None;
}
state.status_message = Some(format!("already viewing {}", id.canonical_name()));
return AppIntent::None;
}
state.open_resource_link_prompt(id, url);
AppIntent::None
}
fn current_resource_url(state: &AppState) -> String {
state.resource.web_url()
}
fn visible_or_current_url(state: &AppState) -> String {
state
.hit_areas
.iter()
.find_map(|area| match &area.target {
HitTarget::OpenUrl(url) => Some(url.clone()),
HitTarget::ResourceLink { id, url } => {
Some(url.clone().unwrap_or_else(|| id.web_url()))
}
HitTarget::Navigate(id) => Some(id.web_url()),
_ => None,
})
.unwrap_or_else(|| current_resource_url(state))
}
#[cfg(test)]
mod tests {
use crossterm::event::MouseEvent;
use super::*;
use crate::app::Tab;
use crate::domain::{
ActivityEntry, ActivityKind, PullRequest, ReactionCounts, Resource, ResourceId,
ResourceKind, FULL_DEPTH_WARNING_HINT,
};
use crate::input::HitArea;
use ratatui::layout::Rect;
fn resource() -> Resource {
Resource {
id: ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 1,
kind_hint: Some(ResourceKind::Issue),
},
title: "Issue".into(),
url: "https://github.com/owner/repo/issues/1".into(),
state: "OPEN".into(),
author: "alice".into(),
created_at: "now".into(),
updated_at: "now".into(),
labels: vec![],
assignees: vec![],
reactions: ReactionCounts::default(),
body: "Body".into(),
activity: vec![],
related_resources: vec![],
metadata: vec![],
warnings: vec![],
pull_request: None,
}
}
fn resource_with_number(number: u64) -> Resource {
let mut resource = resource();
resource.id.number = number;
resource.title = format!("Issue {number}");
resource.url = format!("https://github.com/owner/repo/issues/{number}");
resource
}
fn state_with_resource_tabs() -> AppState {
let mut state = AppState::new(resource_with_number(1));
state.open_resource_in_tab(resource_with_number(2));
state.open_resource_in_tab(resource_with_number(3));
state
}
fn pr_resource() -> Resource {
let mut resource = resource();
resource.id.kind_hint = Some(ResourceKind::PullRequest);
resource.url = "https://github.com/owner/repo/pull/1".into();
resource.pull_request = Some(PullRequest {
base_ref: "main".into(),
head_ref: "topic".into(),
requested_reviewers: vec![],
review_decision: None,
merge_state: None,
additions: 0,
deletions: 0,
commits: vec![],
checks: vec![],
files: vec![],
metadata: vec![],
});
resource
}
fn activity_entry(id: &str, url: &str) -> ActivityEntry {
ActivityEntry {
id: id.into(),
kind: ActivityKind::Comment,
author: "alice".into(),
body: "comment".into(),
updated_at: "now".into(),
path: None,
line: None,
url: Some(url.into()),
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
}
}
#[test]
fn keyboard_tab_changes_active_resource_tab() {
let mut state = state_with_resource_tabs();
state.switch_resource_tab(0);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())),
);
assert_eq!(state.active_resource_tab, 1);
assert_eq!(state.resource.id.number, 2);
}
#[test]
fn shift_tab_changes_active_resource_tab() {
let mut state = state_with_resource_tabs();
state.switch_resource_tab(0);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
);
assert_eq!(state.active_resource_tab, 2);
assert_eq!(state.resource.id.number, 3);
}
#[test]
fn literal_tab_character_changes_active_resource_tab_for_tmux() {
let mut state = state_with_resource_tabs();
state.switch_resource_tab(0);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('\t'), KeyModifiers::empty())),
);
assert_eq!(state.active_resource_tab, 1);
assert_eq!(state.resource.id.number, 2);
}
#[test]
fn ctrl_i_changes_active_resource_tab_for_tmux_tab_encoding() {
let mut state = state_with_resource_tabs();
state.switch_resource_tab(0);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL)),
);
assert_eq!(state.active_resource_tab, 1);
assert_eq!(state.resource.id.number, 2);
}
#[test]
fn left_and_right_change_content_tabs() {
let mut state = AppState::new(pr_resource());
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Activity);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Overview);
}
#[test]
fn shift_left_and_right_change_resource_tabs() {
let mut state = state_with_resource_tabs();
state.switch_resource_tab(1);
state.set_tab(Tab::Activity);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)),
);
assert_eq!(state.active_resource_tab, 2);
assert_eq!(state.resource.id.number, 3);
assert_eq!(state.active_tab, Tab::Overview);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)),
);
assert_eq!(state.active_resource_tab, 1);
assert_eq!(state.resource.id.number, 2);
}
#[test]
fn vim_hjkl_mirror_arrow_keys() {
let mut state = AppState::new(pr_resource());
state.set_scroll_limit(10);
state.scroll = 5;
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())),
);
assert_eq!(state.scroll, 6);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::empty())),
);
assert_eq!(state.scroll, 5);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Activity);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Overview);
}
#[test]
fn number_keys_jump_to_visible_pr_tabs() {
for (shortcut, expected) in [
('1', Tab::Overview),
('2', Tab::Activity),
('3', Tab::Commits),
('4', Tab::Checks),
('5', Tab::Files),
('6', Tab::Links),
] {
let mut state = AppState::new(pr_resource());
state.scroll = 5;
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(
KeyCode::Char(shortcut),
KeyModifiers::empty(),
)),
);
assert_eq!(intent, AppIntent::None, "{shortcut}");
assert_eq!(state.active_tab, expected, "{shortcut}");
assert_eq!(state.scroll, 0, "{shortcut}");
}
}
#[test]
fn number_keys_jump_to_visible_issue_tabs() {
let mut state = AppState::new(resource());
state.scroll = 5;
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Links);
assert_eq!(state.scroll, 0);
}
#[test]
fn unsupported_number_key_keeps_current_tab() {
let mut state = AppState::new(resource());
state.set_tab(Tab::Activity);
state.scroll = 5;
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('6'), KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Activity);
assert_eq!(state.scroll, 5);
}
#[test]
fn number_keys_are_inert_when_modified_or_settings_are_open() {
let mut state = AppState::new(pr_resource());
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::CONTROL)),
);
assert_eq!(state.active_tab, Tab::Overview);
state.show_settings = true;
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::empty())),
);
assert_eq!(state.active_tab, Tab::Overview);
}
#[test]
fn control_letter_shortcuts_are_limited_to_tmux_safe_exceptions() {
for shortcut in [
'a', 'b', 'd', 'e', 'f', 'o', 'q', 'r', 's', 'u', 'v', 'y', '?',
] {
let mut state = AppState::new(resource());
state.scroll = 4;
state.set_scroll_limit(9);
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(
KeyCode::Char(shortcut),
KeyModifiers::CONTROL,
)),
);
assert_eq!(intent, AppIntent::None, "Ctrl-{shortcut} should be inert");
assert_eq!(state.active_tab, Tab::Overview, "Ctrl-{shortcut}");
assert_eq!(state.scroll, 4, "Ctrl-{shortcut}");
assert!(!state.refresh_requested, "Ctrl-{shortcut}");
assert!(!state.should_quit, "Ctrl-{shortcut}");
assert!(!state.show_help, "Ctrl-{shortcut}");
assert!(!state.show_settings, "Ctrl-{shortcut}");
assert!(!state.reverse_chronological, "Ctrl-{shortcut}");
assert!(state.expanded_blocks.is_empty(), "Ctrl-{shortcut}");
}
}
#[test]
fn settings_control_shortcuts_do_not_change_preferences() {
for shortcut in ['t', 'y', 'p', 'w', 'b', '+', '-'] {
let mut state = AppState::new(resource());
state.show_settings = true;
let theme = state.theme;
let symbols = state.symbols;
let spacing = state.spacing;
let width_mode = state.width_mode;
let fixed_width = state.fixed_width;
let scrollbar = state.scrollbar;
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(
KeyCode::Char(shortcut),
KeyModifiers::CONTROL,
)),
);
assert_eq!(intent, AppIntent::None, "Ctrl-{shortcut} should be inert");
assert_eq!(state.theme, theme, "Ctrl-{shortcut}");
assert_eq!(state.symbols, symbols, "Ctrl-{shortcut}");
assert_eq!(state.spacing, spacing, "Ctrl-{shortcut}");
assert_eq!(state.width_mode, width_mode, "Ctrl-{shortcut}");
assert_eq!(state.fixed_width, fixed_width, "Ctrl-{shortcut}");
assert_eq!(state.scrollbar, scrollbar, "Ctrl-{shortcut}");
assert!(state.show_settings, "Ctrl-{shortcut}");
}
}
#[test]
fn ctrl_c_quits() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
);
assert_eq!(intent, AppIntent::Quit);
assert!(state.should_quit);
}
#[test]
fn keyboard_v_toggles_feed_order_and_resets_scroll() {
let mut state = AppState::new(resource());
state.scroll = 7;
state.set_scroll_limit(10);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::empty())),
);
assert!(state.reverse_chronological);
assert_eq!(state.scroll, 0);
assert_eq!(state.scroll_limit, u16::MAX);
}
#[test]
fn keyboard_a_toggles_current_tab_expansion() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(state.block_expanded(&BlockId::Body));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(!state.block_expanded(&BlockId::Body));
}
#[test]
fn keyboard_a_is_inert_for_overlays() {
let mut state = AppState::new(resource());
state.show_help = true;
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(state.expanded_blocks.is_empty());
}
#[test]
fn keyboard_f_loads_full_depth_only_when_partial_warning_is_present() {
let mut complete_state = AppState::new(resource());
let intent = apply_event(
&mut complete_state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
let mut partial_state = AppState::new(resource());
partial_state.resource.warnings.push(format!(
"normal API depth shows the first 100 only for comments; {FULL_DEPTH_WARNING_HINT} for exhaustive pagination"
));
let intent = apply_event(
&mut partial_state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::LoadFullDepth);
}
#[test]
fn mouse_click_on_tab_changes_active_tab() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::Tab(Tab::Links),
));
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(state.active_tab, Tab::Links);
}
#[test]
fn opening_resource_prompt_clears_stale_click_targets() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::Tab(Tab::Links),
));
let open = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty())),
);
let click = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(open, AppIntent::None);
assert_eq!(click, AppIntent::None);
assert!(state.add_resource_prompt.is_some());
assert_eq!(state.active_tab, Tab::Overview);
}
#[test]
fn mouse_wheel_scrolls_without_using_ctrl_shortcuts() {
let mut state = AppState::new(resource());
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 4,
row: 4,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(state.scroll, 3);
}
#[test]
fn repeated_scroll_down_at_bottom_is_idempotent() {
let mut state = AppState::new(resource());
state.set_scroll_limit(9);
state.scroll_to_bottom();
for _ in 0..20 {
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 4,
row: 4,
modifiers: KeyModifiers::empty(),
}),
);
}
assert_eq!(state.scroll, 9);
}
#[test]
fn repeated_key_down_at_bottom_is_idempotent() {
let mut state = AppState::new(resource());
state.set_scroll_limit(4);
state.scroll_to_bottom();
for _ in 0..20 {
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())),
);
}
assert_eq!(state.scroll, 4);
}
#[test]
fn repeated_page_down_at_bottom_is_idempotent() {
let mut state = AppState::new(resource());
state.set_scroll_limit(12);
state.scroll_to_bottom();
for _ in 0..20 {
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty())),
);
}
assert_eq!(state.scroll, 12);
}
#[test]
fn mouse_wheel_is_inert_while_resource_prompt_is_open() {
let mut state = AppState::new(resource());
state.set_scroll_limit(12);
state.scroll = 6;
state.open_add_resource_prompt();
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollUp] {
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert_eq!(state.scroll, 6);
}
}
#[test]
fn mouse_wheel_is_inert_while_resource_link_prompt_is_open() {
let mut state = AppState::new(resource());
state.set_scroll_limit(12);
state.scroll = 6;
state.open_resource_link_prompt(
ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 2,
kind_hint: Some(ResourceKind::Issue),
},
Some("https://github.com/owner/repo/issues/2".into()),
);
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollUp] {
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert_eq!(state.scroll, 6);
}
}
#[test]
fn mouse_click_on_refresh_target_requests_refresh() {
let mut state = AppState::new(resource());
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 9, 1), HitTarget::Refresh));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::Refresh);
assert!(state.refresh_requested);
}
#[test]
fn mouse_click_on_load_full_target_requests_full_depth_load() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 11, 1),
HitTarget::LoadFullDepth,
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::LoadFullDepth);
}
#[test]
fn keyboard_o_opens_replace_current_resource_prompt() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(matches!(
state.add_resource_prompt.as_ref().map(|prompt| prompt.mode),
Some(AddResourceMode::ReplaceCurrent)
));
}
#[test]
fn replace_current_resource_prompt_confirms_replace_intent() {
let mut state = AppState::new(resource());
state.open_replace_resource_prompt();
state.add_resource_input_mut().unwrap().push_str("#42");
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(
intent,
AppIntent::ReplaceResource(crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 42,
kind_hint: None,
})
);
}
#[test]
fn keyboard_y_falls_back_to_current_resource_url() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())),
);
assert_eq!(
intent,
AppIntent::CopyUrl("https://github.com/owner/repo/issues/1".into())
);
}
#[test]
fn keyboard_y_copies_first_visible_open_url() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::ToggleBlock(BlockId::Body),
));
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 10, 1),
HitTarget::OpenUrl("https://github.com/owner/repo/actions/runs/1".into()),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())),
);
assert_eq!(
intent,
AppIntent::CopyUrl("https://github.com/owner/repo/actions/runs/1".into())
);
}
#[test]
fn keyboard_y_copies_first_visible_navigation_target() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::Navigate(ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 2,
kind_hint: Some(ResourceKind::PullRequest),
}),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())),
);
assert_eq!(
intent,
AppIntent::CopyUrl("https://github.com/owner/repo/pull/2".into())
);
}
#[test]
fn keyboard_y_in_settings_cycles_symbols_instead_of_copying() {
let mut state = AppState::new(resource());
state.show_settings = true;
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::SaveSettings);
assert_ne!(state.symbols, SymbolMode::Ascii);
}
#[test]
fn keyboard_enter_activates_first_visible_content_action() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::Tab(Tab::Links),
));
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 10, 1),
HitTarget::ToggleBlock(BlockId::Body),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(state.block_expanded(&BlockId::Body));
assert_eq!(state.active_tab, Tab::Overview);
}
#[test]
fn keyboard_enter_activates_visible_expand_all_action() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 12, 1),
HitTarget::ExpandBlocks(vec![BlockId::Body, BlockId::Activity("comment-1".into())]),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(state.block_expanded(&BlockId::Body));
assert!(state.block_expanded(&BlockId::Activity("comment-1".into())));
}
#[test]
fn mouse_click_on_collapse_all_action_collapses_blocks() {
let mut state = AppState::new(resource());
state.expand_blocks(vec![BlockId::Body, BlockId::Activity("comment-1".into())]);
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 14, 1),
HitTarget::CollapseBlocks(vec![BlockId::Body, BlockId::Activity("comment-1".into())]),
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert!(!state.block_expanded(&BlockId::Body));
assert!(!state.block_expanded(&BlockId::Activity("comment-1".into())));
}
#[test]
fn keyboard_enter_navigates_to_first_visible_content_link() {
let mut state = AppState::new(resource());
let id = ResourceId::from_owner_repo_number("owner/repo", "2").unwrap();
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::Navigate(id.clone()),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::Navigate(id));
}
#[test]
fn keyboard_enter_opens_first_visible_url_action() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::OpenUrl("https://github.com/owner/repo/actions/runs/1".into()),
));
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(
intent,
AppIntent::OpenUrl("https://github.com/owner/repo/actions/runs/1".into())
);
}
#[test]
fn mouse_click_on_open_target_requests_visible_or_current_url() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 10, 1),
HitTarget::Navigate(ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 2,
kind_hint: Some(ResourceKind::PullRequest),
}),
));
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 6, 1),
HitTarget::OpenVisibleUrl,
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(
intent,
AppIntent::OpenUrl("https://github.com/owner/repo/pull/2".into())
);
}
#[test]
fn mouse_click_on_copy_target_requests_visible_or_current_url() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 10, 1),
HitTarget::OpenUrl("https://github.com/owner/repo/issues/1#issuecomment-1".into()),
));
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 6, 1),
HitTarget::CopyVisibleUrl,
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(
intent,
AppIntent::CopyUrl("https://github.com/owner/repo/issues/1#issuecomment-1".into())
);
}
#[test]
fn mouse_click_on_url_target_requests_open_url() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 20, 1),
HitTarget::OpenUrl("https://github.com/owner/repo/actions/runs/1".into()),
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(
intent,
AppIntent::OpenUrl("https://github.com/owner/repo/actions/runs/1".into())
);
}
#[test]
fn different_resource_link_opens_choice_prompt_and_confirms_here() {
let mut state = AppState::new(resource());
let id = ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 2,
kind_hint: Some(ResourceKind::PullRequest),
};
let prompt = apply_event(
&mut state,
AppEvent::Activate(HitTarget::ResourceLink {
id: id.clone(),
url: Some("https://github.com/owner/repo/pull/2".into()),
}),
);
let confirm = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(prompt, AppIntent::None);
assert_eq!(confirm, AppIntent::Navigate(id));
assert!(state.resource_link_prompt.is_none());
}
#[test]
fn different_resource_link_can_open_in_new_tab_from_prompt() {
let mut state = AppState::new(resource());
let id = ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 2,
kind_hint: Some(ResourceKind::Issue),
};
apply_event(
&mut state,
AppEvent::Activate(HitTarget::ResourceLink {
id: id.clone(),
url: None,
}),
);
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::OpenResource(id));
assert!(state.resource_link_prompt.is_none());
}
#[test]
fn same_resource_comment_link_focuses_activity_without_prompt() {
let mut resource = resource();
resource.activity = vec![activity_entry(
"comment-1",
"https://github.com/owner/repo/issues/1#issuecomment-1",
)];
let mut state = AppState::new(resource);
let intent = apply_event(
&mut state,
AppEvent::Activate(HitTarget::ResourceLink {
id: ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 1,
kind_hint: Some(ResourceKind::Issue),
},
url: Some("https://github.com/owner/repo/issues/1#issuecomment-1".into()),
}),
);
assert_eq!(intent, AppIntent::None);
assert!(state.resource_link_prompt.is_none());
assert_eq!(state.active_tab, Tab::Activity);
assert!(state.block_expanded(&BlockId::Activity("comment-1".into())));
assert_eq!(
state.take_pending_activity_focus().as_deref(),
Some("comment-1")
);
}
#[test]
fn mouse_click_on_quit_target_requests_quit() {
let mut state = AppState::new(resource());
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 6, 1), HitTarget::Quit));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert!(state.quit_confirmation);
assert!(!state.should_quit);
}
#[test]
fn backspace_requests_resource_history_navigation() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())),
);
assert!(matches!(intent, AppIntent::Back));
}
#[test]
fn keyboard_question_mark_toggles_help_overlay() {
let mut state = AppState::new(resource());
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty())),
);
assert!(state.show_help);
}
#[test]
fn keyboard_s_toggles_settings() {
let mut state = AppState::new(resource());
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::empty())),
);
assert!(state.show_settings);
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())),
);
assert!(!state.show_settings);
}
#[test]
fn settings_keyboard_changes_return_save_intent() {
let mut state = AppState::new(resource());
state.show_settings = true;
let theme = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty())),
);
assert_eq!(theme, AppIntent::SaveSettings);
assert_eq!(state.theme, ThemeName::Catppuccin);
let symbols = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty())),
);
assert_eq!(symbols, AppIntent::SaveSettings);
assert_eq!(state.symbols, SymbolMode::Emoji);
let spacing = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty())),
);
assert_eq!(spacing, AppIntent::SaveSettings);
assert_eq!(state.spacing, SpacingMode::Compact);
let width_mode = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty())),
);
assert_eq!(width_mode, AppIntent::SaveSettings);
assert_eq!(state.width_mode, ContentWidthMode::Full);
let scrollbar = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty())),
);
assert_eq!(scrollbar, AppIntent::SaveSettings);
assert_eq!(state.scrollbar, ScrollbarMode::Always);
let width = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty())),
);
assert_eq!(width, AppIntent::SaveSettings);
assert_eq!(
state.fixed_width,
crate::render::DEFAULT_FIXED_CONTENT_WIDTH + crate::render::FIXED_CONTENT_WIDTH_STEP
);
}
#[test]
fn mouse_click_on_settings_targets_updates_preferences() {
let mut state = AppState::new(resource());
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 10, 1), HitTarget::Settings));
let open = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(open, AppIntent::None);
assert!(state.show_settings);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 20, 1),
HitTarget::SetTheme("solarized-dark".into()),
));
let theme = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 1,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(theme, AppIntent::SaveSettings);
assert_eq!(state.theme, ThemeName::Solarized);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 2, 20, 1),
HitTarget::SetSpacing("compact".into()),
));
let spacing = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 2,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(spacing, AppIntent::SaveSettings);
assert_eq!(state.spacing, SpacingMode::Compact);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 3, 20, 1),
HitTarget::SetWidthMode("full".into()),
));
let width_mode = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 3,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(width_mode, AppIntent::SaveSettings);
assert_eq!(state.width_mode, ContentWidthMode::Full);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 4, 20, 1),
HitTarget::SetFixedWidth(132),
));
let fixed_width = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 4,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(fixed_width, AppIntent::SaveSettings);
assert_eq!(state.fixed_width, 132);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 5, 20, 1),
HitTarget::SetScrollbar("hidden".into()),
));
let scrollbar = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 5,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(scrollbar, AppIntent::SaveSettings);
assert_eq!(state.scrollbar, ScrollbarMode::Hidden);
}
#[test]
fn mouse_scrollbar_click_and_drag_updates_scroll_position() {
let mut state = AppState::new(resource());
state.set_scroll_limit(100);
state.scrollbar = ScrollbarMode::Always;
state.hit_areas.push(HitArea::new(
Rect::new(79, 10, 1, 21),
HitTarget::Scrollbar {
top: 10,
height: 21,
},
));
let click = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 79,
row: 20,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(click, AppIntent::None);
assert_eq!(state.scroll, 50);
assert!(state.scrollbar_drag.is_some());
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 79,
row: 30,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(state.scroll, 100);
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 79,
row: 30,
modifiers: KeyModifiers::empty(),
}),
);
assert!(state.scrollbar_drag.is_none());
}
#[test]
fn keyboard_end_jumps_to_bottom_sentinel() {
let mut state = AppState::new(resource());
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::End, KeyModifiers::empty())),
);
assert_eq!(state.scroll, u16::MAX);
}
#[test]
fn mouse_click_on_help_target_toggles_help() {
let mut state = AppState::new(resource());
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 6, 1), HitTarget::Help));
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert!(state.show_help);
}
#[test]
fn keyboard_n_opens_resource_prompt_and_enter_confirms_relative_number() {
let mut state = AppState::new(resource());
let open = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty())),
);
assert_eq!(open, AppIntent::None);
assert!(state.add_resource_prompt.is_some());
for ch in ['4', '2'] {
apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())),
);
}
let confirm = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(
confirm,
AppIntent::OpenResource(crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 42,
kind_hint: None,
})
);
assert!(state.add_resource_prompt.is_some());
}
#[test]
fn invalid_resource_prompt_input_stays_open_with_error() {
let mut state = AppState::new(resource());
state.open_add_resource_prompt();
state
.add_resource_input_mut()
.unwrap()
.push_str("not a resource");
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
let prompt = state.add_resource_prompt.as_ref().unwrap();
assert!(prompt.error.is_some());
}
#[test]
fn ctrl_c_in_resource_prompt_clears_input_then_closes_when_empty() {
let mut state = AppState::new(resource());
state.open_add_resource_prompt();
state
.add_resource_input_mut()
.unwrap()
.push_str("owner/repo#42");
let first = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
);
let second = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
);
assert_eq!(first, AppIntent::None);
assert_eq!(second, AppIntent::None);
assert!(!state.should_quit);
assert!(state.add_resource_prompt.is_none());
}
#[test]
fn keyboard_q_closes_overlays_before_quit_confirmation() {
let mut state = AppState::new(resource());
state.show_help = true;
let close_help = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())),
);
let ask_quit = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())),
);
assert_eq!(close_help, AppIntent::None);
assert_eq!(ask_quit, AppIntent::None);
assert!(!state.show_help);
assert!(state.quit_confirmation);
assert!(!state.should_quit);
}
#[test]
fn keyboard_q_confirms_quit_only_after_confirmation_is_visible() {
let mut state = AppState::new(resource());
let ask_quit = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())),
);
let confirm_quit = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())),
);
assert_eq!(ask_quit, AppIntent::None);
assert_eq!(confirm_quit, AppIntent::Quit);
assert!(state.should_quit);
}
#[test]
fn keyboard_x_closes_current_resource_tab_only_in_normal_view() {
let mut state = AppState::new(resource());
let mut second = resource();
second.id.number = 2;
second.title = "Second".into();
state.open_resource_in_tab(second);
assert_eq!(state.active_resource_tab, 1);
let close = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())),
);
assert_eq!(close, AppIntent::None);
assert_eq!(state.resource_tabs.len(), 1);
assert_eq!(state.resource.id.number, 1);
state.show_help = true;
let ignored = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())),
);
assert_eq!(ignored, AppIntent::None);
assert!(state.show_help);
assert_eq!(state.resource_tabs.len(), 1);
}
#[test]
fn keyboard_x_keeps_last_resource_tab_open() {
let mut state = AppState::new(resource());
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert_eq!(state.resource_tabs.len(), 1);
assert_eq!(state.resource.id.number, 1);
}
#[test]
fn keyboard_q_closes_resource_prompt_without_quitting() {
let mut state = AppState::new(resource());
state.open_add_resource_prompt();
state
.add_resource_input_mut()
.unwrap()
.push_str("owner/repo#42");
let intent = apply_event(
&mut state,
AppEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())),
);
assert_eq!(intent, AppIntent::None);
assert!(state.add_resource_prompt.is_none());
assert!(!state.should_quit);
}
#[test]
fn mouse_targets_switch_close_and_open_resource_tabs() {
let mut state = AppState::new(resource());
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 10, 1),
HitTarget::OpenResourcePrompt,
));
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert!(state.add_resource_prompt.is_some());
state.close_add_resource_prompt();
let mut second = resource();
second.id.number = 2;
second.title = "Second".into();
state.open_resource_in_tab(second);
state.hit_areas.clear();
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 13, 1),
HitTarget::ResourceTab(0),
));
state.hit_areas.push(HitArea::new(
Rect::new(10, 1, 3, 1),
HitTarget::CloseResourceTab(1),
));
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 1,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(state.resource.id.number, 1);
state.hit_areas.push(HitArea::new(
Rect::new(0, 1, 13, 1),
HitTarget::ResourceTab(1),
));
state.hit_areas.push(HitArea::new(
Rect::new(10, 1, 3, 1),
HitTarget::CloseResourceTab(1),
));
apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 11,
row: 1,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(state.resource_tabs.len(), 1);
}
#[test]
fn modal_mouse_targets_win_over_underlying_content_hits() {
let mut state = AppState::new(resource());
state.open_add_resource_prompt();
state
.add_resource_input_mut()
.unwrap()
.push_str("owner/repo#42");
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 8, 1),
HitTarget::Navigate(crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 99,
kind_hint: None,
}),
));
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 8, 1),
HitTarget::ConfirmResourcePrompt,
));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(
intent,
AppIntent::OpenResource(crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 42,
kind_hint: None,
})
);
}
#[test]
fn modal_overlay_mouse_target_closes_add_resource_prompt() {
let mut state = AppState::new(resource());
state.open_add_resource_prompt();
state.hit_areas.push(HitArea::new(
Rect::new(0, 0, 8, 1),
HitTarget::Navigate(crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 99,
kind_hint: None,
}),
));
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 8, 1), HitTarget::ModalOverlay));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert!(state.add_resource_prompt.is_none());
}
#[test]
fn modal_overlay_mouse_target_closes_resource_link_prompt() {
let mut state = AppState::new(resource());
state.open_resource_link_prompt(
crate::domain::ResourceId {
owner: "owner".into(),
repo: "repo".into(),
number: 42,
kind_hint: None,
},
Some("https://github.com/owner/repo/issues/42".into()),
);
state
.hit_areas
.push(HitArea::new(Rect::new(0, 0, 8, 1), HitTarget::ModalOverlay));
let intent = apply_event(
&mut state,
AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 0,
modifiers: KeyModifiers::empty(),
}),
);
assert_eq!(intent, AppIntent::None);
assert!(state.resource_link_prompt.is_none());
}
}