diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9d40b..b9cfc06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to this project will be documented in this file. ### Added - Double-stack words: `2dup`, `2drop`, `2swap`, `2over`. - `forget` word to remove user-defined words from the dictionary. +- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position. +- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement. +- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms. +- Fairyfloss theme (pastel candy colors by sailorhg). +- Hot Dog Stand theme (classic Windows 3.1 red/yellow). +- Hue rotation option in Options menu to shift all theme colors (0-360°). + +### Changed +- Title view now adapts to smaller terminal sizes gracefully. ### Fixed - Scope/spectrum ratio asymmetry in Left/Right layout modes. diff --git a/crates/ratatui/src/theme/fairyfloss.rs b/crates/ratatui/src/theme/fairyfloss.rs new file mode 100644 index 0000000..3201fa6 --- /dev/null +++ b/crates/ratatui/src/theme/fairyfloss.rs @@ -0,0 +1,277 @@ +use super::*; +use ratatui::style::Color; + +pub fn theme() -> ThemeColors { + let bg = Color::Rgb(90, 84, 117); + let bg_light = Color::Rgb(113, 103, 153); + let bg_lighter = Color::Rgb(130, 120, 165); + let fg = Color::Rgb(248, 248, 240); + let fg_dim = Color::Rgb(197, 163, 255); + let muted = Color::Rgb(168, 164, 177); + let dark = Color::Rgb(55, 51, 72); + + let purple = Color::Rgb(174, 129, 255); + let pink = Color::Rgb(255, 184, 209); + let coral = Color::Rgb(255, 133, 127); + let yellow = Color::Rgb(255, 243, 82); + let gold = Color::Rgb(230, 192, 0); + let mint = Color::Rgb(194, 255, 223); + let lavender = Color::Rgb(197, 163, 255); + + ThemeColors { + ui: UiColors { + bg, + bg_rgb: (90, 84, 117), + text_primary: fg, + text_muted: fg_dim, + text_dim: muted, + border: bg_lighter, + header: mint, + unfocused: muted, + accent: pink, + surface: bg_light, + }, + status: StatusColors { + playing_bg: Color::Rgb(70, 95, 85), + playing_fg: mint, + stopped_bg: Color::Rgb(100, 70, 85), + stopped_fg: coral, + fill_on: mint, + fill_off: muted, + fill_bg: bg_light, + }, + selection: SelectionColors { + cursor_bg: pink, + cursor_fg: dark, + selected_bg: Color::Rgb(120, 90, 130), + selected_fg: pink, + in_range_bg: Color::Rgb(100, 95, 125), + in_range_fg: fg, + cursor: pink, + selected: Color::Rgb(120, 90, 130), + in_range: Color::Rgb(100, 95, 125), + }, + tile: TileColors { + playing_active_bg: Color::Rgb(100, 85, 60), + playing_active_fg: gold, + playing_inactive_bg: Color::Rgb(95, 90, 70), + playing_inactive_fg: yellow, + active_bg: Color::Rgb(70, 100, 100), + active_fg: mint, + inactive_bg: bg_light, + inactive_fg: fg_dim, + active_selected_bg: Color::Rgb(120, 90, 130), + active_in_range_bg: Color::Rgb(100, 95, 125), + link_bright: [ + (255, 184, 209), + (174, 129, 255), + (255, 133, 127), + (194, 255, 223), + (255, 243, 82), + ], + link_dim: [ + (100, 75, 90), + (85, 70, 105), + (100, 65, 65), + (75, 100, 95), + (100, 95, 55), + ], + }, + header: HeaderColors { + tempo_bg: Color::Rgb(100, 75, 95), + tempo_fg: pink, + bank_bg: Color::Rgb(70, 95, 95), + bank_fg: mint, + pattern_bg: Color::Rgb(85, 75, 110), + pattern_fg: purple, + stats_bg: bg_light, + stats_fg: fg_dim, + }, + modal: ModalColors { + border: mint, + border_accent: pink, + border_warn: coral, + border_dim: muted, + confirm: coral, + rename: purple, + input: mint, + editor: mint, + preview: muted, + }, + flash: FlashColors { + error_bg: Color::Rgb(100, 65, 70), + error_fg: coral, + success_bg: Color::Rgb(65, 95, 85), + success_fg: mint, + info_bg: bg_light, + info_fg: fg, + event_rgb: (100, 85, 110), + }, + list: ListColors { + playing_bg: Color::Rgb(65, 95, 85), + playing_fg: mint, + staged_play_bg: Color::Rgb(95, 80, 120), + staged_play_fg: purple, + staged_stop_bg: Color::Rgb(105, 70, 85), + staged_stop_fg: pink, + edit_bg: Color::Rgb(70, 95, 100), + edit_fg: mint, + hover_bg: bg_lighter, + hover_fg: fg, + }, + link_status: LinkStatusColors { + disabled: coral, + connected: mint, + listening: yellow, + }, + syntax: SyntaxColors { + gap_bg: dark, + executed_bg: Color::Rgb(80, 75, 100), + selected_bg: Color::Rgb(110, 100, 70), + emit: (fg, Color::Rgb(110, 80, 100)), + number: (purple, Color::Rgb(85, 75, 110)), + string: (yellow, Color::Rgb(100, 95, 60)), + comment: (muted, dark), + keyword: (pink, Color::Rgb(105, 75, 90)), + stack_op: (mint, Color::Rgb(70, 100, 95)), + operator: (pink, Color::Rgb(105, 75, 90)), + sound: (mint, Color::Rgb(70, 100, 95)), + param: (coral, Color::Rgb(105, 70, 70)), + context: (coral, Color::Rgb(105, 70, 70)), + note: (lavender, Color::Rgb(85, 75, 110)), + interval: (Color::Rgb(220, 190, 255), Color::Rgb(85, 75, 100)), + variable: (lavender, Color::Rgb(85, 75, 110)), + vary: (yellow, Color::Rgb(100, 95, 60)), + generator: (mint, Color::Rgb(70, 95, 95)), + default: (fg_dim, dark), + }, + table: TableColors { + row_even: dark, + row_odd: bg, + }, + values: ValuesColors { + tempo: coral, + value: fg_dim, + }, + hint: HintColors { + key: coral, + text: muted, + }, + view_badge: ViewBadgeColors { bg: fg, fg: bg }, + nav: NavColors { + selected_bg: Color::Rgb(110, 85, 120), + selected_fg: fg, + unselected_bg: bg_light, + unselected_fg: muted, + }, + editor_widget: EditorWidgetColors { + cursor_bg: fg, + cursor_fg: bg, + selection_bg: Color::Rgb(105, 95, 125), + completion_bg: bg_light, + completion_fg: fg, + completion_selected: coral, + completion_example: mint, + }, + browser: BrowserColors { + directory: mint, + project_file: purple, + selected: coral, + file: fg, + focused_border: coral, + unfocused_border: muted, + root: fg, + file_icon: muted, + folder_icon: mint, + empty_text: muted, + }, + input: InputColors { + text: mint, + cursor: fg, + hint: muted, + }, + search: SearchColors { + active: coral, + inactive: muted, + match_bg: yellow, + match_fg: dark, + }, + markdown: MarkdownColors { + h1: mint, + h2: coral, + h3: purple, + code: lavender, + code_border: Color::Rgb(120, 115, 140), + link: pink, + link_url: Color::Rgb(150, 145, 165), + quote: muted, + text: fg, + list: fg, + }, + engine: EngineColors { + header: mint, + header_focused: yellow, + divider: Color::Rgb(110, 105, 130), + scroll_indicator: Color::Rgb(125, 120, 145), + label: Color::Rgb(175, 170, 190), + label_focused: Color::Rgb(210, 205, 225), + label_dim: Color::Rgb(145, 140, 160), + value: Color::Rgb(230, 225, 240), + focused: yellow, + normal: fg, + dim: Color::Rgb(125, 120, 145), + path: Color::Rgb(175, 170, 190), + border_magenta: pink, + border_green: mint, + border_cyan: lavender, + separator: Color::Rgb(110, 105, 130), + hint_active: Color::Rgb(240, 230, 120), + hint_inactive: Color::Rgb(110, 105, 130), + }, + dict: DictColors { + word_name: lavender, + word_bg: Color::Rgb(75, 85, 105), + alias: muted, + stack_sig: purple, + description: fg, + example: Color::Rgb(175, 170, 190), + category_focused: yellow, + category_selected: mint, + category_normal: fg, + category_dimmed: Color::Rgb(125, 120, 145), + border_focused: yellow, + border_normal: Color::Rgb(110, 105, 130), + header_desc: Color::Rgb(195, 190, 210), + }, + title: TitleColors { + big_title: pink, + author: mint, + link: lavender, + license: coral, + prompt: Color::Rgb(195, 190, 210), + subtitle: fg, + }, + meter: MeterColors { + low: mint, + mid: yellow, + high: coral, + low_rgb: (194, 255, 223), + mid_rgb: (255, 243, 82), + high_rgb: (255, 133, 127), + }, + sparkle: SparkleColors { + colors: [ + (194, 255, 223), + (255, 133, 127), + (255, 243, 82), + (255, 184, 209), + (174, 129, 255), + ], + }, + confirm: ConfirmColors { + border: coral, + button_selected_bg: coral, + button_selected_fg: dark, + }, + } +} diff --git a/crates/ratatui/src/theme/hot_dog_stand.rs b/crates/ratatui/src/theme/hot_dog_stand.rs new file mode 100644 index 0000000..d4a339f --- /dev/null +++ b/crates/ratatui/src/theme/hot_dog_stand.rs @@ -0,0 +1,273 @@ +use super::*; +use ratatui::style::Color; + +pub fn theme() -> ThemeColors { + let red = Color::Rgb(255, 0, 0); + let dark_red = Color::Rgb(215, 0, 0); + let darker_red = Color::Rgb(175, 0, 0); + let yellow = Color::Rgb(255, 255, 0); + let light_yellow = Color::Rgb(255, 255, 95); + let gold = Color::Rgb(255, 215, 0); + let black = Color::Rgb(0, 0, 0); + let white = Color::Rgb(255, 255, 255); + + let dim_yellow = Color::Rgb(180, 180, 0); + let muted_red = Color::Rgb(140, 40, 40); + + ThemeColors { + ui: UiColors { + bg: red, + bg_rgb: (255, 0, 0), + text_primary: yellow, + text_muted: light_yellow, + text_dim: gold, + border: yellow, + header: yellow, + unfocused: gold, + accent: yellow, + surface: dark_red, + }, + status: StatusColors { + playing_bg: Color::Rgb(180, 180, 0), + playing_fg: black, + stopped_bg: darker_red, + stopped_fg: yellow, + fill_on: yellow, + fill_off: gold, + fill_bg: dark_red, + }, + selection: SelectionColors { + cursor_bg: yellow, + cursor_fg: red, + selected_bg: Color::Rgb(200, 200, 0), + selected_fg: black, + in_range_bg: Color::Rgb(170, 100, 0), + in_range_fg: yellow, + cursor: yellow, + selected: Color::Rgb(200, 200, 0), + in_range: Color::Rgb(170, 100, 0), + }, + tile: TileColors { + playing_active_bg: Color::Rgb(200, 200, 0), + playing_active_fg: black, + playing_inactive_bg: Color::Rgb(180, 180, 0), + playing_inactive_fg: black, + active_bg: Color::Rgb(200, 50, 50), + active_fg: yellow, + inactive_bg: dark_red, + inactive_fg: gold, + active_selected_bg: Color::Rgb(200, 200, 0), + active_in_range_bg: Color::Rgb(170, 100, 0), + link_bright: [ + (255, 255, 0), + (255, 255, 255), + (255, 215, 0), + (255, 255, 95), + (255, 255, 0), + ], + link_dim: [ + (140, 140, 0), + (140, 140, 140), + (140, 120, 0), + (140, 140, 60), + (140, 140, 0), + ], + }, + header: HeaderColors { + tempo_bg: Color::Rgb(180, 180, 0), + tempo_fg: black, + bank_bg: darker_red, + bank_fg: yellow, + pattern_bg: Color::Rgb(200, 200, 0), + pattern_fg: black, + stats_bg: dark_red, + stats_fg: yellow, + }, + modal: ModalColors { + border: yellow, + border_accent: white, + border_warn: gold, + border_dim: dim_yellow, + confirm: gold, + rename: light_yellow, + input: yellow, + editor: yellow, + preview: gold, + }, + flash: FlashColors { + error_bg: black, + error_fg: yellow, + success_bg: Color::Rgb(180, 180, 0), + success_fg: black, + info_bg: dark_red, + info_fg: yellow, + event_rgb: (170, 100, 0), + }, + list: ListColors { + playing_bg: Color::Rgb(180, 180, 0), + playing_fg: black, + staged_play_bg: Color::Rgb(200, 200, 0), + staged_play_fg: black, + staged_stop_bg: darker_red, + staged_stop_fg: yellow, + edit_bg: Color::Rgb(200, 50, 50), + edit_fg: yellow, + hover_bg: Color::Rgb(230, 50, 50), + hover_fg: yellow, + }, + link_status: LinkStatusColors { + disabled: white, + connected: yellow, + listening: gold, + }, + syntax: SyntaxColors { + gap_bg: darker_red, + executed_bg: Color::Rgb(200, 50, 50), + selected_bg: Color::Rgb(180, 180, 0), + emit: (yellow, muted_red), + number: (white, muted_red), + string: (gold, muted_red), + comment: (dim_yellow, darker_red), + keyword: (light_yellow, muted_red), + stack_op: (yellow, muted_red), + operator: (light_yellow, muted_red), + sound: (yellow, muted_red), + param: (gold, muted_red), + context: (gold, muted_red), + note: (white, muted_red), + interval: (Color::Rgb(255, 240, 150), muted_red), + variable: (white, muted_red), + vary: (gold, muted_red), + generator: (yellow, muted_red), + default: (light_yellow, darker_red), + }, + table: TableColors { + row_even: darker_red, + row_odd: red, + }, + values: ValuesColors { + tempo: gold, + value: light_yellow, + }, + hint: HintColors { + key: white, + text: gold, + }, + view_badge: ViewBadgeColors { bg: yellow, fg: red }, + nav: NavColors { + selected_bg: Color::Rgb(200, 200, 0), + selected_fg: black, + unselected_bg: dark_red, + unselected_fg: gold, + }, + editor_widget: EditorWidgetColors { + cursor_bg: yellow, + cursor_fg: red, + selection_bg: Color::Rgb(180, 180, 0), + completion_bg: dark_red, + completion_fg: yellow, + completion_selected: white, + completion_example: gold, + }, + browser: BrowserColors { + directory: yellow, + project_file: white, + selected: gold, + file: light_yellow, + focused_border: white, + unfocused_border: gold, + root: yellow, + file_icon: gold, + folder_icon: yellow, + empty_text: gold, + }, + input: InputColors { + text: yellow, + cursor: white, + hint: gold, + }, + search: SearchColors { + active: white, + inactive: gold, + match_bg: yellow, + match_fg: red, + }, + markdown: MarkdownColors { + h1: yellow, + h2: white, + h3: gold, + code: light_yellow, + code_border: dim_yellow, + link: white, + link_url: gold, + quote: dim_yellow, + text: yellow, + list: yellow, + }, + engine: EngineColors { + header: yellow, + header_focused: white, + divider: dim_yellow, + scroll_indicator: gold, + label: light_yellow, + label_focused: white, + label_dim: dim_yellow, + value: yellow, + focused: white, + normal: yellow, + dim: dim_yellow, + path: gold, + border_magenta: gold, + border_green: yellow, + border_cyan: white, + separator: dim_yellow, + hint_active: white, + hint_inactive: dim_yellow, + }, + dict: DictColors { + word_name: yellow, + word_bg: darker_red, + alias: gold, + stack_sig: white, + description: yellow, + example: gold, + category_focused: white, + category_selected: yellow, + category_normal: light_yellow, + category_dimmed: dim_yellow, + border_focused: white, + border_normal: dim_yellow, + header_desc: gold, + }, + title: TitleColors { + big_title: yellow, + author: white, + link: gold, + license: light_yellow, + prompt: gold, + subtitle: yellow, + }, + meter: MeterColors { + low: yellow, + mid: gold, + high: white, + low_rgb: (255, 255, 0), + mid_rgb: (255, 215, 0), + high_rgb: (255, 255, 255), + }, + sparkle: SparkleColors { + colors: [ + (255, 255, 0), + (255, 255, 255), + (255, 215, 0), + (255, 255, 95), + (255, 255, 0), + ], + }, + confirm: ConfirmColors { + border: white, + button_selected_bg: yellow, + button_selected_fg: red, + }, + } +} diff --git a/crates/ratatui/src/theme/kanagawa.rs b/crates/ratatui/src/theme/kanagawa.rs index b7512ef..febbfbe 100644 --- a/crates/ratatui/src/theme/kanagawa.rs +++ b/crates/ratatui/src/theme/kanagawa.rs @@ -14,8 +14,8 @@ pub fn theme() -> ThemeColors { let autumn_red = Color::Rgb(195, 64, 67); let carp_yellow = Color::Rgb(230, 195, 132); let spring_blue = Color::Rgb(127, 180, 202); - let wave_red = Color::Rgb(226, 109, 115); - let sakura_pink = Color::Rgb(212, 140, 149); + let wave_red = Color::Rgb(228, 104, 118); + let sakura_pink = Color::Rgb(210, 126, 153); let darker_bg = Color::Rgb(26, 26, 34); @@ -64,7 +64,7 @@ pub fn theme() -> ThemeColors { active_selected_bg: Color::Rgb(65, 55, 70), active_in_range_bg: Color::Rgb(50, 50, 60), link_bright: [ - (226, 109, 115), + (228, 104, 118), (149, 127, 184), (230, 195, 132), (127, 180, 202), @@ -258,14 +258,14 @@ pub fn theme() -> ThemeColors { high: wave_red, low_rgb: (118, 148, 106), mid_rgb: (230, 195, 132), - high_rgb: (226, 109, 115), + high_rgb: (228, 104, 118), }, sparkle: SparkleColors { colors: [ (127, 180, 202), (230, 195, 132), (118, 148, 106), - (226, 109, 115), + (228, 104, 118), (149, 127, 184), ], }, diff --git a/crates/ratatui/src/theme/mod.rs b/crates/ratatui/src/theme/mod.rs index 9e5bc25..5b58e73 100644 --- a/crates/ratatui/src/theme/mod.rs +++ b/crates/ratatui/src/theme/mod.rs @@ -4,7 +4,9 @@ mod catppuccin_latte; mod catppuccin_mocha; mod dracula; +mod fairyfloss; mod gruvbox_dark; +mod hot_dog_stand; mod kanagawa; mod monochrome_black; mod monochrome_white; @@ -13,6 +15,7 @@ mod nord; mod pitch_black; mod rose_pine; mod tokyo_night; +pub mod transform; use ratatui::style::Color; use std::cell::RefCell; @@ -36,6 +39,8 @@ pub const THEMES: &[ThemeEntry] = &[ ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme }, ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme }, ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme }, + ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", colors: fairyfloss::theme }, + ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", colors: hot_dog_stand::theme }, ]; thread_local! { diff --git a/crates/ratatui/src/theme/rose_pine.rs b/crates/ratatui/src/theme/rose_pine.rs index 52b9e62..81877bd 100644 --- a/crates/ratatui/src/theme/rose_pine.rs +++ b/crates/ratatui/src/theme/rose_pine.rs @@ -8,7 +8,7 @@ pub fn theme() -> ThemeColors { let fg = Color::Rgb(224, 222, 244); let fg_dim = Color::Rgb(144, 140, 170); let muted = Color::Rgb(110, 106, 134); - let rose = Color::Rgb(235, 111, 146); + let rose = Color::Rgb(235, 188, 186); let gold = Color::Rgb(246, 193, 119); let foam = Color::Rgb(156, 207, 216); let iris = Color::Rgb(196, 167, 231); diff --git a/crates/ratatui/src/theme/transform.rs b/crates/ratatui/src/theme/transform.rs new file mode 100644 index 0000000..1bdba8e --- /dev/null +++ b/crates/ratatui/src/theme/transform.rs @@ -0,0 +1,345 @@ +use ratatui::style::Color; +use super::*; + +fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + + let h = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta) % 6.0) + } else if max == g { + 60.0 * (((b - r) / delta) + 2.0) + } else { + 60.0 * (((r - g) / delta) + 4.0) + }; + + let h = if h < 0.0 { h + 360.0 } else { h }; + let s = if max == 0.0 { 0.0 } else { delta / max }; + let v = max; + + (h, s, v) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + + let (r, g, b) = if h < 60.0 { + (c, x, 0.0) + } else if h < 120.0 { + (x, c, 0.0) + } else if h < 180.0 { + (0.0, c, x) + } else if h < 240.0 { + (0.0, x, c) + } else if h < 300.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + ( + ((r + m) * 255.0) as u8, + ((g + m) * 255.0) as u8, + ((b + m) * 255.0) as u8, + ) +} + +fn rotate_hue_rgb(r: u8, g: u8, b: u8, degrees: f32) -> (u8, u8, u8) { + let (h, s, v) = rgb_to_hsv(r, g, b); + let new_h = (h + degrees) % 360.0; + let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h }; + hsv_to_rgb(new_h, s, v) +} + +fn rotate_color(color: Color, degrees: f32) -> Color { + match color { + Color::Rgb(r, g, b) => { + let (nr, ng, nb) = rotate_hue_rgb(r, g, b, degrees); + Color::Rgb(nr, ng, nb) + } + _ => color, + } +} + +fn rotate_tuple(tuple: (u8, u8, u8), degrees: f32) -> (u8, u8, u8) { + rotate_hue_rgb(tuple.0, tuple.1, tuple.2, degrees) +} + +fn rotate_color_pair(pair: (Color, Color), degrees: f32) -> (Color, Color) { + (rotate_color(pair.0, degrees), rotate_color(pair.1, degrees)) +} + +pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors { + if degrees == 0.0 { + return theme; + } + + ThemeColors { + ui: UiColors { + bg: rotate_color(theme.ui.bg, degrees), + bg_rgb: rotate_tuple(theme.ui.bg_rgb, degrees), + text_primary: rotate_color(theme.ui.text_primary, degrees), + text_muted: rotate_color(theme.ui.text_muted, degrees), + text_dim: rotate_color(theme.ui.text_dim, degrees), + border: rotate_color(theme.ui.border, degrees), + header: rotate_color(theme.ui.header, degrees), + unfocused: rotate_color(theme.ui.unfocused, degrees), + accent: rotate_color(theme.ui.accent, degrees), + surface: rotate_color(theme.ui.surface, degrees), + }, + status: StatusColors { + playing_bg: rotate_color(theme.status.playing_bg, degrees), + playing_fg: rotate_color(theme.status.playing_fg, degrees), + stopped_bg: rotate_color(theme.status.stopped_bg, degrees), + stopped_fg: rotate_color(theme.status.stopped_fg, degrees), + fill_on: rotate_color(theme.status.fill_on, degrees), + fill_off: rotate_color(theme.status.fill_off, degrees), + fill_bg: rotate_color(theme.status.fill_bg, degrees), + }, + selection: SelectionColors { + cursor_bg: rotate_color(theme.selection.cursor_bg, degrees), + cursor_fg: rotate_color(theme.selection.cursor_fg, degrees), + selected_bg: rotate_color(theme.selection.selected_bg, degrees), + selected_fg: rotate_color(theme.selection.selected_fg, degrees), + in_range_bg: rotate_color(theme.selection.in_range_bg, degrees), + in_range_fg: rotate_color(theme.selection.in_range_fg, degrees), + cursor: rotate_color(theme.selection.cursor, degrees), + selected: rotate_color(theme.selection.selected, degrees), + in_range: rotate_color(theme.selection.in_range, degrees), + }, + tile: TileColors { + playing_active_bg: rotate_color(theme.tile.playing_active_bg, degrees), + playing_active_fg: rotate_color(theme.tile.playing_active_fg, degrees), + playing_inactive_bg: rotate_color(theme.tile.playing_inactive_bg, degrees), + playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees), + active_bg: rotate_color(theme.tile.active_bg, degrees), + active_fg: rotate_color(theme.tile.active_fg, degrees), + inactive_bg: rotate_color(theme.tile.inactive_bg, degrees), + inactive_fg: rotate_color(theme.tile.inactive_fg, degrees), + active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees), + active_in_range_bg: rotate_color(theme.tile.active_in_range_bg, degrees), + link_bright: [ + rotate_tuple(theme.tile.link_bright[0], degrees), + rotate_tuple(theme.tile.link_bright[1], degrees), + rotate_tuple(theme.tile.link_bright[2], degrees), + rotate_tuple(theme.tile.link_bright[3], degrees), + rotate_tuple(theme.tile.link_bright[4], degrees), + ], + link_dim: [ + rotate_tuple(theme.tile.link_dim[0], degrees), + rotate_tuple(theme.tile.link_dim[1], degrees), + rotate_tuple(theme.tile.link_dim[2], degrees), + rotate_tuple(theme.tile.link_dim[3], degrees), + rotate_tuple(theme.tile.link_dim[4], degrees), + ], + }, + header: HeaderColors { + tempo_bg: rotate_color(theme.header.tempo_bg, degrees), + tempo_fg: rotate_color(theme.header.tempo_fg, degrees), + bank_bg: rotate_color(theme.header.bank_bg, degrees), + bank_fg: rotate_color(theme.header.bank_fg, degrees), + pattern_bg: rotate_color(theme.header.pattern_bg, degrees), + pattern_fg: rotate_color(theme.header.pattern_fg, degrees), + stats_bg: rotate_color(theme.header.stats_bg, degrees), + stats_fg: rotate_color(theme.header.stats_fg, degrees), + }, + modal: ModalColors { + border: rotate_color(theme.modal.border, degrees), + border_accent: rotate_color(theme.modal.border_accent, degrees), + border_warn: rotate_color(theme.modal.border_warn, degrees), + border_dim: rotate_color(theme.modal.border_dim, degrees), + confirm: rotate_color(theme.modal.confirm, degrees), + rename: rotate_color(theme.modal.rename, degrees), + input: rotate_color(theme.modal.input, degrees), + editor: rotate_color(theme.modal.editor, degrees), + preview: rotate_color(theme.modal.preview, degrees), + }, + flash: FlashColors { + error_bg: rotate_color(theme.flash.error_bg, degrees), + error_fg: rotate_color(theme.flash.error_fg, degrees), + success_bg: rotate_color(theme.flash.success_bg, degrees), + success_fg: rotate_color(theme.flash.success_fg, degrees), + info_bg: rotate_color(theme.flash.info_bg, degrees), + info_fg: rotate_color(theme.flash.info_fg, degrees), + event_rgb: rotate_tuple(theme.flash.event_rgb, degrees), + }, + list: ListColors { + playing_bg: rotate_color(theme.list.playing_bg, degrees), + playing_fg: rotate_color(theme.list.playing_fg, degrees), + staged_play_bg: rotate_color(theme.list.staged_play_bg, degrees), + staged_play_fg: rotate_color(theme.list.staged_play_fg, degrees), + staged_stop_bg: rotate_color(theme.list.staged_stop_bg, degrees), + staged_stop_fg: rotate_color(theme.list.staged_stop_fg, degrees), + edit_bg: rotate_color(theme.list.edit_bg, degrees), + edit_fg: rotate_color(theme.list.edit_fg, degrees), + hover_bg: rotate_color(theme.list.hover_bg, degrees), + hover_fg: rotate_color(theme.list.hover_fg, degrees), + }, + link_status: LinkStatusColors { + disabled: rotate_color(theme.link_status.disabled, degrees), + connected: rotate_color(theme.link_status.connected, degrees), + listening: rotate_color(theme.link_status.listening, degrees), + }, + syntax: SyntaxColors { + gap_bg: rotate_color(theme.syntax.gap_bg, degrees), + executed_bg: rotate_color(theme.syntax.executed_bg, degrees), + selected_bg: rotate_color(theme.syntax.selected_bg, degrees), + emit: rotate_color_pair(theme.syntax.emit, degrees), + number: rotate_color_pair(theme.syntax.number, degrees), + string: rotate_color_pair(theme.syntax.string, degrees), + comment: rotate_color_pair(theme.syntax.comment, degrees), + keyword: rotate_color_pair(theme.syntax.keyword, degrees), + stack_op: rotate_color_pair(theme.syntax.stack_op, degrees), + operator: rotate_color_pair(theme.syntax.operator, degrees), + sound: rotate_color_pair(theme.syntax.sound, degrees), + param: rotate_color_pair(theme.syntax.param, degrees), + context: rotate_color_pair(theme.syntax.context, degrees), + note: rotate_color_pair(theme.syntax.note, degrees), + interval: rotate_color_pair(theme.syntax.interval, degrees), + variable: rotate_color_pair(theme.syntax.variable, degrees), + vary: rotate_color_pair(theme.syntax.vary, degrees), + generator: rotate_color_pair(theme.syntax.generator, degrees), + default: rotate_color_pair(theme.syntax.default, degrees), + }, + table: TableColors { + row_even: rotate_color(theme.table.row_even, degrees), + row_odd: rotate_color(theme.table.row_odd, degrees), + }, + values: ValuesColors { + tempo: rotate_color(theme.values.tempo, degrees), + value: rotate_color(theme.values.value, degrees), + }, + hint: HintColors { + key: rotate_color(theme.hint.key, degrees), + text: rotate_color(theme.hint.text, degrees), + }, + view_badge: ViewBadgeColors { + bg: rotate_color(theme.view_badge.bg, degrees), + fg: rotate_color(theme.view_badge.fg, degrees), + }, + nav: NavColors { + selected_bg: rotate_color(theme.nav.selected_bg, degrees), + selected_fg: rotate_color(theme.nav.selected_fg, degrees), + unselected_bg: rotate_color(theme.nav.unselected_bg, degrees), + unselected_fg: rotate_color(theme.nav.unselected_fg, degrees), + }, + editor_widget: EditorWidgetColors { + cursor_bg: rotate_color(theme.editor_widget.cursor_bg, degrees), + cursor_fg: rotate_color(theme.editor_widget.cursor_fg, degrees), + selection_bg: rotate_color(theme.editor_widget.selection_bg, degrees), + completion_bg: rotate_color(theme.editor_widget.completion_bg, degrees), + completion_fg: rotate_color(theme.editor_widget.completion_fg, degrees), + completion_selected: rotate_color(theme.editor_widget.completion_selected, degrees), + completion_example: rotate_color(theme.editor_widget.completion_example, degrees), + }, + browser: BrowserColors { + directory: rotate_color(theme.browser.directory, degrees), + project_file: rotate_color(theme.browser.project_file, degrees), + selected: rotate_color(theme.browser.selected, degrees), + file: rotate_color(theme.browser.file, degrees), + focused_border: rotate_color(theme.browser.focused_border, degrees), + unfocused_border: rotate_color(theme.browser.unfocused_border, degrees), + root: rotate_color(theme.browser.root, degrees), + file_icon: rotate_color(theme.browser.file_icon, degrees), + folder_icon: rotate_color(theme.browser.folder_icon, degrees), + empty_text: rotate_color(theme.browser.empty_text, degrees), + }, + input: InputColors { + text: rotate_color(theme.input.text, degrees), + cursor: rotate_color(theme.input.cursor, degrees), + hint: rotate_color(theme.input.hint, degrees), + }, + search: SearchColors { + active: rotate_color(theme.search.active, degrees), + inactive: rotate_color(theme.search.inactive, degrees), + match_bg: rotate_color(theme.search.match_bg, degrees), + match_fg: rotate_color(theme.search.match_fg, degrees), + }, + markdown: MarkdownColors { + h1: rotate_color(theme.markdown.h1, degrees), + h2: rotate_color(theme.markdown.h2, degrees), + h3: rotate_color(theme.markdown.h3, degrees), + code: rotate_color(theme.markdown.code, degrees), + code_border: rotate_color(theme.markdown.code_border, degrees), + link: rotate_color(theme.markdown.link, degrees), + link_url: rotate_color(theme.markdown.link_url, degrees), + quote: rotate_color(theme.markdown.quote, degrees), + text: rotate_color(theme.markdown.text, degrees), + list: rotate_color(theme.markdown.list, degrees), + }, + engine: EngineColors { + header: rotate_color(theme.engine.header, degrees), + header_focused: rotate_color(theme.engine.header_focused, degrees), + divider: rotate_color(theme.engine.divider, degrees), + scroll_indicator: rotate_color(theme.engine.scroll_indicator, degrees), + label: rotate_color(theme.engine.label, degrees), + label_focused: rotate_color(theme.engine.label_focused, degrees), + label_dim: rotate_color(theme.engine.label_dim, degrees), + value: rotate_color(theme.engine.value, degrees), + focused: rotate_color(theme.engine.focused, degrees), + normal: rotate_color(theme.engine.normal, degrees), + dim: rotate_color(theme.engine.dim, degrees), + path: rotate_color(theme.engine.path, degrees), + border_magenta: rotate_color(theme.engine.border_magenta, degrees), + border_green: rotate_color(theme.engine.border_green, degrees), + border_cyan: rotate_color(theme.engine.border_cyan, degrees), + separator: rotate_color(theme.engine.separator, degrees), + hint_active: rotate_color(theme.engine.hint_active, degrees), + hint_inactive: rotate_color(theme.engine.hint_inactive, degrees), + }, + dict: DictColors { + word_name: rotate_color(theme.dict.word_name, degrees), + word_bg: rotate_color(theme.dict.word_bg, degrees), + alias: rotate_color(theme.dict.alias, degrees), + stack_sig: rotate_color(theme.dict.stack_sig, degrees), + description: rotate_color(theme.dict.description, degrees), + example: rotate_color(theme.dict.example, degrees), + category_focused: rotate_color(theme.dict.category_focused, degrees), + category_selected: rotate_color(theme.dict.category_selected, degrees), + category_normal: rotate_color(theme.dict.category_normal, degrees), + category_dimmed: rotate_color(theme.dict.category_dimmed, degrees), + border_focused: rotate_color(theme.dict.border_focused, degrees), + border_normal: rotate_color(theme.dict.border_normal, degrees), + header_desc: rotate_color(theme.dict.header_desc, degrees), + }, + title: TitleColors { + big_title: rotate_color(theme.title.big_title, degrees), + author: rotate_color(theme.title.author, degrees), + link: rotate_color(theme.title.link, degrees), + license: rotate_color(theme.title.license, degrees), + prompt: rotate_color(theme.title.prompt, degrees), + subtitle: rotate_color(theme.title.subtitle, degrees), + }, + meter: MeterColors { + low: rotate_color(theme.meter.low, degrees), + mid: rotate_color(theme.meter.mid, degrees), + high: rotate_color(theme.meter.high, degrees), + low_rgb: rotate_tuple(theme.meter.low_rgb, degrees), + mid_rgb: rotate_tuple(theme.meter.mid_rgb, degrees), + high_rgb: rotate_tuple(theme.meter.high_rgb, degrees), + }, + sparkle: SparkleColors { + colors: [ + rotate_tuple(theme.sparkle.colors[0], degrees), + rotate_tuple(theme.sparkle.colors[1], degrees), + rotate_tuple(theme.sparkle.colors[2], degrees), + rotate_tuple(theme.sparkle.colors[3], degrees), + rotate_tuple(theme.sparkle.colors[4], degrees), + ], + }, + confirm: ConfirmColors { + border: rotate_color(theme.confirm.border, degrees), + button_selected_bg: rotate_color(theme.confirm.button_selected_bg, degrees), + button_selected_fg: rotate_color(theme.confirm.button_selected_fg, degrees), + }, + } +} diff --git a/src/app.rs b/src/app.rs index 683de7a..413a063 100644 --- a/src/app.rs +++ b/src/app.rs @@ -111,6 +111,7 @@ impl App { flash_brightness: self.ui.flash_brightness, color_scheme: self.ui.color_scheme, layout: self.audio.config.layout, + hue_rotation: self.ui.hue_rotation, ..Default::default() }, link: crate::settings::LinkSettings { @@ -1311,7 +1312,15 @@ impl App { } AppCommand::SetColorScheme(scheme) => { self.ui.color_scheme = scheme; - crate::theme::set(scheme.to_theme()); + let base_theme = scheme.to_theme(); + let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation); + crate::theme::set(rotated); + } + AppCommand::SetHueRotation(degrees) => { + self.ui.hue_rotation = degrees; + let base_theme = self.ui.color_scheme.to_theme(); + let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees); + crate::theme::set(rotated); } AppCommand::ToggleRuntimeHighlight => { self.ui.runtime_highlight = !self.ui.runtime_highlight; @@ -1429,6 +1438,65 @@ impl App { AppCommand::ResetPeakVoices => { self.metrics.peak_voices = 0; } + + // Euclidean distribution + AppCommand::ApplyEuclideanDistribution { + bank, + pattern, + source_step, + pulses, + steps, + rotation, + } => { + let pat_len = self.project_state.project.pattern_at(bank, pattern).length; + let rhythm = euclidean_rhythm(pulses, steps, rotation); + + let mut created_count = 0; + for (i, &is_hit) in rhythm.iter().enumerate() { + if !is_hit { + continue; + } + + let target = (source_step + i) % pat_len; + + if target == source_step { + continue; + } + + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(target) + { + step.source = Some(source_step); + step.script.clear(); + step.command = None; + step.active = true; + } + created_count += 1; + } + + self.project_state.mark_dirty(bank, pattern); + + for (i, &is_hit) in rhythm.iter().enumerate() { + if !is_hit || i == 0 { + continue; + } + let target = (source_step + i) % pat_len; + let saved = self.editor_ctx.step; + self.editor_ctx.step = target; + self.compile_current_step(link); + self.editor_ctx.step = saved; + } + + self.load_step_to_editor(); + self.ui.flash( + &format!("Created {} linked steps (E({pulses},{steps}))", created_count), + 200, + FlashKind::Success, + ); + } } } @@ -1481,3 +1549,21 @@ impl App { } } } + +fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec { + if pulses == 0 || steps == 0 || pulses > steps { + return vec![false; steps]; + } + + let mut pattern = vec![false; steps]; + for i in 0..pulses { + let pos = (i * steps) / pulses; + pattern[pos] = true; + } + + if rotation > 0 { + pattern.rotate_left(rotation % steps); + } + + pattern +} diff --git a/src/commands.rs b/src/commands.rs index 7cfaf19..cc8a817 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -161,6 +161,7 @@ pub enum AppCommand { HideTitle, ToggleEditorStack, SetColorScheme(ColorScheme), + SetHueRotation(f32), ToggleRuntimeHighlight, ToggleCompletion, AdjustFlashBrightness(f32), @@ -207,4 +208,13 @@ pub enum AppCommand { // Metrics ResetPeakVoices, + // Euclidean distribution + ApplyEuclideanDistribution { + bank: usize, + pattern: usize, + source_step: usize, + pulses: usize, + steps: usize, + rotation: usize, + }, } diff --git a/src/input.rs b/src/input.rs index 8effb6c..51f6058 100644 --- a/src/input.rs +++ b/src/input.rs @@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ - CyclicEnum, DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, - PatternPropsField, SampleBrowserState, SettingKind, SidePanel, + CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus, + PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel, }; pub enum InputResult { @@ -641,6 +641,79 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } + Modal::EuclideanDistribution { + bank, + pattern, + source_step, + field, + pulses, + steps, + rotation, + } => { + let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step); + match key.code { + KeyCode::Up => *field = field.prev(), + KeyCode::Down | KeyCode::Tab => *field = field.next(), + KeyCode::Left => { + let target = match field { + EuclideanField::Pulses => pulses, + EuclideanField::Steps => steps, + EuclideanField::Rotation => rotation, + }; + if let Ok(val) = target.parse::() { + *target = val.saturating_sub(1).to_string(); + } + } + KeyCode::Right => { + let target = match field { + EuclideanField::Pulses => pulses, + EuclideanField::Steps => steps, + EuclideanField::Rotation => rotation, + }; + if let Ok(val) = target.parse::() { + *target = (val + 1).min(128).to_string(); + } + } + KeyCode::Char(c) if c.is_ascii_digit() => match field { + EuclideanField::Pulses => pulses.push(c), + EuclideanField::Steps => steps.push(c), + EuclideanField::Rotation => rotation.push(c), + }, + KeyCode::Backspace => match field { + EuclideanField::Pulses => { + pulses.pop(); + } + EuclideanField::Steps => { + steps.pop(); + } + EuclideanField::Rotation => { + rotation.pop(); + } + }, + KeyCode::Enter => { + let pulses_val: usize = pulses.parse().unwrap_or(0); + let steps_val: usize = steps.parse().unwrap_or(0); + let rotation_val: usize = rotation.parse().unwrap_or(0); + if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val { + ctx.dispatch(AppCommand::ApplyEuclideanDistribution { + bank: bank_val, + pattern: pattern_val, + source_step: source_step_val, + pulses: pulses_val, + steps: steps_val, + rotation: rotation_val, + }); + ctx.dispatch(AppCommand::CloseModal); + } else { + ctx.dispatch(AppCommand::SetStatus( + "Invalid: pulses must be > 0 and <= steps".to_string(), + )); + } + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + _ => {} + } + } Modal::None => unreachable!(), } InputResult::Continue @@ -962,6 +1035,25 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } + KeyCode::Char('e') | KeyCode::Char('E') => { + let (bank, pattern, step) = ( + ctx.app.editor_ctx.bank, + ctx.app.editor_ctx.pattern, + ctx.app.editor_ctx.step, + ); + let pattern_len = ctx.app.current_edit_pattern().length; + let default_steps = pattern_len.min(32); + let default_pulses = (default_steps / 2).max(1).min(default_steps); + ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution { + bank, + pattern, + source_step: step, + field: EuclideanField::Pulses, + pulses: default_pulses.to_string(), + steps: default_steps.to_string(), + rotation: "0".to_string(), + })); + } _ => {} } InputResult::Continue @@ -1292,6 +1384,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { }; ctx.dispatch(AppCommand::SetColorScheme(new_scheme)); } + OptionsFocus::HueRotation => { + let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 }; + let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0); + ctx.dispatch(AppCommand::SetHueRotation(new_rotation)); + } OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate), OptionsFocus::RuntimeHighlight => { ctx.dispatch(AppCommand::ToggleRuntimeHighlight); diff --git a/src/main.rs b/src/main.rs index b8e6c40..ee041f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,8 +100,11 @@ fn main() -> io::Result<()> { app.ui.show_completion = settings.display.show_completion; app.ui.flash_brightness = settings.display.flash_brightness; app.ui.color_scheme = settings.display.color_scheme; + app.ui.hue_rotation = settings.display.hue_rotation; app.audio.config.layout = settings.display.layout; - theme::set(settings.display.color_scheme.to_theme()); + let base_theme = settings.display.color_scheme.to_theme(); + let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation); + theme::set(rotated); // Load MIDI settings let outputs = midi::list_midi_outputs(); diff --git a/src/settings.rs b/src/settings.rs index 7bd6934..22f9845 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -52,6 +52,8 @@ pub struct DisplaySettings { pub color_scheme: ColorScheme, #[serde(default)] pub layout: MainLayout, + #[serde(default)] + pub hue_rotation: f32, } fn default_font() -> String { @@ -94,6 +96,7 @@ impl Default for DisplaySettings { font: default_font(), color_scheme: ColorScheme::default(), layout: MainLayout::default(), + hue_rotation: 0.0, } } } diff --git a/src/state/editor.rs b/src/state/editor.rs index 7a6697b..8073e12 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -41,6 +41,32 @@ impl PatternPropsField { } } +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum EuclideanField { + #[default] + Pulses, + Steps, + Rotation, +} + +impl EuclideanField { + pub fn next(&self) -> Self { + match self { + Self::Pulses => Self::Steps, + Self::Steps => Self::Rotation, + Self::Rotation => Self::Rotation, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Pulses => Self::Pulses, + Self::Steps => Self::Pulses, + Self::Rotation => Self::Steps, + } + } +} + pub struct EditorContext { pub bank: usize, pub pattern: usize, diff --git a/src/state/mod.rs b/src/state/mod.rs index 5e6d384..344ab77 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -30,7 +30,8 @@ pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind}; pub use color_scheme::ColorScheme; pub use editor::{ - CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache, + CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField, + StackCache, }; pub use live_keys::LiveKeyState; pub use modal::Modal; diff --git a/src/state/modal.rs b/src/state/modal.rs index 1d4d313..e438262 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -1,5 +1,5 @@ use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; -use crate::state::editor::{PatternField, PatternPropsField}; +use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::file_browser::FileBrowserState; #[derive(Clone, PartialEq, Eq)] @@ -66,4 +66,13 @@ pub enum Modal { KeybindingsHelp { scroll: usize, }, + EuclideanDistribution { + bank: usize, + pattern: usize, + source_step: usize, + field: EuclideanField, + pulses: String, + steps: String, + rotation: String, + }, } diff --git a/src/state/options.rs b/src/state/options.rs index 20f16f8..b6e3652 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -4,6 +4,7 @@ use super::CyclicEnum; pub enum OptionsFocus { #[default] ColorScheme, + HueRotation, RefreshRate, RuntimeHighlight, ShowScope, @@ -26,6 +27,7 @@ pub enum OptionsFocus { impl CyclicEnum for OptionsFocus { const VARIANTS: &'static [Self] = &[ Self::ColorScheme, + Self::HueRotation, Self::RefreshRate, Self::RuntimeHighlight, Self::ShowScope, diff --git a/src/state/ui.rs b/src/state/ui.rs index 743a513..eaa98f6 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -50,6 +50,7 @@ pub struct UiState { pub event_flash: f32, pub flash_brightness: f32, pub color_scheme: ColorScheme, + pub hue_rotation: f32, } impl Default for UiState { @@ -78,6 +79,7 @@ impl Default for UiState { event_flash: 0.0, flash_brightness: 1.0, color_scheme: ColorScheme::default(), + hue_rotation: 0.0, } } } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index 7704a81..ba3dc25 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str bindings.push(("f", "Fill", "Toggle fill mode (hold)")); bindings.push(("r", "Rename", "Rename current step")); bindings.push(("Ctrl+R", "Run", "Run step script immediately")); + bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm")); } Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 18b2492..b1d22c5 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -136,22 +136,15 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, let num_rows = steps_on_page.div_ceil(8); let steps_per_row = steps_on_page.div_ceil(num_rows); - let spacing = num_rows.saturating_sub(1) as u16; - let row_height = area.height.saturating_sub(spacing) / num_rows as u16; + let row_height = area.height / num_rows as u16; - let row_constraints: Vec = (0..num_rows * 2 - 1) - .map(|i| { - if i % 2 == 0 { - Constraint::Length(row_height) - } else { - Constraint::Length(1) - } - }) + let row_constraints: Vec = (0..num_rows) + .map(|_| Constraint::Length(row_height)) .collect(); let rows = Layout::vertical(row_constraints).split(area); for row_idx in 0..num_rows { - let row_area = rows[row_idx * 2]; + let row_area = rows[row_idx]; let start_step = row_idx * steps_per_row; let end_step = (start_step + steps_per_row).min(steps_on_page); let cols_in_row = end_step - start_step; @@ -226,6 +219,12 @@ fn render_tile( (false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg), }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + let source_idx = step.and_then(|s| s.source); let symbol = if is_playing { "▶".to_string() @@ -243,17 +242,17 @@ fn render_tile( }; let num_lines = if step_name.is_some() { 2u16 } else { 1u16 }; let content_height = num_lines; - let y_offset = area.height.saturating_sub(content_height) / 2; + let y_offset = inner.height.saturating_sub(content_height) / 2; - // Fill background for entire tile + // Fill background for inner area let bg_fill = Paragraph::new("").style(Style::new().bg(bg)); - frame.render_widget(bg_fill, area); + frame.render_widget(bg_fill, inner); if let Some(name) = step_name { let name_area = Rect { - x: area.x, - y: area.y + y_offset, - width: area.width, + x: inner.x, + y: inner.y + y_offset, + width: inner.width, height: 1, }; let name_widget = Paragraph::new(name.as_str()) @@ -262,9 +261,9 @@ fn render_tile( frame.render_widget(name_widget, name_area); let symbol_area = Rect { - x: area.x, - y: area.y + y_offset + 1, - width: area.width, + x: inner.x, + y: inner.y + y_offset + 1, + width: inner.width, height: 1, }; let symbol_widget = Paragraph::new(symbol) @@ -273,9 +272,9 @@ fn render_tile( frame.render_widget(symbol_widget, symbol_area); } else { let centered_area = Rect { - x: area.x, - y: area.y + y_offset, - width: area.width, + x: inner.x, + y: inner.y + y_offset, + width: inner.width, height: 1, }; let tile = Paragraph::new(symbol) diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 95ae678..0d6b2f5 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -110,6 +110,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let midi_in_2 = midi_in_display(2); let midi_in_3 = midi_in_display(3); + let hue_str = format!("{}°", app.ui.hue_rotation as i32); + let lines: Vec = vec![ render_section_header("DISPLAY", &theme), render_divider(content_width, &theme), @@ -119,6 +121,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ColorScheme, &theme, ), + render_option_line( + "Hue rotation", + &hue_str, + focus == OptionsFocus::HueRotation, + &theme, + ), render_option_line( "Refresh rate", app.audio.config.refresh_rate.label(), @@ -201,23 +209,24 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let focus_line: usize = match focus { OptionsFocus::ColorScheme => 2, - OptionsFocus::RefreshRate => 3, - OptionsFocus::RuntimeHighlight => 4, - OptionsFocus::ShowScope => 5, - OptionsFocus::ShowSpectrum => 6, - OptionsFocus::ShowCompletion => 7, - OptionsFocus::FlashBrightness => 8, - OptionsFocus::LinkEnabled => 12, - OptionsFocus::StartStopSync => 13, - OptionsFocus::Quantum => 14, - OptionsFocus::MidiOutput0 => 25, - OptionsFocus::MidiOutput1 => 26, - OptionsFocus::MidiOutput2 => 27, - OptionsFocus::MidiOutput3 => 28, - OptionsFocus::MidiInput0 => 32, - OptionsFocus::MidiInput1 => 33, - OptionsFocus::MidiInput2 => 34, - OptionsFocus::MidiInput3 => 35, + OptionsFocus::HueRotation => 3, + OptionsFocus::RefreshRate => 4, + OptionsFocus::RuntimeHighlight => 5, + OptionsFocus::ShowScope => 6, + OptionsFocus::ShowSpectrum => 7, + OptionsFocus::ShowCompletion => 8, + OptionsFocus::FlashBrightness => 9, + OptionsFocus::LinkEnabled => 13, + OptionsFocus::StartStopSync => 14, + OptionsFocus::Quantum => 15, + OptionsFocus::MidiOutput0 => 26, + OptionsFocus::MidiOutput1 => 27, + OptionsFocus::MidiOutput2 => 28, + OptionsFocus::MidiOutput3 => 29, + OptionsFocus::MidiInput0 => 33, + OptionsFocus::MidiInput1 => 34, + OptionsFocus::MidiInput2 => 35, + OptionsFocus::MidiInput3 => 36, }; let scroll_offset = if total_lines <= max_visible { diff --git a/src/views/render.rs b/src/views/render.rs index 2931e4a..7106093 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -16,7 +16,9 @@ use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::{SourceSpan, StepContext, Value}; use crate::page::Page; -use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache}; +use crate::state::{ + EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache, +}; use crate::theme; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; use crate::widgets::{ @@ -1038,5 +1040,131 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term hint_area, ); } + Modal::EuclideanDistribution { + source_step, + field, + pulses, + steps, + rotation, + .. + } => { + let width = 50u16; + let height = 11u16; + let x = (term.width.saturating_sub(width)) / 2; + let y = (term.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + let block = Block::bordered() + .title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1)) + .border_style(Style::default().fg(theme.modal.input)); + + let inner = block.inner(area); + frame.render_widget(Clear, area); + + // Fill background with theme color + let bg_fill = " ".repeat(area.width as usize); + for row in 0..area.height { + let line_area = Rect::new(area.x, area.y + row, area.width, 1); + frame.render_widget( + Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)), + line_area, + ); + } + + frame.render_widget(block, area); + + let fields = [ + ( + "Pulses", + pulses.as_str(), + *field == EuclideanField::Pulses, + ), + ("Steps", steps.as_str(), *field == EuclideanField::Steps), + ( + "Rotation", + rotation.as_str(), + *field == EuclideanField::Rotation, + ), + ]; + + for (i, (label, value, selected)) in fields.iter().enumerate() { + let row_y = inner.y + i as u16; + if row_y >= inner.y + inner.height { + break; + } + + let (label_style, value_style) = if *selected { + ( + Style::default() + .fg(theme.hint.key) + .add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.ui.text_primary) + .bg(theme.ui.surface), + ) + } else { + ( + Style::default().fg(theme.ui.text_muted), + Style::default().fg(theme.ui.text_primary), + ) + }; + + let label_area = Rect::new(inner.x + 1, row_y, 14, 1); + let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1); + + frame.render_widget( + Paragraph::new(format!("{label}:")).style(label_style), + label_area, + ); + frame.render_widget(Paragraph::new(*value).style(value_style), value_area); + } + + let preview_y = inner.y + 4; + if preview_y < inner.y + inner.height { + let pulses_val: usize = pulses.parse().unwrap_or(0); + let steps_val: usize = steps.parse().unwrap_or(0); + let rotation_val: usize = rotation.parse().unwrap_or(0); + let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val); + let preview_line = Line::from(vec![ + Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)), + Span::styled(preview, Style::default().fg(theme.modal.input)), + ]); + let preview_area = + Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1); + frame.render_widget(Paragraph::new(preview_line), preview_area); + } + + let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let hint_line = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(theme.hint.key)), + Span::styled(" nav ", Style::default().fg(theme.hint.text)), + Span::styled("←→", Style::default().fg(theme.hint.key)), + Span::styled(" adjust ", Style::default().fg(theme.hint.text)), + Span::styled("Enter", Style::default().fg(theme.hint.key)), + Span::styled(" apply ", Style::default().fg(theme.hint.text)), + Span::styled("Esc", Style::default().fg(theme.hint.key)), + Span::styled(" cancel", Style::default().fg(theme.hint.text)), + ]); + frame.render_widget(Paragraph::new(hint_line), hint_area); + } } } + +fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String { + if pulses == 0 || steps == 0 || pulses > steps { + return "[invalid]".to_string(); + } + + let mut pattern = vec![false; steps]; + for i in 0..pulses { + let pos = (i * steps) / pulses; + pattern[pos] = true; + } + + if rotation > 0 { + pattern.rotate_left(rotation % steps); + } + + let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect(); + format!("[{}]", chars.join(" ")) +}