#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ftui_core::event::{Event, KeyCode, KeyEvent, KeyEventKind, MouseEventKind};
use ftui_core::geometry::Rect;
use ftui_layout::{
PANE_TREE_SCHEMA_VERSION, PaneCommandEffect, PaneId, PaneLayout, PaneLeaf, PaneNodeKind,
PaneNodeRecord, PaneOperation, PanePlacement, PanePressureSnapProfile, PaneSplit,
PaneSplitRatio, PaneTree, PaneTreeSnapshot, SplitAxis,
};
use ftui_render::frame::Frame;
use ftui_runtime::pane_keymap::{PaneKeyOutcome, PaneKeyboardController};
use ftui_runtime::{
App, Cmd, Every, Model, PaneTerminalAdapter, PaneTerminalAdapterConfig, PaneTerminalDispatch,
PaneTerminalLifecyclePhase, ScreenMode, Subscription, UiAnchor, pane_terminal_splitter_handles,
};
use ftui_widgets::Widget;
use ftui_widgets::block::Block;
use ftui_widgets::borders::Borders;
const ROOT: u64 = 1;
const LEFT: u64 = 2;
const RIGHT: u64 = 3;
const HIT_THICKNESS: u16 = 7;
const FIXED_PRESSURE: PanePressureSnapProfile = PanePressureSnapProfile {
strength_bps: 5_000,
hysteresis_bps: 100,
};
fn pane_id(raw: u64) -> PaneId {
PaneId::new(raw).expect("non-zero pane id")
}
fn root_split_tree(axis: SplitAxis) -> PaneTree {
let snapshot = PaneTreeSnapshot {
schema_version: PANE_TREE_SCHEMA_VERSION,
root: pane_id(ROOT),
next_id: pane_id(4),
nodes: vec![
PaneNodeRecord::split(
pane_id(ROOT),
None,
PaneSplit {
axis,
ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
first: pane_id(LEFT),
second: pane_id(RIGHT),
},
),
PaneNodeRecord::leaf(pane_id(LEFT), Some(pane_id(ROOT)), PaneLeaf::new("left")),
PaneNodeRecord::leaf(pane_id(RIGHT), Some(pane_id(ROOT)), PaneLeaf::new("right")),
],
extensions: BTreeMap::new(),
};
PaneTree::from_snapshot(snapshot).expect("valid root split tree")
}
fn root_first_share_bps(tree: &PaneTree) -> Option<u32> {
match &tree.node(pane_id(ROOT))?.kind {
PaneNodeKind::Split(node) => Some(
node.ratio.numerator() * 10_000 / (node.ratio.numerator() + node.ratio.denominator()),
),
PaneNodeKind::Leaf(_) => None,
}
}
fn root_first_leaf_name(tree: &PaneTree) -> String {
let Some(root) = tree.node(pane_id(ROOT)) else {
return "-".to_string();
};
let PaneNodeKind::Split(split) = &root.kind else {
return "-".to_string();
};
match tree.node(split.first).map(|node| &node.kind) {
Some(PaneNodeKind::Leaf(leaf)) => leaf.surface_key.clone(),
_ => "-".to_string(),
}
}
fn leaf_name(tree: &PaneTree, id: PaneId) -> String {
match tree.node(id).map(|node| &node.kind) {
Some(PaneNodeKind::Leaf(leaf)) => leaf.surface_key.clone(),
_ => "-".to_string(),
}
}
#[derive(Clone)]
struct Shared {
mode: String,
area: Rect,
initial_bps: u32,
final_bps: u32,
applied_ops: u64,
down_resolved: bool,
committed: bool,
canceled: bool,
tree_valid: bool,
node_count: usize,
first_leaf: String,
active_pane: String,
maximized: bool,
}
struct Harness {
tree: PaneTree,
adapter: PaneTerminalAdapter,
keyboard: Option<PaneKeyboardController>,
op_seed: u64,
ticks_remaining: u32,
shared: Arc<Mutex<Shared>>,
}
enum Msg {
Input(Event),
Tick,
Quit,
}
impl From<Event> for Msg {
fn from(event: Event) -> Self {
match event {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}) => Msg::Quit,
other => Msg::Input(other),
}
}
}
impl Harness {
fn with_shared<R>(&self, f: impl FnOnce(&mut Shared) -> R) -> Option<R> {
self.shared.lock().ok().map(|mut guard| f(&mut guard))
}
fn handle_event(&mut self, event: &Event) -> Cmd<Msg> {
let area = self
.with_shared(|s| s.area)
.unwrap_or_else(|| Rect::new(0, 0, 80, 24));
let Ok(layout) = self.tree.solve_layout(area) else {
return Cmd::none();
};
if let Some(mut keyboard) = self.keyboard.take() {
if let Event::Key(key) = event
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
let out = keyboard.handle_key(key, &mut self.tree, &layout);
let applied = match &out {
PaneKeyOutcome::Handled { resolution, .. } => match &resolution.effect {
PaneCommandEffect::Structural(ops) => {
u64::try_from(ops.len()).unwrap_or_default()
}
_ => 0,
},
_ => 0,
};
let active = keyboard
.active()
.map_or_else(|| "-".to_string(), |id| leaf_name(&self.tree, id));
let maximized = keyboard.maximized().is_some();
if applied > 0 {
self.with_shared(|s| s.applied_ops += applied);
}
self.with_shared(|s| {
s.active_pane = active;
s.maximized = maximized;
});
self.record_state(false);
}
self.keyboard = Some(keyboard);
return Cmd::none();
}
let handles = pane_terminal_splitter_handles(&self.tree, &layout, HIT_THICKNESS);
match event {
Event::Mouse(mouse) => {
let dispatch = self.adapter.translate_with_handles(event, &handles);
if matches!(mouse.kind, MouseEventKind::Down(_))
&& self.adapter.active_pointer_id().is_some()
{
self.with_shared(|s| s.down_resolved = true);
}
self.apply_dispatch(&dispatch, &layout);
let committed = matches!(mouse.kind, MouseEventKind::Up(_))
&& dispatch.primary_transition.is_some();
self.record_state(committed);
if committed {
Cmd::quit()
} else {
Cmd::none()
}
}
Event::Key(key) => {
if let Some(op) = affordance_operation(key) {
if self.tree.apply_operation(self.op_seed, op).is_ok() {
self.op_seed += 1;
self.with_shared(|s| s.applied_ops += 1);
}
self.record_state(false);
return Cmd::quit();
}
let target = handles.first().map(|handle| handle.target);
let dispatch = self.adapter.translate(event, target);
let canceled = dispatch.log.phase == PaneTerminalLifecyclePhase::KeyCancel;
self.apply_dispatch(&dispatch, &layout);
if canceled {
self.with_shared(|s| s.canceled = true);
}
self.record_state(false);
if canceled {
Cmd::quit()
} else {
Cmd::none()
}
}
_ => Cmd::none(),
}
}
fn apply_dispatch(&mut self, dispatch: &PaneTerminalDispatch, layout: &PaneLayout) {
let Some(transition) = dispatch.primary_transition.as_ref() else {
return;
};
let ops = self
.tree
.operations_for_transition(transition, layout, FIXED_PRESSURE);
for op in ops {
if self.tree.apply_operation(self.op_seed, op).is_ok() {
self.op_seed += 1;
self.with_shared(|s| s.applied_ops += 1);
}
}
}
fn record_state(&mut self, committed: bool) {
let bps = root_first_share_bps(&self.tree);
let node_count = self.tree.nodes().count();
let first_leaf = root_first_leaf_name(&self.tree);
let valid = self.tree.validate().is_ok();
self.with_shared(|s| {
if let Some(bps) = bps {
s.final_bps = bps;
}
s.node_count = node_count;
s.first_leaf = first_leaf;
s.tree_valid = valid;
if committed {
s.committed = true;
}
});
}
}
fn affordance_operation(key: &KeyEvent) -> Option<PaneOperation> {
if key.kind == KeyEventKind::Release {
return None;
}
let KeyCode::Char(c) = key.code else {
return None;
};
match c {
's' => Some(PaneOperation::SplitLeaf {
target: pane_id(LEFT),
axis: SplitAxis::Vertical,
ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
placement: PanePlacement::ExistingFirst,
new_leaf: PaneLeaf::new("split"),
}),
'c' => Some(PaneOperation::CloseNode {
target: pane_id(RIGHT),
}),
'w' => Some(PaneOperation::SwapNodes {
first: pane_id(LEFT),
second: pane_id(RIGHT),
}),
_ => None,
}
}
impl Model for Harness {
type Message = Msg;
fn update(&mut self, msg: Msg) -> Cmd<Msg> {
match msg {
Msg::Quit => Cmd::quit(),
Msg::Tick => {
self.ticks_remaining = self.ticks_remaining.saturating_sub(1);
if self.ticks_remaining == 0 {
Cmd::quit()
} else {
Cmd::none()
}
}
Msg::Input(event) => self.handle_event(&event),
}
}
fn view(&self, frame: &mut Frame) {
let area = Rect::from_size(frame.buffer.width(), frame.buffer.height());
self.with_shared(|s| s.area = area);
let Ok(layout) = self.tree.solve_layout(area) else {
return;
};
for record in self.tree.nodes() {
if let PaneNodeKind::Leaf(leaf) = &record.kind
&& let Some(rect) = layout.rect(record.id)
{
Block::new()
.borders(Borders::ALL)
.title(leaf.surface_key.as_str())
.render(rect, frame);
}
}
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Msg>>> {
vec![Box::new(Every::new(Duration::from_millis(100), || {
Msg::Tick
}))]
}
}
fn env_u16(key: &str, default: u16) -> u16 {
std::env::var(key)
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or(default)
}
fn main() -> std::io::Result<()> {
let mode = std::env::var("PANE_HARNESS_SCREEN_MODE").unwrap_or_else(|_| "alt".to_string());
let axis = match std::env::var("PANE_HARNESS_AXIS").as_deref() {
Ok("vertical") => SplitAxis::Vertical,
_ => SplitAxis::Horizontal,
};
let ui_height = env_u16("PANE_HARNESS_UI_HEIGHT", 12).max(4);
let exit_after_ms = u32::from(env_u16("PANE_HARNESS_EXIT_AFTER_MS", 4_000)).max(200);
let keymap_mode = std::env::var("PANE_HARNESS_INPUT").as_deref() == Ok("keymap");
let (screen_mode, anchor) = if mode == "inline" {
(ScreenMode::Inline { ui_height }, UiAnchor::Top)
} else {
(ScreenMode::AltScreen, UiAnchor::Top)
};
let tree = root_split_tree(axis);
let initial_bps = root_first_share_bps(&tree).expect("root split has a first share");
let node_count = tree.nodes().count();
let first_leaf = root_first_leaf_name(&tree);
let keyboard = keymap_mode.then(|| PaneKeyboardController::new(Some(pane_id(LEFT))));
let initial_active = keyboard
.as_ref()
.and_then(PaneKeyboardController::active)
.map_or_else(|| "-".to_string(), |id| leaf_name(&tree, id));
let shared = Arc::new(Mutex::new(Shared {
mode: mode.clone(),
area: Rect::new(0, 0, 80, 24),
initial_bps,
final_bps: initial_bps,
applied_ops: 0,
down_resolved: false,
committed: false,
canceled: false,
tree_valid: true,
node_count,
first_leaf,
active_pane: initial_active,
maximized: false,
}));
let adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
.expect("valid pane terminal adapter config");
let model = Harness {
tree,
adapter,
keyboard,
op_seed: 1,
ticks_remaining: exit_after_ms.div_ceil(100).max(1),
shared: Arc::clone(&shared),
};
let run_result = App::new(model)
.screen_mode(screen_mode)
.anchor(anchor)
.with_mouse()
.run();
let snap = shared.lock().expect("shared state lock").clone();
println!(
"PANE_RESULT mode={} initial_bps={} final_bps={} applied_ops={} down_resolved={} committed={} tree_valid={} node_count={} first_leaf={} canceled={} active_pane={} maximized={}",
snap.mode,
snap.initial_bps,
snap.final_bps,
snap.applied_ops,
snap.down_resolved,
snap.committed,
snap.tree_valid,
snap.node_count,
snap.first_leaf,
snap.canceled,
snap.active_pane,
snap.maximized,
);
let _ = std::io::stdout().flush();
run_result
}