diff --git a/src/app.rs b/src/app.rs index ba0511e..ce5661f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,16 +13,19 @@ use crate::{ }, ui::{ ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, - draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_matrix_config_panel, - draw_stats_panel, panel_restore_item, + draw_config_panel, draw_spatial_force_panel, draw_stats_panel, panel_restore_item, }, }; use eframe::{egui, egui_wgpu}; -use std::sync::Arc; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; const SUMMARY_POINTS_PER_SERIES: usize = 42; const HAND_FORCE_PANEL_COUNT: usize = 7; const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44]; +const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500); pub struct EskinDesktopApp { connect_panel: FloatingPanelState, @@ -44,16 +47,61 @@ pub struct EskinDesktopApp { hand_signal_histories: [Vec; HAND_FORCE_PANEL_COUNT], force_estimator: ForceEstimatorState, latest_spatial_force: Option, + spatial_force_panel_force: Option, latest_raw_matrix: Vec, latest_matrix_rows: u32, latest_matrix_cols: u32, breakout_visible: bool, breakout_game: BreakoutGame, + live_rates: LiveRateMeters, context_menu_open: bool, context_menu_pos: egui::Pos2, active_mode: ActiveMode, } +struct LiveRateMeters { + window_started_at: Instant, + window_rx_frames: u64, + window_render_frames: u32, + sample_rate_hz: f32, + render_rate_hz: f32, +} + +impl LiveRateMeters { + fn new() -> Self { + Self { + window_started_at: Instant::now(), + window_rx_frames: 0, + window_render_frames: 0, + sample_rate_hz: 0.0, + render_rate_hz: 0.0, + } + } + + fn tick_render(&mut self, total_rx_frames: u64) { + if total_rx_frames < self.window_rx_frames { + self.window_rx_frames = total_rx_frames; + self.sample_rate_hz = 0.0; + } + + self.window_render_frames = self.window_render_frames.saturating_add(1); + + let now = Instant::now(); + let elapsed = now.duration_since(self.window_started_at); + if elapsed < RATE_UPDATE_INTERVAL { + return; + } + + let seconds = elapsed.as_secs_f32().max(0.001); + let rx_delta = total_rx_frames.saturating_sub(self.window_rx_frames); + self.sample_rate_hz = rx_delta as f32 / seconds; + self.render_rate_hz = self.window_render_frames as f32 / seconds; + self.window_started_at = now; + self.window_rx_frames = total_rx_frames; + self.window_render_frames = 0; + } +} + impl EskinDesktopApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { egui_extras::install_image_loaders(&cc.egui_ctx); @@ -110,11 +158,13 @@ impl EskinDesktopApp { hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)), force_estimator: ForceEstimatorState::new(), latest_spatial_force: None, + spatial_force_panel_force: None, latest_raw_matrix: Vec::new(), latest_matrix_rows: MATRIX_ROWS, latest_matrix_cols: MATRIX_COLS, breakout_visible: false, breakout_game: BreakoutGame::default(), + live_rates: LiveRateMeters::new(), context_menu_open: false, context_menu_pos: egui::pos2(24.0, 48.0), active_mode: ActiveMode::Finger(FingerMode { @@ -362,13 +412,22 @@ impl EskinDesktopApp { } } - fn draw_floating_panels(&mut self, ctx: &egui::Context) { - // draw_scene_panel(ctx, &mut self.scene_panel); - // if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible { - // self.config_panel.visible = false; - // self.export_panel.visible = false; - // self.matrix_config_panel.visible = false; - // } + fn draw_floating_panels( + &mut self, + ctx: &egui::Context, + stats: crate::serial_core::serial::SerialIoStats, + ) { + self.scene_panel.visible = false; + self.connect_panel.visible = false; + self.export_panel.visible = false; + self.matrix_config_panel.visible = false; + self.context_menu_open = false; + + if self.breakout_visible { + self.config_panel.visible = false; + self.stats_panel.visible = false; + return; + } if let Some(next_mode) = draw_config_panel( ctx, @@ -376,29 +435,43 @@ impl EskinDesktopApp { &mut self.config_state, &self.connection, &self.recorder, + stats, + self.live_rates.sample_rate_hz, + self.live_rates.render_rate_hz, ) { - self.switch_mode(next_mode); - } - match self.config_state.mode { - SerialMode::Finger => { - draw_stats_panel( - ctx, - &mut self.stats_panel, - &self.signal_history, - self.latest_spatial_force, - ); - } - SerialMode::Hand => { - draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories); + if next_mode == SerialMode::Hand { + self.config_state.mode = SerialMode::Finger; + self.connect_state.mode = SerialMode::Finger; + self.config_state.baud_rate = SerialMode::Finger.baud_rate(); + self.active_mode = ActiveMode::Finger(FingerMode { + rows: self.matrix_config.rows, + cols: self.matrix_config.cols, + range: 0..7000, + dot: true, + }); + self.breakout_visible = true; + } else { + self.switch_mode(next_mode); } } - draw_export_panel( - ctx, - &mut self.export_panel, - &self.recorder, - &mut self.export_path, - ); - draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config); + + if self.config_state.mode == SerialMode::Finger { + self.stats_panel.visible = true; + draw_stats_panel( + ctx, + &mut self.stats_panel, + &self.signal_history, + self.latest_spatial_force, + ); + } else { + self.stats_panel.visible = false; + } + + if let Some(force) = self.latest_spatial_force { + self.spatial_force_panel_force = Some(force); + } + + draw_spatial_force_panel(ctx, self.spatial_force_panel_force, &self.signal_history); } fn draw_panel_context_menu(&mut self, ctx: &egui::Context) { @@ -499,6 +572,7 @@ impl EskinDesktopApp { self.hand_pressure.clear(); self.force_estimator.reset(); self.latest_spatial_force = None; + self.spatial_force_panel_force = None; self.signal_history.clear(); self.hand_signal_histories .iter_mut() @@ -663,12 +737,13 @@ fn normalize_pressure_value(value: u32) -> [f32; 2] { impl eframe::App for EskinDesktopApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); + let stats = self.connection.stats(); + self.live_rates.tick_render(stats.rx_frames); self.update_pressure_matrix(); self.draw_workspace(ui); self.draw_title_bar(ui, frame); - self.draw_floating_panels(&ctx); - self.draw_panel_context_menu(&ctx); + self.draw_floating_panels(&ctx, stats); // Keep repainting while the wgpu background is a realtime viewport. ctx.request_repaint(); diff --git a/src/breakout.rs b/src/breakout.rs index 882bda8..9927002 100644 --- a/src/breakout.rs +++ b/src/breakout.rs @@ -20,6 +20,7 @@ enum BreakoutPhase { Idle, Running, Paused, + Won, Over, } @@ -46,7 +47,6 @@ pub struct BreakoutGame { score: u32, combo: u32, lives: u32, - level: u32, last_time: Option, previous_pause_gesture: bool, pause_locked_until: f64, @@ -63,7 +63,6 @@ impl Default for BreakoutGame { score: 0, combo: 0, lives: 3, - level: 1, last_time: None, previous_pause_gesture: false, pause_locked_until: 0.0, @@ -150,7 +149,6 @@ impl BreakoutGame { status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN); status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE); status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED); - status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE); }); ui.add_space(7.0); @@ -191,12 +189,11 @@ impl BreakoutGame { self.score = 0; self.combo = 0; self.lives = 3; - self.level = 1; self.rebuild_bricks(); } fn start(&mut self) { - if self.phase == BreakoutPhase::Over { + if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won { self.reset(); } if self.phase != BreakoutPhase::Running { @@ -219,7 +216,10 @@ impl BreakoutGame { let threshold = pressure_pause_threshold(); let active = top_force >= threshold; if active && !self.previous_pause_gesture && now >= self.pause_locked_until { - if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over { + if matches!( + self.phase, + BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won + ) { self.start(); } else { self.toggle_pause(); @@ -249,7 +249,7 @@ impl BreakoutGame { } fn launch_ball(&mut self) { - let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 }; + let direction = 0.24; self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS); self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED; } @@ -324,9 +324,8 @@ impl BreakoutGame { } if self.bricks.iter().all(|brick| !brick.alive) { - self.level += 1; - self.rebuild_bricks(); - self.launch_ball(); + self.phase = BreakoutPhase::Won; + self.ball_vel = egui::Vec2::ZERO; } } @@ -358,6 +357,7 @@ impl BreakoutGame { BreakoutPhase::Idle => "待机", BreakoutPhase::Running => "运行", BreakoutPhase::Paused => "暂停", + BreakoutPhase::Won => "过关", BreakoutPhase::Over => "结束", } } @@ -387,6 +387,7 @@ impl BreakoutGame { if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Paused + || self.phase == BreakoutPhase::Won || self.phase == BreakoutPhase::Over { paint_center_overlay(&painter, rect, self.phase); @@ -403,6 +404,10 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl let cols = cols.max(1) as usize; let sample_rows = rows.min(2); let sample_cols = cols.min(2); + let top_gesture_rows = rows.min(1); + let top_gesture_cols = (cols / 3).clamp(1, cols); + let top_gesture_col_start = (cols - top_gesture_cols) / 2; + let top_gesture_col_end = top_gesture_col_start + top_gesture_cols; let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 { let mut sum = 0.0; let mut count = 0.0; @@ -430,7 +435,12 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl let left_force = tl + bl; let right_force = tr + br; - let top_force = tl + tr; + let top_force = avg( + 0, + top_gesture_rows, + top_gesture_col_start, + top_gesture_col_end, + ); let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22); let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0); let axis = if raw_axis.abs() < 0.045 { @@ -485,6 +495,7 @@ fn status_color(phase: BreakoutPhase) -> egui::Color32 { BreakoutPhase::Idle => ONE_DARK_PRO.text_dim, BreakoutPhase::Running => ACCENT_GREEN, BreakoutPhase::Paused => ACCENT_ORANGE, + BreakoutPhase::Won => ACCENT_GREEN, BreakoutPhase::Over => ACCENT_RED, } } @@ -607,6 +618,7 @@ fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: Breako let (title, detail) = match phase { BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"), BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"), + BreakoutPhase::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"), BreakoutPhase::Over => ("游戏结束", "点击或空格重开"), BreakoutPhase::Running => ("", ""), }; diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs index aa837e4..9c1705e 100644 --- a/src/serial_core/serial.rs +++ b/src/serial_core/serial.rs @@ -14,6 +14,7 @@ const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70 pub struct SerialIoStats { pub rx_bytes: u64, pub tx_bytes: u64, + pub rx_frames: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -101,6 +102,8 @@ fn run_tactile_a_loop( recorder.add_frame(&pressures); } let _ = sample_tx.try_send(vals); + io_stats.rx_frames += 1; + publish_stats(stats_tx, io_stats); } } } @@ -191,6 +194,8 @@ fn run_hand_gateway_loop( recorder.add_frame(&pressures); } let _ = sample_tx.try_send(vals); + io_stats.rx_frames += 1; + publish_stats(stats_tx, io_stats); } } } diff --git a/src/ui.rs b/src/ui.rs index eb38e24..4250187 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -391,17 +391,20 @@ pub fn draw_config_panel( config: &mut ConfigPanelState, connection: &ConnectionManager, recorder: &Recorder, + stats: SerialIoStats, + sample_rate_hz: f32, + render_rate_hz: f32, ) -> Option { let mut changed_mode = None; let conn_state = connection.state(); - let stats = connection.stats(); let panel_width = responsive_panel_width(ctx, 560.0, 340.0); config.connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); + panel.visible = true; - draw_floating_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| { + draw_pinned_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| { ui.set_min_width(panel_width); ui.set_max_width(panel_width); @@ -416,11 +419,85 @@ pub fn draw_config_panel( // Legacy serial parameter grid is intentionally kept commented for future hardware: // draw_serial_grid(ui, config); // ui.add_space(8.0); - draw_mode_body(ui, config, conn_state, stats); + draw_mode_body( + ui, + config, + conn_state, + stats, + sample_rate_hz, + render_rate_hz, + ); }); changed_mode } +pub fn draw_spatial_force_panel( + ctx: &egui::Context, + force: Option, + force_history: &[f32], +) { + let Some(force) = force else { + return; + }; + + let target_visible = has_recent_resultant_force(force_history); + let anim = ctx.animate_bool(egui::Id::new("spatial_force_panel_enter"), target_visible); + if anim <= 0.01 { + return; + } + + let target_anim = if target_visible { 1.0 } else { 0.0 }; + if (anim - target_anim).abs() > 0.001 { + ctx.request_repaint(); + } + + let alpha = (anim * 242.0).round() as u8; + let panel_width = responsive_panel_width(ctx, 380.0, 260.0); + let panel_height = responsive_panel_height(ctx, 340.0, 220.0); + let viewport = viewport_panel_rect(ctx); + let pos = egui::pos2( + viewport.right() - panel_width, + viewport.bottom() - panel_height - PANEL_VIEWPORT_MARGIN, + ); + + egui::Area::new(egui::Id::new("spatial_force_panel")) + .fixed_pos(pos) + .constrain_to(viewport) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + faded_panel_frame(ctx, alpha).show(ui, |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + ui.set_min_height(panel_height); + + let text_dim = color_alpha(ONE_DARK_PRO.text_dim, alpha); + let accent = color_alpha(ACCENT_ORANGE, alpha); + let green = color_alpha(ACCENT_GREEN, alpha); + + ui.horizontal(|ui| { + ui.colored_label(accent, "3D FORCE"); + ui.label(egui::RichText::new("三维力方向").color(text_dim).size(17.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.colored_label( + green, + egui::RichText::new("LIVE").color(green).size(12.0), + ); + }); + }); + ui.separator(); + + ui.vertical_centered(|ui| { + let gauge_size = (panel_height - 52.0).min(panel_width - 24.0).max(168.0); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(gauge_size, gauge_size), + egui::Sense::hover(), + ); + paint_spatial_force_gauge(ui.painter(), rect, force, alpha); + }); + }); + }); +} + fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option { let mut changed_to = None; ui.horizontal_wrapped(|ui| { @@ -430,7 +507,7 @@ fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option { - // Legacy module-address controls for older devices: - // ui.horizontal(|ui| { - // ui.label(style::field_label("模块地址")); - // ui.add_sized( - // egui::vec2(80.0, METRICS.field_height), - // egui::DragValue::new(&mut config.module_addr).range(1..=247), - // ); - // ui.add_space(16.0); - // if ui.add(tag_button("读取信息")).clicked() {} - // if ui.add(tag_button("探测")).clicked() {} - // }); - draw_status_bytes_row(ui, conn_state, stats); - } - SerialMode::Hand => { - // Legacy manual-TX controls for older hand-module debugging: - // ui.horizontal(|ui| { - // ui.label(style::field_label("发送")); - // ui.add_sized( - // egui::vec2(300.0, METRICS.field_height), - // egui::TextEdit::singleline(&mut config.manual_tx), - // ); - // if ui.add(tag_button("发送")).clicked() {} - // if ui.add(tag_button("清空")).clicked() { - // config.manual_tx.clear(); - // } - // }); - draw_status_bytes_row(ui, conn_state, stats); - } // SerialMode::Model => { - // ui.horizontal(|ui| { - // ui.label(style::field_label("模型")); - // ui.add_sized( - // egui::vec2(300.0, METRICS.field_height), - // egui::TextEdit::singleline(&mut config.model_path), - // ); - // if ui.add(tag_button("加载")).clicked() {} - // if ui.add(tag_button("运行")).clicked() {} - // }); - // } + let _ = (config.mode, conn_state, stats); + draw_realtime_rate_row(ui, sample_rate_hz, render_rate_hz); +} + +fn draw_realtime_rate_row(ui: &mut egui::Ui, sample_rate_hz: f32, render_rate_hz: f32) { + group_frame().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.colored_label(dim_text(), "实时采样率"); + ui.label(style::value_text(format!("{sample_rate_hz:.1} Hz"))); + ui.add_space(18.0); + ui.colored_label(dim_text(), "画面刷新率"); + ui.label(style::value_text(format!("{render_rate_hz:.1} Hz"))); + }); }); } @@ -1015,9 +1067,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: .ctx() .animate_bool(egui::Id::new("resultant_force_chart_enter"), true); let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65; - let tick_phase = (time / FORCE_CHART_SAMPLE_SECONDS).fract(); - let scroll_offset = -tick_phase * chart_rect.width() / CHART_POINTS.max(1) as f32; - let plot_rect = chart_rect.translate(egui::vec2(slide_offset + scroll_offset, 0.0)); + let plot_rect = chart_rect.translate(egui::vec2(slide_offset, 0.0)); let mut points = Vec::with_capacity(values.len()); let start_slot = CHART_POINTS.saturating_sub(values.len()); @@ -1144,6 +1194,126 @@ fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 { egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha) } +fn faded_panel_frame(ctx: &egui::Context, alpha: u8) -> egui::Frame { + let style = ctx.global_style(); + egui::Frame::window(&style) + .fill(color_alpha(ONE_DARK_PRO.panel, alpha)) + .stroke(egui::Stroke::new( + 1.0, + color_alpha(ONE_DARK_PRO.border, alpha), + )) + .corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius)) + .inner_margin(egui::Margin::same(METRICS.panel_padding)) + .shadow(egui::epaint::Shadow { + offset: [0, 14], + blur: 28, + spread: 0, + color: egui::Color32::from_black_alpha((alpha as f32 * 0.45) as u8), + }) +} + +fn paint_spatial_force_gauge( + painter: &egui::Painter, + rect: egui::Rect, + force: HudSpatialForce, + alpha: u8, +) { + let center = rect.center(); + let radius = rect.width().min(rect.height()) * 0.42; + let angle = force.angle_deg.to_radians(); + let direction = egui::vec2(angle.cos(), angle.sin()); + let magnitude = force.magnitude.clamp(0.0, 1.0); + let end = center + direction * (radius * (0.35 + magnitude * 0.65)); + let side = egui::vec2(-direction.y, direction.x); + + painter.circle_stroke( + center, + radius, + egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, alpha)), + ); + painter.circle_stroke( + center, + radius * 0.55, + egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 2)), + ); + painter.line_segment( + [ + center + egui::vec2(-radius, 0.0), + center + egui::vec2(radius, 0.0), + ], + egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 3)), + ); + painter.line_segment( + [ + center + egui::vec2(0.0, -radius), + center + egui::vec2(0.0, radius), + ], + egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 3)), + ); + painter.circle_filled( + end, + 12.0, + color_alpha(ACCENT_ORANGE, (alpha as f32 * 0.16) as u8), + ); + painter.line_segment( + [center, end], + egui::Stroke::new(4.0, color_alpha(ACCENT_ORANGE, alpha)), + ); + painter.line_segment( + [end, end - direction * 13.0 + side * 7.0], + egui::Stroke::new(3.0, color_alpha(ACCENT_ORANGE, alpha)), + ); + painter.line_segment( + [end, end - direction * 13.0 - side * 7.0], + egui::Stroke::new(3.0, color_alpha(ACCENT_ORANGE, alpha)), + ); + painter.circle_filled(center, 4.5, color_alpha(ONE_DARK_PRO.text_dim, alpha)); + painter.circle_filled(end, 5.5, color_alpha(ACCENT_GREEN, alpha)); + painter.circle_filled(end, 2.2, color_alpha(egui::Color32::WHITE, alpha)); +} + +fn draw_pinned_panel( + ctx: &egui::Context, + panel: &mut FloatingPanelState, + title: &'static str, + id: &'static str, + preferred_width: f32, + min_width: f32, + add_contents: impl FnOnce(&mut egui::Ui), +) { + let panel_width = responsive_panel_width(ctx, preferred_width, min_width); + let max_height = responsive_panel_height(ctx, ctx.content_rect().height(), 180.0); + let pos = responsive_panel_pos(ctx, panel.default_pos, panel_width); + + egui::Window::new(title) + .id(egui::Id::new(id)) + .current_pos(pos) + .max_width(panel_width) + .max_height(max_height) + .constrain_to(viewport_panel_rect(ctx)) + .title_bar(false) + .resizable(false) + .frame(panel_frame(ctx)) + .show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + ui.horizontal(|ui| { + ui.label(style::panel_title(title)); + }); + ui.separator(); + let content_max_height = (max_height - 60.0).max(120.0); + egui::ScrollArea::vertical() + .max_height(content_max_height) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + add_contents(ui); + }); + }); +} + fn draw_floating_panel( ctx: &egui::Context, panel: &mut FloatingPanelState,