WIP: better precision?

This commit is contained in:
2026-01-29 18:50:54 +01:00
parent 00a90f1c15
commit 89e4795e86
13 changed files with 477 additions and 224 deletions

View File

@@ -11,6 +11,7 @@ use crate::widgets::{Orientation, Scope, Spectrum};
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [left_col, _, right_col] = Layout::horizontal([
@@ -40,20 +41,105 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
height: inner.height.saturating_sub(1),
};
let devices_height = devices_section_height(app);
// Calculate section heights
let devices_lines = devices_section_height(app) as usize;
let settings_lines: usize = 8; // header(1) + divider(1) + 6 rows
let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1)
let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines;
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
Constraint::Length(devices_height),
Constraint::Length(1),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(6),
])
.areas(padded);
let max_visible = padded.height as usize;
render_devices(frame, app, devices_area);
render_settings(frame, app, settings_area);
render_samples(frame, app, samples_area);
// Calculate scroll offset based on focused section
let (focus_start, focus_height) = match app.audio.section {
EngineSection::Devices => (0, devices_lines),
EngineSection::Settings => (devices_lines + 1, settings_lines),
EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines),
};
let scroll_offset = if total_lines <= max_visible {
0
} else {
// Keep focused section in view (top-aligned when possible)
let focus_end = focus_start + focus_height;
if focus_end <= max_visible {
0
} else {
focus_start.min(total_lines.saturating_sub(max_visible))
}
};
let viewport_top = padded.y as i32;
let viewport_bottom = (padded.y + padded.height) as i32;
// Render each section at adjusted position
let mut y = viewport_top - scroll_offset as i32;
// Devices section
let devices_top = y;
let devices_bottom = y + devices_lines as i32;
if devices_bottom > viewport_top && devices_top < viewport_bottom {
let clipped_y = devices_top.max(viewport_top) as u16;
let clipped_height =
(devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16;
let devices_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_devices(frame, app, devices_area);
}
y += devices_lines as i32 + 1; // +1 for blank line
// Settings section
let settings_top = y;
let settings_bottom = y + settings_lines as i32;
if settings_bottom > viewport_top && settings_top < viewport_bottom {
let clipped_y = settings_top.max(viewport_top) as u16;
let clipped_height =
(settings_bottom.min(viewport_bottom) - settings_top.max(viewport_top)) as u16;
let settings_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_settings(frame, app, settings_area);
}
y += settings_lines as i32 + 1;
// Samples section
let samples_top = y;
let samples_bottom = y + samples_lines as i32;
if samples_bottom > viewport_top && samples_top < viewport_bottom {
let clipped_y = samples_top.max(viewport_top) as u16;
let clipped_height =
(samples_bottom.min(viewport_bottom) - samples_top.max(viewport_top)) as u16;
let samples_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
render_samples(frame, app, samples_area);
}
// Scroll indicators
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
let indicator_x = padded.x + padded.width.saturating_sub(1);
if scroll_offset > 0 {
let up_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1));
}
if scroll_offset + max_visible < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
);
}
}
fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
@@ -241,6 +327,7 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
let lookahead_focused = section_focused && app.audio.setting_kind == SettingKind::Lookahead;
let nudge_ms = app.metrics.nudge_ms;
let nudge_label = if nudge_ms == 0.0 {
@@ -249,6 +336,12 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
format!("{nudge_ms:+.1} ms")
};
let lookahead_label = if app.audio.config.lookahead_ms == 0 {
"off".to_string()
} else {
format!("{} ms", app.audio.config.lookahead_ms)
};
let rows = vec![
Row::new(vec![
Span::styled(
@@ -305,6 +398,17 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
),
render_selector(&nudge_label, nudge_focused, highlight, normal),
]),
Row::new(vec![
Span::styled(
if lookahead_focused {
"> Lookahead"
} else {
" Lookahead"
},
label_style,
),
render_selector(&lookahead_label, lookahead_focused, highlight, normal),
]),
Row::new(vec![
Span::styled(" Sample rate", label_style),
Span::styled(

View File

@@ -1,4 +1,4 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
@@ -11,6 +11,7 @@ use crate::state::OptionsFocus;
const LABEL_COLOR: Color = Color::Rgb(120, 125, 135);
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let block = Block::default()
@@ -28,43 +29,59 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
height: inner.height.saturating_sub(2),
};
let [display_area, _, link_area, _, session_area] = Layout::vertical([
Constraint::Length(8),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(5),
])
.areas(padded);
render_display_section(frame, app, display_area);
render_link_section(frame, app, link, link_area);
render_session_section(frame, link, session_area);
}
fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"DISPLAY",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus;
let content_width = padded.width as usize;
// Build link header with status
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let link_header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
]);
// Prepare values
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
let lines = vec![
let quantum_str = format!("{:.0}", link.quantum());
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
// Build flat list of all lines
let lines: Vec<Line> = vec![
// DISPLAY section (lines 0-7)
render_section_header("DISPLAY"),
render_divider(content_width),
render_option_line(
"Refresh rate",
app.audio.config.refresh_rate.label(),
@@ -94,68 +111,12 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
if app.ui.show_completion { "On" } else { "Off" },
focus == OptionsFocus::ShowCompletion,
),
render_option_line(
"Flash brightness",
&flash_str,
focus == OptionsFocus::FlashBrightness,
),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
]);
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus;
let quantum_str = format!("{:.0}", link.quantum());
let lines = vec![
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness),
// Blank line (line 8)
Line::from(""),
// ABLETON LINK section (lines 9-14)
link_header,
render_divider(content_width),
render_option_line(
"Enabled",
if link.is_enabled() { "On" } else { "Off" },
@@ -171,47 +132,84 @@ fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rec
focus == OptionsFocus::StartStopSync,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_session_section(frame: &mut Frame, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"SESSION",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let lines = vec![
// Blank line (line 15)
Line::from(""),
// SESSION section (lines 16-21)
render_section_header("SESSION"),
render_divider(content_width),
render_readonly_line("Tempo", &tempo_str, tempo_style),
render_readonly_line("Beat", &beat_str, value_style),
render_readonly_line("Phase", &phase_str, value_style),
];
frame.render_widget(Paragraph::new(lines), content_area);
let total_lines = lines.len();
let max_visible = padded.height as usize;
// Map focus to line index
let focus_line: usize = match focus {
OptionsFocus::RefreshRate => 2,
OptionsFocus::RuntimeHighlight => 3,
OptionsFocus::ShowScope => 4,
OptionsFocus::ShowSpectrum => 5,
OptionsFocus::ShowCompletion => 6,
OptionsFocus::FlashBrightness => 7,
OptionsFocus::LinkEnabled => 11,
OptionsFocus::StartStopSync => 12,
OptionsFocus::Quantum => 13,
};
// Calculate scroll offset to keep focused line visible (centered when possible)
let scroll_offset = if total_lines <= max_visible {
0
} else {
focus_line
.saturating_sub(max_visible / 2)
.min(total_lines.saturating_sub(max_visible))
};
// Render visible portion
let visible_end = (scroll_offset + max_visible).min(total_lines);
let visible_lines: Vec<Line> = lines
.into_iter()
.skip(scroll_offset)
.take(visible_end - scroll_offset)
.collect();
frame.render_widget(Paragraph::new(visible_lines), padded);
// Render scroll indicators
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
let indicator_x = padded.x + padded.width.saturating_sub(1);
if scroll_offset > 0 {
let up_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
up_indicator,
Rect::new(indicator_x, padded.y, 1, 1),
);
}
if visible_end < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
);
}
}
fn render_section_header(title: &str) -> Line<'static> {
Line::from(Span::styled(
title.to_string(),
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
))
}
fn render_divider(width: usize) -> Line<'static> {
Line::from(Span::styled(
"".repeat(width),
Style::new().fg(DIVIDER_COLOR),
))
}
fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {

View File

@@ -9,6 +9,8 @@ use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn;
const MIN_ROW_HEIGHT: u16 = 1;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let [banks_area, gap, patterns_area] = Layout::horizontal([
Constraint::Fill(1),
@@ -55,16 +57,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
})
.collect();
let row_height = (inner.height / MAX_BANKS as u16).max(1);
let total_needed = row_height * MAX_BANKS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
let cursor = app.patterns_nav.bank_cursor;
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let max_visible = max_visible.max(1);
let scroll_offset = if MAX_BANKS <= max_visible {
0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_BANKS - max_visible)
};
for idx in 0..MAX_BANKS {
let y = inner.y + top_padding + (idx as u16) * row_height;
let visible_count = MAX_BANKS.min(max_visible);
let row_height = inner.height / visible_count as u16;
let row_height = row_height.max(MIN_ROW_HEIGHT);
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let y = inner.y + (visible_idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
@@ -126,6 +137,22 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let para = Paragraph::new(label).style(style);
frame.render_widget(para, text_area);
}
// Scroll indicators
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
if scroll_offset > 0 {
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..inner });
}
if scroll_offset + visible_count < MAX_BANKS {
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
}
}
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
@@ -191,16 +218,25 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None
};
let row_height = (inner.height / MAX_PATTERNS as u16).max(1);
let total_needed = row_height * MAX_PATTERNS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
let cursor = app.patterns_nav.pattern_cursor;
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
let max_visible = max_visible.max(1);
let scroll_offset = if MAX_PATTERNS <= max_visible {
0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(MAX_PATTERNS - max_visible)
};
for idx in 0..MAX_PATTERNS {
let y = inner.y + top_padding + (idx as u16) * row_height;
let visible_count = MAX_PATTERNS.min(max_visible);
let row_height = inner.height / visible_count as u16;
let row_height = row_height.max(MIN_ROW_HEIGHT);
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let y = inner.y + (visible_idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
@@ -247,52 +283,56 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
row_area.y
};
// Split row into columns: [index+name] [length] [speed]
let speed_width: u16 = 14; // "Speed: 1/4x "
let length_width: u16 = 13; // "Length: 16 "
let name_width = row_area
.width
.saturating_sub(speed_width + length_width + 2);
let [name_area, length_area, speed_area] = Layout::horizontal([
Constraint::Length(name_width),
Constraint::Length(length_width),
Constraint::Length(speed_width),
])
.areas(Rect {
let text_area = Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = if name.is_empty() {
format!("{}{:02}", prefix, idx + 1)
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
// Build the line: [prefix][idx] [name] ... [length] [speed]
let name_style = if is_playing || is_staged_play {
bold_style
} else {
base_style
};
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area);
let dim_style = base_style.remove_modifier(Modifier::BOLD);
// Column 2: length
let length_line = Line::from(vec![
Span::styled("Length: ", bold_style),
Span::styled(format!("{length}"), base_style),
]);
frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default)
if speed != PatternSpeed::NORMAL {
let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style),
]);
frame.render_widget(Paragraph::new(speed_line), speed_area);
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
if !name.is_empty() {
spans.push(Span::styled(format!(" {name}"), name_style));
}
// Right-aligned info: length and speed
let speed_str = if speed != PatternSpeed::NORMAL {
format!(" {}", speed.label())
} else {
String::new()
};
let right_info = format!("{length}{speed_str}");
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let right_width = right_info.chars().count();
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(right_info, dim_style));
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
}
// Scroll indicators
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
if scroll_offset > 0 {
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..inner });
}
if scroll_offset + visible_count < MAX_PATTERNS {
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
}
}

View File

@@ -430,12 +430,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("?", "Keys"),
],
Page::Patterns => vec![
("←→↑↓", "Navigate"),
("Enter", "Select"),
("Space", "Play"),
("Esc", "Back"),
("r", "Rename"),
("Del", "Reset"),
("?", "Keys"),
],
Page::Engine => vec![