use std::error::Error;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use densa_core::Point;
use densa_grid::{Align as Side, CellStyle, CellText, Column, GridModel, Palette, SortOrder};
use densa_ui::{
Action, ActionKind, Align, PointerButton, PointerEvent, PointerKind, Style, Theme, Ui, WidgetId,
};
use densa_widgets::{
DataGrid, DockArea, DockAt, DockLayout, DockPanel, DockSide, Label, Panel, ScrollView,
};
use densa_window::{App, Host, WindowOptions};
const NAMES: [&str; 12] = [
"AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "AVGO", "ASML", "AMD", "INTC", "ORCL",
];
const INSTRUMENTS: usize = 2_000;
const TICKS_PER_FRAME: usize = 20;
const FLASH: f32 = 0.3;
struct Workspace {
light: bool,
demo: bool,
close_at: Option<Instant>,
started: Instant,
dock: Option<WidgetId>,
status: Option<WidgetId>,
watchlist: Option<WidgetId>,
layout_file: Option<PathBuf>,
}
struct Quote {
symbol: String,
open: f64,
last: f64,
volume: u64,
changed: (f32, bool),
}
struct Market {
columns: Vec<Column>,
quotes: Vec<Quote>,
seed: u64,
now: f32,
}
impl Market {
fn new() -> Self {
let quotes = (0..INSTRUMENTS)
.map(|i| {
let name = NAMES[i % NAMES.len()];
let open = 20.0 + (i * 37 % 900) as f64 + (i * 13 % 100) as f64 / 100.0;
Quote {
symbol: format!("{name}.{:04}", i / NAMES.len()),
open,
last: open,
volume: (i as u64 * 7_919) % 90_000 + 1_000,
changed: (f32::NEG_INFINITY, true),
}
})
.collect();
Self {
columns: vec![
Column::new("Symbol", 10, Side::Left),
Column::new("Last", 9, Side::Right),
Column::new("Chg %", 7, Side::Right),
Column::new("Volume", 9, Side::Right),
Column::new("Open", 9, Side::Right),
],
quotes,
seed: 0x9E37_79B9_7F4A_7C15,
now: 0.0,
}
}
fn next(&mut self) -> u64 {
self.seed = self.seed.wrapping_add(0xa076_1d64_78bd_642f);
let t = u128::from(self.seed) * u128::from(self.seed ^ 0xe703_7ed1_a0b4_28db);
(t as u64) ^ ((t >> 64) as u64)
}
fn tick(&mut self, now: f32, count: usize) {
self.now = now;
for _ in 0..count {
let r = self.next();
let i = (r % self.quotes.len() as u64) as usize;
let up = r & (1 << 40) != 0;
let quote = &mut self.quotes[i];
let step = (quote.last * 0.0005).max(0.01);
quote.last = (quote.last + if up { step } else { -step }).max(0.01);
quote.volume += 1 + (r >> 50) % 500;
quote.changed = (now, up);
}
}
}
impl GridModel for Market {
fn columns(&self) -> &[Column] {
&self.columns
}
fn rows(&self) -> usize {
self.quotes.len()
}
fn cell(&self, row: usize, column: usize, text: &mut CellText, palette: &Palette) -> CellStyle {
let quote = &self.quotes[row];
let change = (quote.last / quote.open - 1.0) * 100.0;
let change = if change.abs() < 0.005 { 0.0 } else { change };
let direction = match change {
c if c > 0.0 => palette.positive,
c if c < 0.0 => palette.negative,
_ => palette.text,
};
let _ = match column {
0 => text.write_str("e.symbol),
1 => write!(text, "{:.2}", quote.last),
2 if change == 0.0 => text.write_str("0.00"),
2 => write!(text, "{change:+.2}"),
3 => write!(text, "{}", quote.volume),
_ => write!(text, "{:.2}", quote.open),
};
let (when, up) = quote.changed;
let lit = column == 1 && self.now - when < FLASH;
let flash = if up {
palette.positive
} else {
palette.negative
}
.with_alpha(0.35);
CellStyle::new(if column == 2 { direction } else { palette.text })
.with_background(lit.then_some(flash))
}
fn sort(&mut self, column: usize, order: SortOrder) -> bool {
let key = |q: &Quote| match column {
1 => q.last,
2 => q.last / q.open,
3 => q.volume as f64,
4 => q.open,
_ => 0.0,
};
if column == 0 {
self.quotes.sort_by(|a, b| a.symbol.cmp(&b.symbol));
} else {
self.quotes.sort_by(|a, b| key(a).total_cmp(&key(b)));
}
if order == SortOrder::Descending {
self.quotes.reverse();
}
true
}
}
fn list_panel(
ui: &mut Ui,
dock: WidgetId,
title: &str,
at: DockAt,
rows: impl Iterator<Item = String>,
) -> Result<WidgetId, Box<dyn Error>> {
let spec = DockPanel::new(title).with_key(title.to_lowercase());
let panel = DockArea::add_panel(ui, dock, Panel::new(), spec, at)?;
let list = ui.add(panel, ScrollView::new(), ScrollView::style().padding(6.0))?;
for row in rows {
ui.add(list, Label::new(row), Style::row().padding(3.0).shrink(0.0))?;
}
Ok(panel)
}
fn text_panel(
ui: &mut Ui,
dock: WidgetId,
title: &str,
at: DockAt,
text: &str,
) -> Result<WidgetId, Box<dyn Error>> {
let spec = DockPanel::new(title).with_key(title.to_lowercase());
let panel = DockArea::add_panel(ui, dock, Panel::new(), spec, at)?;
let body = Style::column().grow(1.0).padding(14.0).gap(8.0);
let body = ui.add(panel, Panel::transparent(), body)?;
ui.add(body, Label::new(title).with_size(18.0), Style::row())?;
ui.add(body, Label::new(text).wrapping().dim(), Style::row())?;
Ok(panel)
}
fn quote(index: usize) -> String {
let name = NAMES[index % NAMES.len()];
let price = 40.0 + (index * 37 % 500) as f64 + (index * 13 % 100) as f64 / 100.0;
let change = (index * 29 % 400) as f64 / 100.0 - 2.0;
format!("{name:<7}{price:>9.2}{change:>+7.2}%")
}
fn log_line(index: usize) -> String {
let (h, m, s) = (9 + index / 60 % 8, index % 60, index * 7 % 60);
format!("{h:02}:{m:02}:{s:02} event {index:>4} ok")
}
impl App for Workspace {
fn build(&mut self, ui: &mut Ui, _: &mut Host<'_>) -> Result<(), Box<dyn Error>> {
if self.light {
ui.set_theme(Theme::light());
}
let root = ui.root();
let dock = ui.add(root, DockArea::new(), DockArea::style().padding(0.0))?;
let spec = DockPanel::new("Watchlist").with_key("watchlist");
let watch = DockArea::add_panel(ui, dock, Panel::new(), spec, DockAt::First)?;
let grid = DataGrid::new(Market::new()).with_frozen_columns(1);
self.watchlist = Some(ui.add(watch, grid, DataGrid::<Market>::style())?);
let about = "Drag a tab along its bar to reorder, onto another group to join it, or \
near a group's edge to split the group. Drag the gaps between groups to \
resize them. Painting and dragging tabs allocate nothing.";
let details = text_panel(
ui,
dock,
"Details",
DockAt::Beside(watch, DockSide::Right),
about,
)?;
let below = DockAt::Beside(details, DockSide::Bottom);
let log = list_panel(ui, dock, "Log", below, (0..200).map(log_line))?;
text_panel(ui, dock, "Notes", DockAt::With(log), "Tabs share a group.")?;
list_panel(
ui,
dock,
"Screener",
DockAt::With(watch),
(12..40).map(quote),
)?;
ui.update::<DockArea, _>(dock, |area, cx| area.show(cx, watch));
let bar = Style::row()
.padding(6.0)
.shrink(0.0)
.align_items(Align::Center);
let bar = ui.add(root, Panel::new(), bar)?;
let status = ui.add(bar, Label::new("Ready").dim(), Style::row())?;
self.status = Some(status);
self.dock = Some(dock);
if let Some(text) = self.read_layout(ui) {
ui.update::<Label, _>(status, |label, cx| label.set_text(cx, text));
}
Ok(())
}
fn action(&mut self, ui: &mut Ui, _: &mut Host<'_>, action: Action) {
let (Some(dock), Some(status)) = (self.dock, self.status) else {
return;
};
if Some(action.source) == self.watchlist {
if let ActionKind::Selected(row) = action.kind {
let symbol = ui
.widget::<DataGrid<Market>>(action.source)
.and_then(|g| g.model().quotes.get(row))
.map(|q| q.symbol.clone())
.unwrap_or_default();
ui.update::<Label, _>(status, |label, cx| {
label.set_text(cx, format!("Selected {symbol}"))
});
}
return;
}
if action.source != dock {
return;
}
let groups = ui.widget::<DockArea>(dock).map_or(0, DockArea::group_count);
let Some(area) = ui.widget::<DockArea>(dock) else {
return;
};
let saved = self
.layout_file
.as_ref()
.zip(area.layout())
.map(|(file, layout)| match save_layout(file, &layout) {
Ok(()) => String::from(", saved"),
Err(error) => format!(", not saved: {error}"),
});
let text = match action.kind {
ActionKind::Selected(index) => {
let title = area.panels().nth(index).and_then(|p| area.title(p));
format!("Showing {}", title.unwrap_or("?"))
}
ActionKind::Changed => format!("Rearranged: {groups} groups"),
_ => return,
};
let text = text + saved.as_deref().unwrap_or("");
ui.update::<Label, _>(status, |label, cx| label.set_text(cx, text));
}
fn frame(&mut self, ui: &mut Ui, host: &mut Host<'_>) -> bool {
if let Some(grid) = self.watchlist {
let now = self.started.elapsed().as_secs_f32();
ui.update::<DataGrid<Market>, _>(grid, |grid, cx| {
grid.model_mut(cx).tick(now, TICKS_PER_FRAME);
});
}
if std::mem::take(&mut self.demo) {
if let Some((from, to)) = self.dock.and_then(|dock| demo_drag(ui, dock)) {
let press = PointerKind::Down(PointerButton::Primary);
ui.pointer(PointerEvent::new(press, from));
ui.pointer(PointerEvent::new(PointerKind::Move, to));
}
}
if self.close_at.is_some_and(|at| Instant::now() >= at) {
host.close();
return false;
}
true
}
}
impl Workspace {
fn read_layout(&mut self, ui: &mut Ui) -> Option<String> {
let file = self.layout_file.clone()?;
let restored = match std::fs::read_to_string(&file) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => Err(error.to_string()),
Ok(text) => text
.parse::<DockLayout>()
.map_err(|e| e.to_string())
.and_then(|layout| {
let dock = self.dock.ok_or("no dock")?;
DockArea::restore_layout(ui, dock, &layout).map_err(|e| e.to_string())
}),
};
Some(match restored {
Ok(()) => format!("Layout restored from {}", file.display()),
Err(error) => {
self.layout_file = None;
format!("Layout not restored ({error}); changes will not be saved")
}
})
}
}
fn save_layout(file: &Path, layout: &DockLayout) -> std::io::Result<()> {
let partial = file.with_extension("partial");
std::fs::write(&partial, layout.to_string())?;
std::fs::rename(&partial, file)
}
fn demo_drag(ui: &mut Ui, dock: WidgetId) -> Option<(Point, Point)> {
ui.layout();
let area = ui.widget::<DockArea>(dock)?;
let screener = area.panel_with_key("screener")?;
let tab = area.tab_rect(screener, ui.rect(dock)?, ui.scale())?;
let log = area.panel_with_key("log")?;
let shown = area.tabs_with(log).find(|&p| area.is_shown(p))?;
let group = ui.rect(shown)?;
let from = Point::new(
(tab.left() + tab.right()) / 2.0,
(tab.top() + tab.bottom()) / 2.0,
);
let to = Point::new(
group.right() - group.width() / 10.0,
(group.top() + group.bottom()) / 2.0,
);
Some((from, to))
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let light = args.iter().any(|a| a == "--light");
let demo = args.iter().any(|a| a == "--demo");
let seconds = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.map(|s| s.parse::<u64>())
.transpose()?;
let layout_file = match args.iter().position(|a| a == "--layout") {
Some(at) => match args.get(at + 1).filter(|a| !a.starts_with("--")) {
Some(file) => Some(PathBuf::from(file)),
None => return Err("--layout needs a file name".into()),
},
None => None,
};
let app = Workspace {
light,
demo,
close_at: seconds.map(|s| Instant::now() + Duration::from_secs(s)),
started: Instant::now(),
dock: None,
status: None,
watchlist: None,
layout_file,
};
densa_window::run(
WindowOptions::new("densa dock").with_size(1200.0, 760.0),
app,
)?;
Ok(())
}