c1205e5fb0
Split the monolithic app.rs (762 lines) into four DDD layers: - domain/: models, navigation enums (Tab, ModalKind), conversions, aggregates - application/: App state, LibraryState, NotificationManager, event handlers - infrastructure/: config, gRPC client, system utilities - presentation/: all render functions, widgets, views, modals Original modules (app, data, ui, config, grpc) preserved as thin re-export facades for backward compatibility. All 13 insta snapshot tests pass without modification.
318 lines
11 KiB
Rust
318 lines
11 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use crossterm::event::MouseButton;
|
|
use ratatui::widgets::ListState;
|
|
|
|
use crate::application::app_state::App;
|
|
use crate::data::{Artist, Track};
|
|
use crate::domain::conversions::{convert_artist, convert_track};
|
|
use crate::domain::navigation::Tab;
|
|
use crate::grpc::GrpcResponse;
|
|
use crate::ui::library::{LibraryFocus, LibraryState};
|
|
use crate::ui::notifications::NotifKind;
|
|
|
|
impl App {
|
|
pub fn handle_escape(&mut self) {
|
|
if self.notifications_open {
|
|
self.notifications_open = false;
|
|
return;
|
|
}
|
|
if self.modal.is_some() {
|
|
self.modal = None;
|
|
}
|
|
}
|
|
|
|
pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) {
|
|
if button != MouseButton::Left {
|
|
return;
|
|
}
|
|
|
|
if self.modal.is_some() {
|
|
self.handle_modal_click(x, y);
|
|
return;
|
|
}
|
|
|
|
if self.notifications_open {
|
|
let in_dropdown = x >= self.notifications_dropdown_area.x
|
|
&& x < self.notifications_dropdown_area.x + self.notifications_dropdown_area.width
|
|
&& y >= self.notifications_dropdown_area.y
|
|
&& y < self.notifications_dropdown_area.y + self.notifications_dropdown_area.height;
|
|
let in_btn = x >= self.notifications_btn_area.x
|
|
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
|
|
&& y == self.notifications_btn_area.y;
|
|
|
|
if in_btn {
|
|
self.notifications_open = false;
|
|
self.notifications_expanded = None;
|
|
} else if in_dropdown {
|
|
self.handle_notification_click(x, y);
|
|
} else {
|
|
self.notifications_open = false;
|
|
self.notifications_expanded = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if y == self.topbar_area.y {
|
|
self.handle_topbar_click(x);
|
|
return;
|
|
}
|
|
|
|
if y >= self.main_area.y && y < self.main_area.y + self.main_area.height {
|
|
self.handle_main_click(x, y);
|
|
}
|
|
}
|
|
|
|
fn handle_modal_click(&mut self, _x: u16, _y: u16) {
|
|
self.modal = None;
|
|
}
|
|
|
|
fn handle_topbar_click(&mut self, x: u16) {
|
|
if x >= self.notifications_btn_area.x
|
|
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
|
|
{
|
|
self.notifications_open = !self.notifications_open;
|
|
self.notifications_scroll = 0;
|
|
self.notifications_expanded = None;
|
|
return;
|
|
}
|
|
|
|
for (i, area) in self.tab_areas.iter().enumerate() {
|
|
if x >= area.x && x < area.x + area.width {
|
|
if let Some(tab) = Tab::ALL.get(i) {
|
|
self.tab = *tab;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_notification_click(&mut self, _x: u16, y: u16) {
|
|
if self.notifications_expanded.is_some() {
|
|
self.notifications_expanded = None;
|
|
return;
|
|
}
|
|
|
|
let inner_y = self.notifications_dropdown_area.y + 1;
|
|
let rel_y = y.saturating_sub(inner_y) as usize;
|
|
let item_height = 3usize;
|
|
let clicked_idx = rel_y / item_height;
|
|
|
|
let history = self.notifications.history();
|
|
let actual_idx = self.notifications_scroll + clicked_idx;
|
|
|
|
if let Some(notif) = history.iter().rev().nth(actual_idx) {
|
|
self.notifications_expanded = Some(notif.id);
|
|
}
|
|
}
|
|
|
|
fn handle_main_click(&mut self, x: u16, y: u16) {
|
|
let rel_y = y.saturating_sub(self.main_area.y) as usize;
|
|
|
|
match self.tab {
|
|
Tab::Library => {
|
|
self.handle_library_click(x, rel_y);
|
|
}
|
|
Tab::Wanted => {
|
|
self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y);
|
|
if rel_y < self.wanted.len() {
|
|
self.wanted_state.select(Some(rel_y));
|
|
}
|
|
}
|
|
Tab::Queue => {
|
|
if rel_y < self.queue.len() {
|
|
self.queue_state.select(Some(rel_y));
|
|
}
|
|
}
|
|
Tab::History => {
|
|
if rel_y < self.history.len() {
|
|
self.history_state.select(Some(rel_y));
|
|
}
|
|
}
|
|
Tab::Calendar | Tab::Settings => {}
|
|
}
|
|
}
|
|
|
|
fn handle_library_click(&mut self, x: u16, rel_y: usize) {
|
|
const ARTISTS_PANE_WIDTH: u16 = 32;
|
|
const BORDER_TOP: usize = 1;
|
|
const HEADER_HEIGHT: usize = 6;
|
|
const DIVIDER_HEIGHT: usize = 1;
|
|
const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT;
|
|
|
|
if x < ARTISTS_PANE_WIDTH {
|
|
if rel_y > 0 && rel_y <= self.library.artists.len() {
|
|
self.library.artist_state.select(Some(rel_y - 1));
|
|
self.library.album_state.select(Some(0));
|
|
self.library.track_state.select(Some(0));
|
|
self.library.focus = LibraryFocus::Artists;
|
|
}
|
|
} else if rel_y >= ALBUMS_START_ROW {
|
|
let album_row = rel_y - ALBUMS_START_ROW;
|
|
let content_height = self.main_area.height.saturating_sub(10) as usize;
|
|
let albums_section_height = (content_height * 40) / 100;
|
|
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
|
|
|
|
if rel_y < tracks_start_row {
|
|
if let Some(artist) = self.library.selected_artist()
|
|
&& album_row < artist.albums.len()
|
|
{
|
|
self.library.album_state.select(Some(album_row));
|
|
self.library.track_state.select(Some(0));
|
|
self.library.focus = LibraryFocus::Albums;
|
|
}
|
|
} else {
|
|
let track_row = rel_y - tracks_start_row;
|
|
if let Some(album) = self.library.selected_album()
|
|
&& track_row < album.total as usize
|
|
{
|
|
self.library.track_state.select(Some(track_row));
|
|
self.library.focus = LibraryFocus::Tracks;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
|
|
|
|
pub fn handle_scroll(&mut self, delta: i32) {
|
|
if self.modal.is_some() {
|
|
return;
|
|
}
|
|
|
|
if self.notifications_open {
|
|
let max_scroll = self.notifications.history_count().saturating_sub(8);
|
|
if delta > 0 {
|
|
self.notifications_scroll = (self.notifications_scroll + 1).min(max_scroll);
|
|
} else {
|
|
self.notifications_scroll = self.notifications_scroll.saturating_sub(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
match self.tab {
|
|
Tab::Library => {
|
|
self.scroll_library_list(delta);
|
|
}
|
|
Tab::Wanted => {
|
|
let len = self.wanted.len();
|
|
scroll_list_state(&mut self.wanted_state, len, delta);
|
|
}
|
|
Tab::Queue => {
|
|
let len = self.queue.len();
|
|
scroll_list_state(&mut self.queue_state, len, delta);
|
|
}
|
|
Tab::History => {
|
|
let len = self.history.len();
|
|
scroll_list_state(&mut self.history_state, len, delta);
|
|
}
|
|
Tab::Calendar | Tab::Settings => {}
|
|
}
|
|
}
|
|
|
|
fn scroll_library_list(&mut self, delta: i32) {
|
|
match self.library.focus {
|
|
LibraryFocus::Artists => {
|
|
let len = self.library.artists.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let current = self.library.artist_state.selected().unwrap_or(0);
|
|
let new_idx = if delta > 0 {
|
|
(current + 1).min(len - 1)
|
|
} else {
|
|
current.saturating_sub(1)
|
|
};
|
|
if new_idx != current {
|
|
self.library.artist_state.select(Some(new_idx));
|
|
self.library.album_state.select(Some(0));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
}
|
|
LibraryFocus::Albums => {
|
|
if let Some(artist) = self.library.selected_artist() {
|
|
let len = artist.albums.len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let current = self.library.album_state.selected().unwrap_or(0);
|
|
let new_idx = if delta > 0 {
|
|
(current + 1).min(len - 1)
|
|
} else {
|
|
current.saturating_sub(1)
|
|
};
|
|
if new_idx != current {
|
|
self.library.album_state.select(Some(new_idx));
|
|
self.library.track_state.select(Some(0));
|
|
}
|
|
}
|
|
}
|
|
LibraryFocus::Tracks => {
|
|
if let Some(album) = self.library.selected_album() {
|
|
let len = album.total as usize;
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let current = self.library.track_state.selected().unwrap_or(0);
|
|
let new_idx = if delta > 0 {
|
|
(current + 1).min(len - 1)
|
|
} else {
|
|
current.saturating_sub(1)
|
|
};
|
|
self.library.track_state.select(Some(new_idx));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn handle_tick(&mut self) {
|
|
self.notifications.tick();
|
|
}
|
|
|
|
pub fn set_error(&mut self, msg: String) {
|
|
self.notifications
|
|
.push("Error", Some(msg), NotifKind::Error, "✗");
|
|
}
|
|
|
|
pub fn handle_grpc_response(&mut self, response: GrpcResponse) {
|
|
match response {
|
|
GrpcResponse::Artists(artists) => {
|
|
let converted: Vec<Artist> = artists.into_iter().map(convert_artist).collect();
|
|
let count = converted.len();
|
|
self.library = LibraryState::new(converted);
|
|
self.notifications.push(
|
|
"Library loaded",
|
|
Some(format!("{} artists", count)),
|
|
NotifKind::Success,
|
|
"✓",
|
|
);
|
|
}
|
|
GrpcResponse::Album { album, tracks } => {
|
|
let converted: Vec<Track> = tracks.into_iter().map(convert_track).collect();
|
|
self.library.cache_tracks(album.id, converted);
|
|
}
|
|
GrpcResponse::Error(msg) => {
|
|
self.set_error(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn pending_album_fetch(&mut self) -> Option<String> {
|
|
self.library.needs_fetch()
|
|
}
|
|
}
|
|
|
|
fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) {
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
|
|
let current = state.selected().unwrap_or(0);
|
|
let new_idx = if delta > 0 {
|
|
(current + 1).min(len - 1)
|
|
} else {
|
|
current.saturating_sub(1)
|
|
};
|
|
state.select(Some(new_idx));
|
|
}
|