refactor: migrate to DDD layered architecture

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.
This commit is contained in:
Alexander
2026-05-09 12:25:10 +02:00
parent 5bee7092d3
commit c1205e5fb0
45 changed files with 3983 additions and 2248 deletions
+79
View File
@@ -0,0 +1,79 @@
#![allow(dead_code)]
use ratatui::layout::Rect;
use ratatui::widgets::ListState;
use crate::application::library_state::LibraryState;
use crate::application::notification_state::NotificationManager;
use crate::data::{Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry};
use crate::domain::navigation::{ModalKind, Tab};
pub struct App {
pub running: bool,
pub tab: Tab,
pub size: Rect,
pub library: LibraryState,
pub modal: Option<ModalKind>,
pub wanted: Vec<WantedEntry>,
pub wanted_state: ListState,
pub queue: Vec<QueueEntry>,
pub queue_state: ListState,
pub history: Vec<HistoryEntry>,
pub history_state: ListState,
pub calendar: Vec<CalendarEntry>,
pub notifications: NotificationManager,
pub notifications_open: bool,
pub notifications_scroll: usize,
pub notifications_expanded: Option<u64>,
pub(crate) topbar_area: Rect,
pub(crate) main_area: Rect,
pub(crate) statusbar_area: Rect,
pub(crate) tab_areas: Vec<Rect>,
pub(crate) notifications_btn_area: Rect,
pub(crate) notifications_dropdown_area: Rect,
}
impl Default for App {
fn default() -> Self {
let artists: Vec<Artist> = Vec::new();
let wanted: Vec<WantedEntry> = Vec::new();
let queue: Vec<QueueEntry> = Vec::new();
let history: Vec<HistoryEntry> = Vec::new();
let calendar: Vec<CalendarEntry> = Vec::new();
let wanted_state = ListState::default();
let queue_state = ListState::default();
let history_state = ListState::default();
Self {
running: true,
tab: Tab::Library,
size: Rect::default(),
library: LibraryState::new(artists),
modal: None,
wanted,
wanted_state,
queue,
queue_state,
history,
history_state,
calendar,
notifications: NotificationManager::new(),
notifications_open: false,
notifications_scroll: 0,
notifications_expanded: None,
topbar_area: Rect::default(),
main_area: Rect::default(),
statusbar_area: Rect::default(),
tab_areas: Vec::new(),
notifications_btn_area: Rect::default(),
notifications_dropdown_area: Rect::default(),
}
}
}
impl App {
pub fn new() -> Self {
Self::default()
}
}
+317
View File
@@ -0,0 +1,317 @@
#![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));
}
+250
View File
@@ -0,0 +1,250 @@
#![allow(dead_code)]
use std::collections::HashMap;
use ratatui::widgets::ListState;
use crate::data::{Album, Artist, Track};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LibraryFocus {
#[default]
Artists,
Albums,
Tracks,
}
pub struct LibraryState {
pub artists: Vec<Artist>,
pub tracks: Vec<Track>,
pub focus: LibraryFocus,
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
tracks_cache: HashMap<String, Vec<Track>>,
pending_album_id: Option<String>,
}
impl LibraryState {
pub fn new(artists: Vec<Artist>) -> Self {
let mut artist_state = ListState::default();
let mut album_state = ListState::default();
let mut track_state = ListState::default();
if !artists.is_empty() {
artist_state.select(Some(0));
if !artists[0].albums.is_empty() {
album_state.select(Some(0));
track_state.select(Some(0));
}
}
Self {
artists,
tracks: Vec::new(),
focus: LibraryFocus::Artists,
artist_state,
album_state,
track_state,
tracks_cache: HashMap::new(),
pending_album_id: None,
}
}
pub fn selected_artist(&self) -> Option<&Artist> {
self.artist_state
.selected()
.and_then(|i| self.artists.get(i))
}
pub fn selected_album(&self) -> Option<&Album> {
self.selected_artist()
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
}
pub fn move_up(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected()
&& i > 0
{
self.artist_state.select(Some(i - 1));
self.reset_album_selection();
}
}
LibraryFocus::Albums => {
if let Some(i) = self.album_state.selected()
&& i > 0
{
self.album_state.select(Some(i - 1));
self.reset_track_selection();
}
}
LibraryFocus::Tracks => {
if let Some(i) = self.track_state.selected()
&& i > 0
{
self.track_state.select(Some(i - 1));
}
}
}
}
pub fn move_down(&mut self) {
match self.focus {
LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected()
&& i < max
{
self.artist_state.select(Some(i + 1));
self.reset_album_selection();
}
}
LibraryFocus::Albums => {
let max = self
.selected_artist()
.map(|a| a.albums.len().saturating_sub(1))
.unwrap_or(0);
if let Some(i) = self.album_state.selected()
&& i < max
{
self.album_state.select(Some(i + 1));
self.reset_track_selection();
}
}
LibraryFocus::Tracks => {
let max = self.track_count().saturating_sub(1);
if let Some(i) = self.track_state.selected()
&& i < max
{
self.track_state.select(Some(i + 1));
}
}
}
}
fn track_count(&self) -> usize {
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
}
pub fn focus_left(&mut self) {
match self.focus {
LibraryFocus::Artists => {}
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
}
}
pub fn focus_right(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if self.selected_artist().is_some() {
self.focus = LibraryFocus::Albums;
}
}
LibraryFocus::Albums => {
if self.selected_album().is_some() {
self.focus = LibraryFocus::Tracks;
}
}
LibraryFocus::Tracks => {}
}
}
pub fn cycle_focus(&mut self) {
self.focus = match self.focus {
LibraryFocus::Artists => LibraryFocus::Albums,
LibraryFocus::Albums => LibraryFocus::Tracks,
LibraryFocus::Tracks => LibraryFocus::Artists,
};
}
fn reset_album_selection(&mut self) {
if let Some(artist) = self.selected_artist() {
if !artist.albums.is_empty() {
self.album_state.select(Some(0));
} else {
self.album_state.select(None);
}
}
self.reset_track_selection();
}
fn reset_track_selection(&mut self) {
if self.selected_album().is_some() {
self.track_state.select(Some(0));
} else {
self.track_state.select(None);
}
}
pub fn artist_count(&self) -> usize {
self.artists.len()
}
pub fn selected_artist_index(&self) -> Option<usize> {
self.artist_state.selected()
}
pub fn get_tracks(&self) -> &[Track] {
&self.tracks
}
pub fn cache_tracks(&mut self, album_id: String, tracks: Vec<Track>) {
self.tracks_cache.insert(album_id.clone(), tracks);
if self.pending_album_id.as_ref() == Some(&album_id) {
self.pending_album_id = None;
self.load_tracks_from_cache(&album_id);
}
}
pub fn load_tracks_from_cache(&mut self, album_id: &str) {
if let Some(tracks) = self.tracks_cache.get(album_id) {
self.tracks = tracks.clone();
self.track_state.select(if self.tracks.is_empty() {
None
} else {
Some(0)
});
}
}
pub fn get_cached_tracks(&self, album_id: &str) -> Option<&Vec<Track>> {
self.tracks_cache.get(album_id)
}
pub fn needs_fetch(&mut self) -> Option<String> {
let current_album_id = self.selected_album().map(|a| a.id.clone())?;
if current_album_id.is_empty() {
return None;
}
if self.tracks_cache.contains_key(&current_album_id) {
if self.pending_album_id.as_ref() != Some(&current_album_id) {
self.load_tracks_from_cache(&current_album_id);
}
return None;
}
if self.pending_album_id.as_ref() == Some(&current_album_id) {
return None;
}
self.pending_album_id = Some(current_album_id.clone());
self.tracks.clear();
Some(current_album_id)
}
pub fn selected_album_id(&self) -> Option<String> {
self.selected_album().map(|a| a.id.clone())
}
pub fn clear_cache(&mut self) {
self.tracks_cache.clear();
self.pending_album_id = None;
self.tracks.clear();
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod app_state;
pub mod handlers;
pub mod library_state;
pub mod notification_state;
+128
View File
@@ -0,0 +1,128 @@
#![allow(dead_code)]
use std::time::Instant;
use ratatui::style::Color;
use crate::theme;
pub const NOTIFICATION_TTL_SECS: u64 = 6;
pub const MAX_VISIBLE: usize = 5;
pub const MAX_HISTORY: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifKind {
Info,
Success,
Warn,
Error,
}
impl NotifKind {
pub fn color(self) -> Color {
match self {
NotifKind::Info => theme::BLUE,
NotifKind::Success => theme::GREEN,
NotifKind::Warn => theme::YELLOW,
NotifKind::Error => theme::RED,
}
}
pub fn label(self) -> &'static str {
match self {
NotifKind::Info => "Info",
NotifKind::Success => "Success",
NotifKind::Warn => "Warning",
NotifKind::Error => "Error",
}
}
}
#[derive(Clone)]
pub struct Notification {
pub id: u64,
pub title: String,
pub detail: Option<String>,
pub kind: NotifKind,
pub icon: String,
pub created_at: Instant,
}
pub struct NotificationManager {
active: Vec<Notification>,
history: Vec<Notification>,
next_id: u64,
}
impl Default for NotificationManager {
fn default() -> Self {
Self::new()
}
}
impl NotificationManager {
pub fn new() -> Self {
Self {
active: Vec::new(),
history: Vec::new(),
next_id: 1,
}
}
pub fn push(
&mut self,
title: impl Into<String>,
detail: Option<String>,
kind: NotifKind,
icon: impl Into<String>,
) {
let notification = Notification {
id: self.next_id,
title: title.into(),
detail,
kind,
icon: icon.into(),
created_at: Instant::now(),
};
self.next_id += 1;
self.history.push(notification.clone());
if self.history.len() > MAX_HISTORY {
self.history.remove(0);
}
self.active.push(notification);
}
pub fn tick(&mut self) {
let now = Instant::now();
self.active
.retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS);
}
pub fn history(&self) -> &[Notification] {
&self.history
}
pub fn active(&self) -> &[Notification] {
&self.active
}
pub fn active_count(&self) -> usize {
self.active.len()
}
pub fn history_count(&self) -> usize {
self.history.len()
}
}
pub fn format_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}