use std::cell::RefCell;
use crate::compose::app_shell::{AppShell, AppShellEvent, AppShellLayout};
use crate::compose::bottom_panel::{BottomPanelController, BottomPanelEvent};
use crate::event::{Point, Rect};
use crate::runner::{AppLogic, Reaction};
use crate::shell::{ShellApp, ShellConfig, ShellContext};
use crate::types::WidgetId;
use crate::{ActivityBarEvent, Backend, MouseButton, UiEvent};
pub(crate) struct ShellAdapter<A: ShellApp> {
pub(crate) app: A,
pub(crate) shell: AppShell,
pub(crate) _last_layout: Option<AppShellLayout>,
pub(crate) active_panel_id: Option<WidgetId>,
pub(crate) bottom_panel: Option<RefCell<BottomPanelController>>,
pub(crate) editor_font: Option<(String, f32)>,
}
impl<A: ShellApp> ShellAdapter<A> {
pub(crate) fn new(
app: A,
shell: AppShell,
active_panel_id: Option<WidgetId>,
bottom_panel: Option<RefCell<BottomPanelController>>,
editor_font: Option<(String, f32)>,
) -> Self {
Self {
app,
shell,
_last_layout: None,
active_panel_id,
bottom_panel,
editor_font,
}
}
fn apply_requested_panel(&mut self, backend: &mut dyn Backend, area: Rect) -> bool {
let Some(panel_id) = self.app.take_requested_panel() else {
return false;
};
self.shell.show_panel(&panel_id);
self.active_panel_id = Some(panel_id.clone());
self.notify_shell_event(backend, area, &AppShellEvent::PanelChanged { panel_id });
true
}
fn notify_shell_event(
&mut self,
backend: &mut dyn Backend,
area: Rect,
ev: &AppShellEvent,
) -> Reaction {
let layout = self.shell.layout(area, backend.line_height());
let sidebar_visible = self.shell.sidebar_visible();
let ctx = ShellContext::new(
self.active_panel_id.as_ref(),
sidebar_visible,
&layout,
&mut self.shell,
);
self.app.on_shell_event_ctx(ev, &ctx);
Reaction::Redraw
}
}
pub(crate) fn build_shell_adapter<A: ShellApp + 'static>(
app: A,
config: ShellConfig,
) -> ShellAdapter<A> {
let editor_font = config.editor_font.clone();
let mut shell = AppShell::new(config.panels, config.default_sidebar_width)
.with_bottom_items(config.bottom_items)
.with_min_width(config.min_sidebar_width)
.with_max_width(config.max_sidebar_width)
.with_activity_bar_width(config.activity_bar_width)
.with_position(config.position)
.with_bottom_bands(config.bottom_bands);
for (id, icon) in config.panel_icons {
shell = shell.with_panel_icon(id, icon);
}
if let Some(width_px) = config.activity_bar_width_px {
shell = shell.with_activity_bar_width_px(width_px);
}
shell = shell.with_title_bar(config.title_bar_height_lh);
if let Some(min_px) = config.title_bar_min_px {
shell = shell.with_title_bar_min_px(min_px);
}
shell.set_title_bar_visible(config.has_title_bar);
if config.has_bottom_panel {
shell = shell
.with_bottom_panel(config.bottom_panel_height_lh)
.with_bottom_panel_limits(
config.min_bottom_panel_height_lh,
config.max_bottom_panel_height_lh,
);
}
if config.has_command_line {
shell = shell.with_command_line();
}
if config.has_status_bar {
shell = shell.with_status_bar();
}
let bottom_panel = if let Some(bp_config) = config.bottom_panel {
shell = shell
.with_bottom_panel(10.0)
.with_bottom_panel_limits(3.0, 40.0);
Some(RefCell::new(BottomPanelController::new(bp_config)))
} else {
None
};
let active_panel_id = shell.active_panel_id().cloned();
ShellAdapter::new(app, shell, active_panel_id, bottom_panel, editor_font)
}
impl<A: ShellApp> AppLogic for ShellAdapter<A> {
type AreaId = ();
fn setup(&mut self, backend: &mut dyn Backend) {
if let Some((family, size_pt)) = &self.editor_font {
backend.set_editor_font(family, *size_pt);
}
if let Some(ref ctrl_cell) = self.bottom_panel {
let ctrl = ctrl_cell.borrow();
let viewport = backend.viewport();
let lh = backend.line_height().max(1.0);
let panel_lh = (viewport.height / lh * ctrl.height_fraction).clamp(3.0, 30.0);
drop(ctrl);
self.shell.set_bottom_panel_height(panel_lh);
}
self.app.setup(backend);
}
fn render(&self, backend: &mut dyn Backend, _area: ()) {
let viewport = backend.viewport();
let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);
let layout = self.shell.render(backend, area);
if let Some(ref ctrl_cell) = self.bottom_panel {
let maximised = ctrl_cell.borrow().maximised;
if maximised {
let main = layout.main_content_bounds;
let bp_h = layout.bottom_panel_bounds.map(|r| r.height).unwrap_or(0.0);
let full_panel = Rect::new(main.x, main.y, main.width, main.height + bp_h);
ctrl_cell.borrow_mut().render(backend, full_panel);
let maximised_layout = AppShellLayout {
main_content_bounds: Rect::new(
main.x,
main.y + main.height + bp_h,
main.width,
0.0,
),
bottom_panel_bounds: Some(full_panel),
..layout
};
self.app.render_content(backend, &maximised_layout);
} else if let Some(panel_bounds) = layout.bottom_panel_bounds {
ctrl_cell.borrow_mut().render(backend, panel_bounds);
self.app.render_content(backend, &layout);
} else {
self.app.render_content(backend, &layout);
}
} else {
self.app.render_content(backend, &layout);
}
for (id, bounds) in self.shell.bottom_band_bounds() {
self.app.render_bottom_band(backend, &id, bounds);
}
}
fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
let viewport = backend.viewport();
let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);
if matches!(event, UiEvent::WindowResized { .. }) {
if let Some(ref ctrl_cell) = self.bottom_panel {
let height_fraction = ctrl_cell.borrow().height_fraction;
let lh = backend.line_height().max(1.0);
let panel_lh = (viewport.height / lh * height_fraction).clamp(3.0, 30.0);
self.shell.set_bottom_panel_height(panel_lh);
}
}
if let Some(position) = mouse_event_position(&event) {
let modal_hit = {
let stack_rc = backend.modal_stack_handle();
let stack = stack_rc.borrow();
stack.hit_test(position).is_some()
};
if modal_hit {
let layout = self.shell.layout(area, backend.line_height());
let sidebar_visible = self.shell.sidebar_visible();
let ctx = ShellContext::new(
self.active_panel_id.as_ref(),
sidebar_visible,
&layout,
&mut self.shell,
);
return self.app.handle(event, backend, &ctx);
}
}
let shell_ev = self.shell.handle(&event, backend, area);
match &shell_ev {
AppShellEvent::PanelChanged { panel_id } => {
self.active_panel_id = Some(panel_id.clone());
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::SidebarHidden => {
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::SidebarResized { .. } => {
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::BottomPanelResized { new_height } => {
if let Some(ref ctrl_cell) = self.bottom_panel {
let new_fraction = resized_to_fraction(*new_height, viewport.height);
ctrl_cell.borrow_mut().height_fraction = new_fraction;
let ev = BottomPanelEvent::Resized(*new_height);
self.app.on_bottom_panel_event(&ev);
}
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::BottomPanelHidden => {
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::BottomItemClicked { .. } => {
return self.notify_shell_event(backend, area, &shell_ev);
}
AppShellEvent::Consumed => return Reaction::Redraw,
AppShellEvent::Ignored => {}
}
if let UiEvent::ActivityBar(_, ActivityBarEvent::KeyPressed { ref key, .. }) = event {
return match key.as_str() {
"j" | "Down" => {
self.shell.activity_select_next();
Reaction::Redraw
}
"k" | "Up" => {
self.shell.activity_select_prev();
Reaction::Redraw
}
"l" | "Enter" | " " | "Space" => {
if let Some(ev) = self.shell.activity_activate_selected() {
self.shell.set_activity_keyboard_focused(false);
if let AppShellEvent::PanelChanged { ref panel_id } = ev {
self.active_panel_id = Some(panel_id.clone());
}
self.notify_shell_event(backend, area, &ev);
}
Reaction::Redraw
}
"Escape" | "h" | "Left" => {
self.shell.set_activity_keyboard_focused(false);
Reaction::Redraw
}
_ => Reaction::Continue,
};
}
if let Some(ref ctrl_cell) = self.bottom_panel {
if let UiEvent::MouseDown {
button: MouseButton::Left,
position,
..
} = &event
{
let bp_ev = ctrl_cell.borrow_mut().handle_click(position.x, position.y);
if let Some(bp_ev) = bp_ev {
if matches!(bp_ev, BottomPanelEvent::TabClosed(_))
&& ctrl_cell.borrow().tabs().is_empty()
{
self.shell.hide_bottom_panel();
}
self.app.on_bottom_panel_event(&bp_ev);
return Reaction::Redraw;
}
}
}
let layout = self.shell.layout(area, backend.line_height());
let sidebar_visible = self.shell.sidebar_visible();
let ctx = ShellContext::new(
self.active_panel_id.as_ref(),
sidebar_visible,
&layout,
&mut self.shell,
);
let mut reaction = self.app.handle(event, backend, &ctx);
if ctx.take_activity_focus_requested() {
self.shell.set_activity_keyboard_focused(true);
self.shell.activity_set_cursor(0);
if reaction == Reaction::Continue {
reaction = Reaction::Redraw;
}
}
if self.apply_requested_panel(backend, area) {
reaction = Reaction::Redraw;
}
reaction
}
fn tick(&mut self, backend: &mut dyn Backend) -> Reaction {
let mut reaction = self.app.tick(backend);
let viewport = backend.viewport();
let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);
if self.apply_requested_panel(backend, area) && reaction == Reaction::Continue {
reaction = Reaction::Redraw;
}
reaction
}
}
fn mouse_event_position(event: &UiEvent) -> Option<Point> {
match event {
UiEvent::MouseDown { position, .. } => Some(*position),
UiEvent::MouseUp { position, .. } => Some(*position),
_ => None,
}
}
fn resized_to_fraction(new_height: f32, viewport_height: f32) -> f32 {
let h = viewport_height.max(1.0);
(new_height / h).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compose::app_shell::AppShellLayout;
struct NoopApp;
impl ShellApp for NoopApp {
fn render_content(&self, _backend: &mut dyn Backend, _layout: &AppShellLayout) {}
fn handle(
&mut self,
_event: UiEvent,
_backend: &mut dyn Backend,
_ctx: &ShellContext,
) -> Reaction {
Reaction::Continue
}
}
#[test]
fn title_bar_height_lh_survives_start_hidden_then_shown() {
let mut config = ShellConfig::new("t", Vec::new());
config.has_title_bar = false;
config.title_bar_height_lh = 1.0;
let mut adapter = build_shell_adapter(NoopApp, config);
assert!(!adapter.shell.title_bar_visible());
adapter.shell.set_title_bar_visible(true);
let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 1.0);
let tb = layout.title_bar_bounds.expect("row now reserved");
assert_eq!(
tb.height, 1.0,
"configured height (1.0 lh) must be honoured, not the 1.5 lh AppShell default"
);
}
#[test]
fn title_bar_height_lh_honoured_when_started_visible() {
let mut config = ShellConfig::new("t", Vec::new());
config.has_title_bar = true;
config.title_bar_height_lh = 1.0;
let adapter = build_shell_adapter(NoopApp, config);
assert!(adapter.shell.title_bar_visible());
let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 1.0);
let tb = layout
.title_bar_bounds
.expect("row reserved from construction");
assert_eq!(tb.height, 1.0);
}
#[test]
fn title_bar_min_px_reaches_the_built_app_shell() {
let config = ShellConfig::new("t", Vec::new())
.with_title_bar(1.5)
.with_title_bar_min_px(34.0);
let adapter = build_shell_adapter(NoopApp, config);
let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 6.0);
let tb = layout.title_bar_bounds.expect("row reserved");
assert_eq!(tb.height, 34.0);
}
#[test]
fn build_shell_adapter_replays_panel_icon_overrides() {
use crate::compose::app_shell::PanelDefinition;
use crate::types::Icon;
let panel_id = WidgetId::new("panel:explorer");
let bottom_id = WidgetId::new("panel:settings");
let config = ShellConfig::new(
"t",
vec![PanelDefinition {
id: panel_id.clone(),
icon: "X".to_string(),
tooltip: "Explorer".to_string(),
title: "EXPLORER".to_string(),
}],
)
.with_bottom_items(vec![PanelDefinition {
id: bottom_id.clone(),
icon: "Y".to_string(),
tooltip: "Settings".to_string(),
title: "SETTINGS".to_string(),
}])
.with_panel_icon(panel_id, Icon::new("\u{f07c}", "\u{25c6}"))
.with_panel_icon(bottom_id, Icon::new("\u{f013}", "\u{25a0}"));
let adapter = build_shell_adapter(NoopApp, config);
let bar = adapter.shell.build_activity_bar();
assert_eq!(
bar.top_items[0].icon,
Icon::new("\u{f07c}", "\u{25c6}"),
"top-panel override must reach the built AppShell, not just ShellConfig"
);
assert_eq!(
bar.bottom_items[0].icon,
Icon::new("\u{f013}", "\u{25a0}"),
"bottom-item override must reach the built AppShell too (#683 parity)"
);
}
#[test]
fn build_shell_adapter_replays_bottom_bands() {
use crate::compose::app_shell::BottomBand;
let band_id = WidgetId::new("band:qf");
let config = ShellConfig::new("t", Vec::new())
.with_bottom_bands(vec![BottomBand::new(band_id.clone(), 3.0)]);
let adapter = build_shell_adapter(NoopApp, config);
assert_eq!(adapter.shell.bottom_bands().len(), 1);
assert_eq!(adapter.shell.bottom_bands()[0].id, band_id);
assert_eq!(adapter.shell.bottom_bands()[0].height_lh, 3.0);
}
#[test]
fn resized_to_fraction_recovers_proportion() {
assert!((resized_to_fraction(18.0, 60.0) - 0.30).abs() < 1e-5);
assert!((resized_to_fraction(36.0, 60.0) - 0.60).abs() < 1e-5);
}
#[test]
fn resized_to_fraction_clamps_negative_to_zero() {
assert_eq!(resized_to_fraction(-5.0, 60.0), 0.0);
}
#[test]
fn resized_to_fraction_clamps_over_full_viewport_to_one() {
assert_eq!(resized_to_fraction(120.0, 60.0), 1.0);
}
#[test]
fn resized_to_fraction_handles_zero_viewport_safely() {
let f = resized_to_fraction(10.0, 0.0);
assert!(f.is_finite());
}
#[test]
fn drag_then_window_resize_preserves_user_intent() {
let mut height_fraction = 0.30_f32;
let viewport_h_initial = 100.0_f32;
let initial_panel = viewport_h_initial * height_fraction;
assert!((initial_panel - 30.0).abs() < 1e-4);
let dragged_native = 60.0_f32;
height_fraction = resized_to_fraction(dragged_native, viewport_h_initial);
assert!((height_fraction - 0.60).abs() < 1e-5);
let viewport_h_resized = 50.0_f32;
let recalculated_panel = viewport_h_resized * height_fraction;
assert!(
(recalculated_panel - 30.0).abs() < 1e-4,
"post-resize panel height ({}) should reflect dragged proportion (0.60), \
not initial 0.30 (which would give {})",
recalculated_panel,
viewport_h_resized * 0.30,
);
}
}