Simple gui with track state and selection
This commit is contained in:
parent
0616721681
commit
b68e3af28a
1
gui/.gitignore
vendored
Normal file
1
gui/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
4231
gui/Cargo.lock
generated
Normal file
4231
gui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
gui/Cargo.toml
Normal file
16
gui/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "fcb-looper-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
bytes = "1.0"
|
||||||
|
eframe = { version = "0.29", default-features = true, features = [ "default_fonts", "glow" ] }
|
||||||
|
egui = "0.29"
|
||||||
|
futures = "0.3"
|
||||||
|
log = "0.4"
|
||||||
|
rosc = "0.10"
|
||||||
|
simple_logger = "5"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
72
gui/src/display_state.rs
Normal file
72
gui/src/display_state.rs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TrackState {
|
||||||
|
Empty,
|
||||||
|
Ready,
|
||||||
|
Recording,
|
||||||
|
Playing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackState {
|
||||||
|
pub fn display_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TrackState::Empty => "---",
|
||||||
|
TrackState::Ready => "READY",
|
||||||
|
TrackState::Recording => "REC",
|
||||||
|
TrackState::Playing => "PLAY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ColumnState {
|
||||||
|
pub track_states: Vec<TrackState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColumnState {
|
||||||
|
pub fn new(rows: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
track_states: vec![TrackState::Empty; rows],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DisplayState {
|
||||||
|
pub selected_column: usize, // 0-based internally
|
||||||
|
pub selected_row: usize, // 0-based internally
|
||||||
|
pub column_states: Vec<ColumnState>,
|
||||||
|
pub columns: usize,
|
||||||
|
pub rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayState {
|
||||||
|
pub fn new(columns: usize, rows: usize) -> Self {
|
||||||
|
let column_states = (0..columns).map(|_| ColumnState::new(rows)).collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
selected_column: 0,
|
||||||
|
selected_row: 0,
|
||||||
|
column_states,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_track_state(&mut self, column: usize, row: usize, state: TrackState) {
|
||||||
|
if column < self.columns && row < self.rows {
|
||||||
|
self.column_states[column].track_states[row] = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_selected_column(&mut self, column: usize) {
|
||||||
|
if column < self.columns {
|
||||||
|
self.selected_column = column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_selected_row(&mut self, row: usize) {
|
||||||
|
if row < self.rows {
|
||||||
|
self.selected_row = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
gui/src/main.rs
Normal file
52
gui/src/main.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
mod display_state;
|
||||||
|
mod osc;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use display_state::ColumnState;
|
||||||
|
use display_state::DisplayState;
|
||||||
|
use display_state::TrackState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Configuration
|
||||||
|
let socket_path = "fcb_looper.sock";
|
||||||
|
let columns = 5;
|
||||||
|
let rows = 5;
|
||||||
|
|
||||||
|
// Logger
|
||||||
|
simple_logger::SimpleLogger::new()
|
||||||
|
.with_level(log::LevelFilter::Error)
|
||||||
|
.with_module_level("fcb_looper_gui::osc::client", log::LevelFilter::Off)
|
||||||
|
.init()
|
||||||
|
.expect("Could not initialize logger");
|
||||||
|
|
||||||
|
// Create OSC client
|
||||||
|
let osc_client = osc::Client::new(socket_path, columns, rows)?;
|
||||||
|
let state_receiver = osc_client.state_receiver();
|
||||||
|
|
||||||
|
// Spawn OSC receiver thread on tokio runtime
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = osc_client.run().await {
|
||||||
|
eprintln!("OSC client error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run egui on main thread
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([800.0, 600.0])
|
||||||
|
.with_min_inner_size([50.0, 50.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// This blocks the main thread with egui's event loop
|
||||||
|
eframe::run_native(
|
||||||
|
"FCB Looper",
|
||||||
|
options,
|
||||||
|
Box::new(|_cc| Ok(Box::new(ui::App::new(state_receiver)))),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to run egui: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
126
gui/src/osc/client.rs
Normal file
126
gui/src/osc/client.rs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||||
|
|
||||||
|
use super::message::*;
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
type PlatformStream = UnixStream;
|
||||||
|
#[cfg(windows)]
|
||||||
|
type PlatformStream = NamedPipeClient;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
socket_path: String,
|
||||||
|
state_sender: watch::Sender<DisplayState>,
|
||||||
|
state_receiver: watch::Receiver<DisplayState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> {
|
||||||
|
let initial_state = DisplayState::new(columns, rows);
|
||||||
|
// Test data
|
||||||
|
let initial_state = {
|
||||||
|
let mut initial_state = initial_state;
|
||||||
|
initial_state.selected_column = 2;
|
||||||
|
initial_state.selected_row = 3;
|
||||||
|
initial_state.column_states[0].track_states[0] = TrackState::Playing;
|
||||||
|
initial_state.column_states[0].track_states[1] = TrackState::Ready;
|
||||||
|
initial_state.column_states[1].track_states[0] = TrackState::Ready;
|
||||||
|
initial_state.column_states[1].track_states[1] = TrackState::Playing;
|
||||||
|
initial_state.column_states[2].track_states[0] = TrackState::Ready;
|
||||||
|
initial_state.column_states[2].track_states[3] = TrackState::Recording;
|
||||||
|
|
||||||
|
initial_state
|
||||||
|
};
|
||||||
|
let (state_sender, state_receiver) = watch::channel(initial_state);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
socket_path: socket_path.to_string(),
|
||||||
|
state_sender,
|
||||||
|
state_receiver,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_receiver(&self) -> watch::Receiver<DisplayState> {
|
||||||
|
self.state_receiver.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&self) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
match self.try_connect_and_run().await {
|
||||||
|
Ok(()) => {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("OSC connection error: {}", e);
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_connect_and_run(&self) -> Result<()> {
|
||||||
|
let stream = self.connect().await?;
|
||||||
|
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
|
||||||
|
|
||||||
|
let mut current_state = self.state_receiver.borrow().clone();
|
||||||
|
|
||||||
|
while let Some(frame) = framed.try_next().await? {
|
||||||
|
if let Err(e) = self.handle_osc_data(frame.into(), &mut current_state).await {
|
||||||
|
log::error!("Error handling OSC data: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_osc_data(&self, data: Bytes, current_state: &mut DisplayState) -> Result<()> {
|
||||||
|
let (_, osc_packet) = rosc::decoder::decode_udp(&data)
|
||||||
|
.map_err(|e| anyhow!("Failed to decode OSC packet: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(update) = StateUpdate::from_osc_packet(osc_packet)? {
|
||||||
|
update.apply_to_state(current_state);
|
||||||
|
|
||||||
|
// Send updated state through watch channel
|
||||||
|
if let Err(_) = self.state_sender.send(current_state.clone()) {
|
||||||
|
return Err(anyhow!("Failed to send state update"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn connect(&self) -> Result<PlatformStream> {
|
||||||
|
let stream = UnixStream::connect(&self.socket_path).await.map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"Failed to connect to Unix socket {}: {}",
|
||||||
|
self.socket_path,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
async fn connect(&self) -> Result<PlatformStream> {
|
||||||
|
let pipe_path = if self.socket_path.starts_with(r"\\.\pipe\") {
|
||||||
|
self.socket_path.clone()
|
||||||
|
} else {
|
||||||
|
format!(r"\\.\pipe\{}", self.socket_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = ClientOptions::new()
|
||||||
|
.open(&pipe_path)
|
||||||
|
.map_err(|e| anyhow!("Failed to connect to named pipe {}: {}", pipe_path, e))?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
gui/src/osc/message.rs
Normal file
91
gui/src/osc/message.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use crate::display_state::{DisplayState, TrackState};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use rosc::{OscMessage, OscPacket, OscType};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StateUpdate {
|
||||||
|
TrackStateChanged {
|
||||||
|
column: usize, // 0-based
|
||||||
|
row: usize, // 0-based
|
||||||
|
state: TrackState,
|
||||||
|
},
|
||||||
|
SelectedColumnChanged {
|
||||||
|
column: usize, // 0-based
|
||||||
|
},
|
||||||
|
SelectedRowChanged {
|
||||||
|
row: usize, // 0-based
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateUpdate {
|
||||||
|
pub fn from_osc_packet(packet: OscPacket) -> Result<Option<Self>> {
|
||||||
|
match packet {
|
||||||
|
OscPacket::Message(msg) => Self::from_osc_message(msg),
|
||||||
|
OscPacket::Bundle(_) => {
|
||||||
|
// For now, we don't handle bundles
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_to_state(&self, state: &mut DisplayState) {
|
||||||
|
match self {
|
||||||
|
StateUpdate::TrackStateChanged {
|
||||||
|
column,
|
||||||
|
row,
|
||||||
|
state: track_state,
|
||||||
|
} => {
|
||||||
|
state.update_track_state(*column, *row, track_state.clone());
|
||||||
|
}
|
||||||
|
StateUpdate::SelectedColumnChanged { column } => {
|
||||||
|
state.update_selected_column(*column);
|
||||||
|
}
|
||||||
|
StateUpdate::SelectedRowChanged { row } => {
|
||||||
|
state.update_selected_row(*row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_osc_message(msg: OscMessage) -> Result<Option<Self>> {
|
||||||
|
let OscMessage { addr, args } = msg;
|
||||||
|
|
||||||
|
if addr.starts_with("/looper/cell/") && addr.ends_with("/state") {
|
||||||
|
// Parse: /looper/cell/{column}/{row}/state
|
||||||
|
let parts: Vec<&str> = addr.split('/').collect();
|
||||||
|
if parts.len() != 6 {
|
||||||
|
return Err(anyhow!("Invalid cell state address format: {}", addr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let column: usize = parts[3].parse::<usize>()? - 1; // Convert to 0-based
|
||||||
|
let row: usize = parts[4].parse::<usize>()? - 1; // Convert to 0-based
|
||||||
|
|
||||||
|
if let Some(OscType::String(state_str)) = args.first() {
|
||||||
|
let state = Self::track_state_from_osc_string(state_str);
|
||||||
|
return Ok(Some(StateUpdate::TrackStateChanged { column, row, state }));
|
||||||
|
}
|
||||||
|
} else if addr == "/looper/selected/column" {
|
||||||
|
if let Some(OscType::Int(column_1based)) = args.first() {
|
||||||
|
let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based
|
||||||
|
return Ok(Some(StateUpdate::SelectedColumnChanged { column }));
|
||||||
|
}
|
||||||
|
} else if addr == "/looper/selected/row" {
|
||||||
|
if let Some(OscType::Int(row_1based)) = args.first() {
|
||||||
|
let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based
|
||||||
|
return Ok(Some(StateUpdate::SelectedRowChanged { row }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown or unsupported message
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_state_from_osc_string(s: &str) -> TrackState {
|
||||||
|
match s {
|
||||||
|
"empty" => TrackState::Empty,
|
||||||
|
"ready" => TrackState::Ready,
|
||||||
|
"recording" => TrackState::Recording,
|
||||||
|
"playing" => TrackState::Playing,
|
||||||
|
_ => TrackState::Empty, // Default fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
gui/src/osc/mod.rs
Normal file
4
gui/src/osc/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
mod client;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub use client::Client;
|
||||||
35
gui/src/ui/app.rs
Normal file
35
gui/src/ui/app.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
state_receiver: tokio::sync::watch::Receiver<DisplayState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(state_receiver: tokio::sync::watch::Receiver<DisplayState>) -> Self {
|
||||||
|
Self { state_receiver }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for App {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
let state = self.state_receiver.borrow_and_update().clone();
|
||||||
|
ctx.style_mut(|style| {
|
||||||
|
style.visuals.panel_fill = egui::Color32::from_rgb(17, 17, 17);
|
||||||
|
style.visuals.window_fill = egui::Color32::from_rgb(17, 17, 17);
|
||||||
|
style.spacing.item_spacing = egui::vec2(0.0, 0.0);
|
||||||
|
});
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.columns(state.columns, |ui| {
|
||||||
|
for (i, column) in state.column_states.iter().enumerate() {
|
||||||
|
let selected_row = if i == state.selected_column {
|
||||||
|
Some(state.selected_row)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
ui[i].add(super::Column::new(column, selected_row));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
gui/src/ui/column.rs
Normal file
27
gui/src/ui/column.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use super::ui_rows_ext::UiRowsExt;
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
pub struct Column<'a> {
|
||||||
|
state: &'a ColumnState,
|
||||||
|
selected_row: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Column<'a> {
|
||||||
|
pub fn new(state: &'a ColumnState, selected_row: Option<usize>) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
selected_row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> egui::Widget for Column<'a> {
|
||||||
|
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
|
||||||
|
ui.rows(self.state.track_states.len(), |ui| {
|
||||||
|
for (i, track) in self.state.track_states.iter().enumerate() {
|
||||||
|
ui[i].add(super::Track::new(track, Some(i) == self.selected_row));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.response()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
gui/src/ui/mod.rs
Normal file
8
gui/src/ui/mod.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
mod app;
|
||||||
|
mod column;
|
||||||
|
mod track;
|
||||||
|
mod ui_rows_ext;
|
||||||
|
|
||||||
|
pub use app::App;
|
||||||
|
use column::Column;
|
||||||
|
use track::Track;
|
||||||
66
gui/src/ui/track.rs
Normal file
66
gui/src/ui/track.rs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
pub struct Track<'a> {
|
||||||
|
state: &'a TrackState,
|
||||||
|
selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Track<'a> {
|
||||||
|
pub fn new(state: &'a TrackState, selected: bool) -> Self {
|
||||||
|
Self { state, selected }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_colors(&self) -> (egui::Color32, egui::Color32) {
|
||||||
|
// Returns (background_color, text_color)
|
||||||
|
let (dark, bright) = match self.state {
|
||||||
|
TrackState::Empty => (
|
||||||
|
egui::Color32::from_rgb(26, 26, 26), // #1a1a1a
|
||||||
|
egui::Color32::from_rgb(102, 102, 102), // #666666
|
||||||
|
),
|
||||||
|
TrackState::Ready => (
|
||||||
|
egui::Color32::from_rgb(10, 48, 32), // #0a3020
|
||||||
|
egui::Color32::from_rgb(0, 170, 102), // #00aa66
|
||||||
|
),
|
||||||
|
TrackState::Playing => (
|
||||||
|
egui::Color32::from_rgb(0, 42, 64), // #002a40
|
||||||
|
egui::Color32::from_rgb(0, 136, 204), // #0088cc
|
||||||
|
),
|
||||||
|
TrackState::Recording => (
|
||||||
|
egui::Color32::from_rgb(68, 10, 10), // #440a0a
|
||||||
|
egui::Color32::from_rgb(204, 68, 68), // #cc4444
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if self.selected {
|
||||||
|
(bright, dark)
|
||||||
|
} else {
|
||||||
|
(dark, bright)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> egui::Widget for Track<'a> {
|
||||||
|
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
|
||||||
|
let sf = ui.available_width() / 100.0;
|
||||||
|
let text_style = egui::TextStyle::Body;
|
||||||
|
let mut font_id = ui.style().text_styles.get(&text_style).unwrap().clone();
|
||||||
|
font_id.size = 20.0 * sf;
|
||||||
|
|
||||||
|
let (bg_color, fg_color) = self.get_colors();
|
||||||
|
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(bg_color)
|
||||||
|
.rounding(egui::Rounding::same(4.0 * sf))
|
||||||
|
.stroke(egui::Stroke::new(2.5 * sf, fg_color))
|
||||||
|
.outer_margin(egui::Margin::same(5.0 * sf))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.add(egui::Label::new(
|
||||||
|
egui::RichText::new(self.state.display_str())
|
||||||
|
.font(font_id)
|
||||||
|
.color(fg_color),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
|
}
|
||||||
75
gui/src/ui/ui_rows_ext.rs
Normal file
75
gui/src/ui/ui_rows_ext.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
use egui::{pos2, vec2, Align, Layout, Rect, Ui, UiBuilder};
|
||||||
|
|
||||||
|
/// Extension trait to add rows functionality to egui::Ui
|
||||||
|
pub trait UiRowsExt {
|
||||||
|
/// Divide the available height into equal rows.
|
||||||
|
///
|
||||||
|
/// Similar to `ui.columns()` but for horizontal rows.
|
||||||
|
/// Each row gets equal height and spans the full width.
|
||||||
|
fn rows<R>(&mut self, num_rows: usize, add_contents: impl FnOnce(&mut [Ui]) -> R) -> R;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRowsExt for Ui {
|
||||||
|
#[inline]
|
||||||
|
fn rows<R>(&mut self, num_rows: usize, add_contents: impl FnOnce(&mut [Ui]) -> R) -> R {
|
||||||
|
self.rows_dyn(num_rows, Box::new(add_contents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper trait to mirror egui's internal pattern
|
||||||
|
trait UiRowsExtPrivate {
|
||||||
|
fn rows_dyn<'c, R>(
|
||||||
|
&mut self,
|
||||||
|
num_rows: usize,
|
||||||
|
add_contents: Box<dyn FnOnce(&mut [Ui]) -> R + 'c>,
|
||||||
|
) -> R;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRowsExtPrivate for Ui {
|
||||||
|
fn rows_dyn<'c, R>(
|
||||||
|
&mut self,
|
||||||
|
num_rows: usize,
|
||||||
|
add_contents: Box<dyn FnOnce(&mut [Self]) -> R + 'c>,
|
||||||
|
) -> R {
|
||||||
|
let spacing = self.spacing().item_spacing.y;
|
||||||
|
let total_spacing = spacing * (num_rows as f32 - 1.0);
|
||||||
|
let row_height = (self.available_height() - total_spacing) / (num_rows as f32);
|
||||||
|
let top_left = self.cursor().min;
|
||||||
|
|
||||||
|
let mut rows: Vec<Self> = (0..num_rows)
|
||||||
|
.map(|row_idx| {
|
||||||
|
let pos = top_left + vec2(0.0, (row_idx as f32) * (row_height + spacing));
|
||||||
|
let child_rect = Rect::from_min_max(
|
||||||
|
pos,
|
||||||
|
pos2(self.max_rect().right_bottom().x, pos.y + row_height),
|
||||||
|
);
|
||||||
|
let mut row_ui = self.new_child(
|
||||||
|
UiBuilder::new()
|
||||||
|
.max_rect(child_rect)
|
||||||
|
.layout(Layout::left_to_right(Align::Center)),
|
||||||
|
);
|
||||||
|
row_ui.set_height(row_height);
|
||||||
|
row_ui
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = add_contents(&mut rows[..]);
|
||||||
|
|
||||||
|
let mut max_row_height = row_height;
|
||||||
|
let mut max_width = 0.0;
|
||||||
|
for row in &rows {
|
||||||
|
max_row_height = max_row_height.max(row.min_rect().height());
|
||||||
|
max_width = row.min_size().x.max(max_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we fit everything next frame:
|
||||||
|
let total_required_height = total_spacing + max_row_height * (num_rows as f32);
|
||||||
|
|
||||||
|
let size = vec2(
|
||||||
|
max_width,
|
||||||
|
self.available_height().max(total_required_height),
|
||||||
|
);
|
||||||
|
self.advance_cursor_after_rect(Rect::from_min_size(top_left, size));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user