use crossterm::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crate::session::{SessionId, SessionStatus};
use crate::tui::popup::PopupSection;
use crate::tui::tabs::{StatusIndicator, TabItem, find_tab_at_column};
use super::action::AppAction;
use super::state::AppState;
use super::tuiapp::TuiApp;
fn is_cancel_key(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Esc)
|| (matches!(key.code, KeyCode::Char('g')) && key.modifiers.contains(KeyModifiers::CONTROL))
}
impl TuiApp {
pub fn handle_event(&mut self, event: &Event) -> Option<AppAction> {
match event {
Event::Key(key) => self.handle_key(*key),
Event::Mouse(mouse) => self.handle_mouse(*mouse),
Event::Resize(cols, rows) => Some(AppAction::ResizeTerminal(*rows, *cols)),
_ => None,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<AppAction> {
match self.state {
AppState::Normal => self.handle_key_normal(key),
AppState::WorkspacePopup => self.handle_key_workspace_popup(key),
AppState::ConfirmQuit => self.handle_key_confirm_quit(key),
AppState::ErrorPopup { .. } => self.handle_key_error_popup(key),
AppState::SessionTerminatedPopup { session_id, .. } => {
self.handle_key_session_terminated_popup(key, session_id)
}
AppState::IssueLoading { .. } => {
self.handle_key_issue_loading(key)
}
AppState::ActionSelectPopup { .. } => self.handle_key_action_select_popup(key),
AppState::ConfirmPermissions { .. } => self.handle_key_confirm_permissions(key),
}
}
#[allow(clippy::unused_self)]
fn handle_key_issue_loading(&self, key: KeyEvent) -> Option<AppAction> {
is_cancel_key(key).then_some(AppAction::CancelIssueFlow)
}
fn handle_key_action_select_popup(&mut self, key: KeyEvent) -> Option<AppAction> {
if is_cancel_key(key) {
return Some(AppAction::CancelIssueFlow);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => Some(AppAction::SelectPrev),
KeyCode::Down | KeyCode::Char('j') => Some(AppAction::SelectNext),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(AppAction::SelectPrev)
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(AppAction::SelectNext)
}
KeyCode::Enter => {
if let AppState::ActionSelectPopup { ref choices, .. } = self.state
&& let Some(idx) = self.action_select_state.selected()
&& idx < choices.len()
{
return Some(AppAction::SelectActionChoice { index: idx });
}
None
}
KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let AppState::ActionSelectPopup { ref choices, .. } = self.state
&& let Some(idx) = self.action_select_state.selected()
&& idx < choices.len()
{
return Some(AppAction::SelectActionChoice { index: idx });
}
None
}
_ => None,
}
}
fn handle_key_confirm_permissions(&mut self, key: KeyEvent) -> Option<AppAction> {
if is_cancel_key(key) || matches!(key.code, KeyCode::Char('n' | 'N')) {
return Some(AppAction::CancelIssueFlow);
}
match key.code {
KeyCode::Char('y' | 'Y') => Some(AppAction::ConfirmDangerousPermissions),
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
Some(AppAction::TogglePermissionsChoice)
}
KeyCode::Enter | KeyCode::Char('m')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
if let AppState::ConfirmPermissions { selected_yes, .. } = self.state
&& selected_yes
{
return Some(AppAction::ConfirmDangerousPermissions);
}
Some(AppAction::CancelIssueFlow)
}
_ => None,
}
}
fn handle_key_session_terminated_popup(
&mut self,
key: KeyEvent,
session_id: SessionId,
) -> Option<AppAction> {
match key.code {
KeyCode::Char('c' | 'C') => {
self.state = AppState::Normal;
Some(AppAction::CloseSession { id: session_id })
}
KeyCode::Char('n' | 'N') | KeyCode::Esc => {
self.state = AppState::Normal;
None
}
_ => None,
}
}
#[allow(clippy::unused_self)]
fn handle_key_error_popup(&self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Enter | KeyCode::Esc => Some(AppAction::DismissError),
_ => None,
}
}
#[allow(clippy::unused_self)]
fn handle_key_normal(&mut self, key: KeyEvent) -> Option<AppAction> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('w') => Some(AppAction::TerminateCurrentSession),
KeyCode::Char('n') => Some(AppAction::NextSession),
KeyCode::Char('p') => Some(AppAction::PrevSession),
KeyCode::Char('s') => Some(AppAction::ShowWorkspacePopup),
KeyCode::Char('q') => Some(AppAction::ShowConfirmQuit),
_ => {
if let KeyCode::Char(c) = key.code {
let ctrl_code = c.to_ascii_lowercase() as u8 - b'a' + 1;
Some(AppAction::SendInput(vec![ctrl_code]))
} else {
None
}
}
};
}
match key.code {
KeyCode::Char(c) => Some(AppAction::SendInput(c.to_string().into_bytes())),
KeyCode::Enter => Some(AppAction::SendInput(vec![b'\r'])),
KeyCode::Backspace => Some(AppAction::SendInput(vec![0x7f])),
KeyCode::Tab => Some(AppAction::SendInput(vec![b'\t'])),
KeyCode::BackTab => Some(AppAction::SendInput(b"\x1b[Z".to_vec())),
KeyCode::Esc => Some(AppAction::SendInput(vec![0x1b])),
KeyCode::Up => Some(AppAction::SendInput(b"\x1b[A".to_vec())),
KeyCode::Down => Some(AppAction::SendInput(b"\x1b[B".to_vec())),
KeyCode::Right => Some(AppAction::SendInput(b"\x1b[C".to_vec())),
KeyCode::Left => Some(AppAction::SendInput(b"\x1b[D".to_vec())),
KeyCode::Home => Some(AppAction::SendInput(b"\x1b[H".to_vec())),
KeyCode::End => Some(AppAction::SendInput(b"\x1b[F".to_vec())),
KeyCode::PageUp => Some(AppAction::Scroll(-10)),
KeyCode::PageDown => Some(AppAction::Scroll(10)),
KeyCode::Delete => Some(AppAction::SendInput(b"\x1b[3~".to_vec())),
_ => None,
}
}
fn handle_key_workspace_popup(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Esc => return Some(AppAction::HidePopup),
KeyCode::Tab => return Some(AppAction::NextPopupSection),
KeyCode::BackTab => return Some(AppAction::PrevPopupSection),
_ => {}
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('g') => return Some(AppAction::HidePopup),
KeyCode::Char('n') => return Some(AppAction::CrossSectionNext),
KeyCode::Char('p') => return Some(AppAction::CrossSectionPrev),
KeyCode::Char('m') => return self.handle_enter_for_section(),
KeyCode::Char('q') => return Some(AppAction::ShowConfirmQuit),
_ => {}
}
}
match self.popup_state.section {
PopupSection::BranchInput => self.handle_key_branch_input(key),
PopupSection::Sessions => self.handle_key_session_section(key),
PopupSection::Worktrees => self.handle_key_worktree_section(key),
PopupSection::Issues => self.handle_key_issue_section(key),
}
}
fn handle_key_issue_section(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Up => Some(AppAction::SelectPrev),
KeyCode::Down => Some(AppAction::SelectNext),
KeyCode::Enter => self
.selected_issue()
.map(|i| AppAction::SelectIssue { number: i.number }),
_ => None,
}
}
fn handle_key_branch_input(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Char(c) => Some(AppAction::InputChar(c)),
KeyCode::Backspace => Some(AppAction::InputBackspace),
KeyCode::Left => Some(AppAction::InputCursorLeft),
KeyCode::Right => Some(AppAction::InputCursorRight),
KeyCode::Enter if !self.popup_state.input.is_empty() => Some(
AppAction::CreateSessionWithBranch(self.popup_state.input.clone()),
),
_ => None,
}
}
#[allow(clippy::unused_self)]
fn handle_key_session_section(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Down => Some(AppAction::SelectNext),
KeyCode::Up => Some(AppAction::SelectPrev),
KeyCode::Enter => Some(AppAction::ConfirmSelection),
KeyCode::Char('d') => self.selected_session_for_close(),
_ => None,
}
}
fn selected_session_for_close(&self) -> Option<AppAction> {
let idx = self.popup_state.session_list.selected()?;
let session = self.sessions.get(idx)?;
if matches!(session.status, SessionStatus::Terminated { .. }) {
Some(AppAction::CloseSession { id: session.id })
} else {
None
}
}
fn handle_key_worktree_section(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Down => Some(AppAction::SelectNext),
KeyCode::Up => Some(AppAction::SelectPrev),
KeyCode::Enter => self.selected_worktree().map(|w| AppAction::AdoptWorktree {
path: w.path.clone(),
}),
KeyCode::Char('d') => self.selected_worktree().map(|w| AppAction::DeleteWorktree {
path: w.path.clone(),
}),
KeyCode::Char('p') => self.selected_worktree().map(|w| AppAction::PullWorktree {
path: w.path.clone(),
}),
_ => None,
}
}
pub(super) fn selected_worktree(&self) -> Option<&super::super::popup::WorktreeItem> {
self.popup_state
.worktree_list
.selected()
.and_then(|idx| self.worktrees.get(idx))
}
pub(super) fn selected_issue(&self) -> Option<&super::super::popup::IssueItem> {
self.popup_state
.issue_list
.selected()
.and_then(|idx| self.issues.get(idx))
}
pub(super) fn handle_enter_for_section(&self) -> Option<AppAction> {
match self.popup_state.section {
PopupSection::BranchInput => {
if self.popup_state.input.is_empty() {
None
} else {
Some(AppAction::CreateSessionWithBranch(
self.popup_state.input.clone(),
))
}
}
PopupSection::Sessions => Some(AppAction::ConfirmSelection),
PopupSection::Worktrees => self.selected_worktree().map(|w| AppAction::AdoptWorktree {
path: w.path.clone(),
}),
PopupSection::Issues => self
.selected_issue()
.map(|i| AppAction::SelectIssue { number: i.number }),
}
}
#[allow(clippy::unused_self)]
fn handle_key_confirm_quit(&mut self, key: KeyEvent) -> Option<AppAction> {
match key.code {
KeyCode::Esc | KeyCode::Char('n' | 'N') => Some(AppAction::HidePopup),
KeyCode::Char('y' | 'Y') => Some(AppAction::ConfirmQuit),
KeyCode::Tab | KeyCode::Left | KeyCode::Right => Some(AppAction::ToggleQuitSelection),
_ => None,
}
}
pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<AppAction> {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if mouse.row == 0 && self.state == AppState::Normal {
let tabs: Vec<TabItem> = self
.sessions
.iter()
.map(|s| TabItem::new(&s.name, StatusIndicator::from(&s.status)))
.collect();
if let Some(idx) = find_tab_at_column(&tabs, mouse.column) {
return Some(AppAction::SwitchSession(idx));
}
}
None
}
MouseEventKind::ScrollUp => Some(AppAction::Scroll(-3)),
MouseEventKind::ScrollDown => Some(AppAction::Scroll(3)),
_ => None,
}
}
pub(super) fn select_next(&mut self) {
if let AppState::ActionSelectPopup { ref choices, .. } = self.state {
self.action_select_state.select_next(choices.len());
return;
}
match self.popup_state.section {
PopupSection::Sessions => {
if self.sessions.is_empty() {
return;
}
let current = self.popup_state.session_list.selected().unwrap_or(0);
let next = (current + 1) % self.sessions.len();
self.popup_state.session_list.select(Some(next));
}
PopupSection::Worktrees => {
if self.worktrees.is_empty() {
return;
}
let current = self.popup_state.worktree_list.selected().unwrap_or(0);
let next = (current + 1) % self.worktrees.len();
self.popup_state.worktree_list.select(Some(next));
}
PopupSection::Issues => {
if self.issues.is_empty() {
return;
}
let current = self.popup_state.issue_list.selected().unwrap_or(0);
let next = (current + 1) % self.issues.len();
self.popup_state.issue_list.select(Some(next));
}
PopupSection::BranchInput => {}
}
}
pub(super) fn select_prev(&mut self) {
if let AppState::ActionSelectPopup { ref choices, .. } = self.state {
self.action_select_state.select_prev(choices.len());
return;
}
match self.popup_state.section {
PopupSection::Sessions => {
if self.sessions.is_empty() {
return;
}
let current = self.popup_state.session_list.selected().unwrap_or(0);
let prev = if current == 0 {
self.sessions.len() - 1
} else {
current - 1
};
self.popup_state.session_list.select(Some(prev));
}
PopupSection::Worktrees => {
if self.worktrees.is_empty() {
return;
}
let current = self.popup_state.worktree_list.selected().unwrap_or(0);
let prev = if current == 0 {
self.worktrees.len() - 1
} else {
current - 1
};
self.popup_state.worktree_list.select(Some(prev));
}
PopupSection::Issues => {
if self.issues.is_empty() {
return;
}
let current = self.popup_state.issue_list.selected().unwrap_or(0);
let prev = if current == 0 {
self.issues.len() - 1
} else {
current - 1
};
self.popup_state.issue_list.select(Some(prev));
}
PopupSection::BranchInput => {}
}
}
pub(super) fn cross_section_next(&mut self) {
match self.popup_state.section {
PopupSection::BranchInput => {
if !self.sessions.is_empty() {
self.popup_state.section = PopupSection::Sessions;
self.popup_state.session_list.select(Some(0));
} else if !self.worktrees.is_empty() {
self.popup_state.section = PopupSection::Worktrees;
self.popup_state.worktree_list.select(Some(0));
} else if !self.issues.is_empty() {
self.popup_state.section = PopupSection::Issues;
self.popup_state.issue_list.select(Some(0));
}
}
PopupSection::Sessions => {
let current = self.popup_state.session_list.selected().unwrap_or(0);
if current + 1 < self.sessions.len() {
self.popup_state.session_list.select(Some(current + 1));
} else if !self.worktrees.is_empty() {
self.popup_state.section = PopupSection::Worktrees;
self.popup_state.worktree_list.select(Some(0));
} else if !self.issues.is_empty() {
self.popup_state.section = PopupSection::Issues;
self.popup_state.issue_list.select(Some(0));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
PopupSection::Worktrees => {
let current = self.popup_state.worktree_list.selected().unwrap_or(0);
if current + 1 < self.worktrees.len() {
self.popup_state.worktree_list.select(Some(current + 1));
} else if !self.issues.is_empty() {
self.popup_state.section = PopupSection::Issues;
self.popup_state.issue_list.select(Some(0));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
PopupSection::Issues => {
let current = self.popup_state.issue_list.selected().unwrap_or(0);
if current + 1 < self.issues.len() {
self.popup_state.issue_list.select(Some(current + 1));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
}
}
pub(super) fn cross_section_prev(&mut self) {
match self.popup_state.section {
PopupSection::BranchInput => {
if !self.issues.is_empty() {
self.popup_state.section = PopupSection::Issues;
self.popup_state
.issue_list
.select(Some(self.issues.len() - 1));
} else if !self.worktrees.is_empty() {
self.popup_state.section = PopupSection::Worktrees;
self.popup_state
.worktree_list
.select(Some(self.worktrees.len() - 1));
} else if !self.sessions.is_empty() {
self.popup_state.section = PopupSection::Sessions;
self.popup_state
.session_list
.select(Some(self.sessions.len() - 1));
}
}
PopupSection::Sessions => {
let current = self.popup_state.session_list.selected().unwrap_or(0);
if current > 0 {
self.popup_state.session_list.select(Some(current - 1));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
PopupSection::Worktrees => {
let current = self.popup_state.worktree_list.selected().unwrap_or(0);
if current > 0 {
self.popup_state.worktree_list.select(Some(current - 1));
} else if !self.sessions.is_empty() {
self.popup_state.section = PopupSection::Sessions;
self.popup_state
.session_list
.select(Some(self.sessions.len() - 1));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
PopupSection::Issues => {
let current = self.popup_state.issue_list.selected().unwrap_or(0);
if current > 0 {
self.popup_state.issue_list.select(Some(current - 1));
} else if !self.worktrees.is_empty() {
self.popup_state.section = PopupSection::Worktrees;
self.popup_state
.worktree_list
.select(Some(self.worktrees.len() - 1));
} else if !self.sessions.is_empty() {
self.popup_state.section = PopupSection::Sessions;
self.popup_state
.session_list
.select(Some(self.sessions.len() - 1));
} else {
self.popup_state.section = PopupSection::BranchInput;
}
}
}
}
}