Tabs: widgets and canvases
A tab sits in one of the window’s four areas, beside the core’s own (Player, Tracks, Palette). Its contents are rows of standard widgets that the core draws in the window’s own style, so it looks like the rest of OxiGuitar and follows the dark or light interface by itself, and, if it needs one, a canvas the plugin draws. Tabs need interface 1.1.
Declaring a tab
"tabs": [{ "id": "statistics", "title": "Statistics", "area": "right" }]
| Key | What it is |
|---|---|
id | The tab’s id in the plugin |
title | Its name on the area’s tab strip: a word or two |
area | top (across the window, above the page), bottom (across, below), left or right (beside the page, narrower) |
The window shows the tab’s title from the manifest alone. The plugin is loaded when the user first opens the tab.
Contents
pub trait Tab: Send + Sync {
fn content(&self) -> TabContent;
fn on(&self, widget: &str, event: TabEvent) -> Result<(), Error> { Ok(()) }
}
content says what the tab shows now: TabContent { rows }, rows from top to bottom, each a Row { widgets } from left to right. The core asks for it when the tab is shown, after every on, and after the plugin asks for a redraw. It runs on a thread of the core’s, never the window’s, but keep it quick: the tab shows its old contents until it answers.
| Widget | Shows | The user’s event (TabEvent) |
|---|---|---|
Label { text, muted } | Text; dimmer when muted | None |
Button { id, text, enabled } | A push button | Clicked |
Toggle { id, text, on, enabled } | A tickbox with its text | Toggled(bool) |
Slider { id, value, min, max, enabled } | A slider, min < max | Slid(f32), when it is let go |
Field { id, text, placeholder, enabled } | One line of text to type | Edited(String), when Enter is pressed |
Choice { id, options, selected, enabled } | A drop-down | Chosen(u32), the option’s index |
List { id, items, selected } | Lines, one of which may be selected | Chosen(u32), the line’s index |
Canvas(Canvas { id, width, height, items }) | A drawing of the plugin’s own | Pointer(Pointer) |
Every widget the user can use has an id, unique in the tab (a valid id: lowercase letters and digits joined by . or -). When the user uses one, the core calls on(id, event), then content again. The widgets hold no state of their own: a toggle shows on as your last content said, so on must remember what changed.
An error returned from on is shown in the footer, as a warning, and the tab keeps showing its contents (the core asks for them again). Return one when the plugin refuses the event; for something the user should just know, call host.notify and return Ok(()), as the template does when it can’t save a setting.
The template’s tab:
fn content(&self) -> TabContent {
self.refresh();
let s = self.state();
let label = |text: String, muted: bool| Widget::Label { text, muted };
if s.tracks.is_empty() {
return TabContent {
rows: vec![Row {
widgets: vec![label("Open a score to count its notes".into(), true)],
}],
};
}
let counts = s.per_string();
let total: u32 = counts.iter().sum();
let detail = match s.picked.and_then(|i| Some((i, counts.get(i)?))) {
Some((i, n)) => format!("String {}: {n} notes", i + 1),
None if counts.is_empty() => "This track has no strings".into(),
None => format!("{total} notes; click a bar"),
};
let mut rows = vec![
Row {
widgets: vec![label(s.title.clone(), false)],
},
Row {
widgets: vec![Widget::Choice {
id: "track".into(),
options: s.tracks.iter().map(|t| t.name.clone()).collect(),
selected: s.track as u32,
enabled: s.tracks.len() > 1,
}],
},
Row {
widgets: vec![Widget::Toggle {
id: "tied".into(),
text: "Count tied notes".into(),
on: s.count_tied,
enabled: true,
}],
},
Row {
widgets: vec![label(detail, s.picked.is_none())],
},
];
if !counts.is_empty() {
rows.push(Row {
widgets: vec![Widget::Canvas(Canvas {
id: "chart".into(),
width: WIDTH,
height: HEIGHT,
items: draw(&counts, s.picked),
})],
});
}
TabContent { rows }
}
and what it does with the user’s events:
fn on(&self, widget: &str, event: TabEvent) -> Result<(), Error> {
match (widget, event) {
("chart", TabEvent::Pointer(pointer)) => {
let mut s = self.state();
let strings = s.per_string().len();
if let Some(string) = bar_at(pointer, strings) {
s.picked = Some(string);
}
}
("track", TabEvent::Chosen(i)) => {
let mut s = self.state();
if (i as usize) < s.tracks.len() {
s.track = i as usize;
s.picked = None;
}
}
("tied", TabEvent::Toggled(on)) => {
self.state().count_tied = on;
// Not being able to save it is worth knowing, not worth
// losing the tab over.
if let Err(e) = self.save(on) {
let text = format!("\"Count tied notes\" can't be remembered: {e}");
self.host.notify(Level::Warning, text);
}
}
(widget, event) => {
return Err(Error::new(
ErrorKind::Invalid,
format!("the widget {widget:?} doesn't take {event:?}"),
));
}
}
// The core asks for the contents again after every event.
Ok(())
}
Contents the core refuses
Contents with an id missing or used twice, a slider whose range is backwards, a choice or list selecting what it doesn’t have, or a canvas that is too large or holds something it can’t draw break the interface’s contract: the plugin is withdrawn for the rest of the session, its tabs with it, and the footer names the first problem. TabContent::problems() gives the same sentences, and cargo test runs it on every tab’s first contents; the contract suite also uses each widget once (a click, a tick, the slider’s lowest value, an empty field, the first option, a press, drag and release in the middle of a canvas) and checks the contents after each.
Redrawing
A tab whose contents change on their own (after an event, a timer, a finished download) calls host.redraw("statistics"). The core asks for content when the tab is next on screen; a tab no one is looking at costs nothing. Nothing polls: don’t ask for redraws on a timer to look for changes.
The canvas
A canvas is a drawing in its own units: width × height, with x to the right and y down from the top-left corner. The core scales it to the room the tab has, keeping its proportions, and reports the pointer in the same units, so you never deal with pixels or screen scales.
Its items are drawn in order, later ones over earlier ones (oxiguitar_plugin::display):
| Item | Draws |
|---|---|
Line { from, to, width, paint } | A straight line with square ends |
Rect { at, width, height, paint } | A filled rectangle, at its top-left corner |
Path { cmds, fill, stroke } | A shape of MoveTo, LineTo, CubicTo and Close, filled (non-zero winding), stroked ((width, paint)), or both |
Text { text, at, size, font, anchor, paint } | One line of text on its baseline at at, starting, centred or ending there (Anchor::Start, Middle, End); size is the font’s em in canvas units |
Colours are roles, resolved by the window’s theme, so a canvas follows the dark interface by itself:
Paint | For |
|---|---|
Ink | Lines, shapes and text |
Page | The canvas’s background colour: for knocking shapes out |
Accent | Highlights: the selection, the playing note |
Muted | Secondary marks |
Text crosses as text, and the core draws it with its own fonts: Font::Sans, the window’s text font, or Font::Music, the music font (Bravura), where the text is SMuFL code points and size is a five-line staff’s height, as in engraving ("\u{E050}" is a G clef). A plugin needs no fonts of its own.
The template draws a bar chart, one bar per string:
/// A bar per string, as tall as its share of the busiest string, with the
/// strings' numbers under them. Paints are roles, so the chart follows the
/// window's theme.
fn draw(counts: &[u32], picked: Option<usize>) -> Vec<Item> {
let most = counts.iter().copied().max().unwrap_or(0).max(1) as f32;
let slot = slot(counts.len());
let room = HEIGHT - LABELS;
let mut items = vec![Item::Line {
from: Point::new(0.0, room),
to: Point::new(WIDTH, room),
width: 0.5,
paint: Paint::Muted,
}];
for (i, &n) in counts.iter().enumerate() {
let x = i as f32 * slot;
let height = room * n as f32 / most;
items.push(Item::Rect {
at: Point::new(x + slot * 0.15, room - height),
width: slot * 0.7,
height,
paint: if picked == Some(i) {
Paint::Accent
} else {
Paint::Ink
},
});
items.push(Item::Text {
text: (i + 1).to_string(),
at: Point::new(x + slot / 2.0, HEIGHT - 2.0),
size: 8.0,
font: Font::Sans,
anchor: Anchor::Middle,
paint: Paint::Muted,
});
}
items
}
Limits
A canvas is at most 100,000 units a side and holds at most 100,000 items, and every number in it is finite. Everything stays within reach of the canvas: every point lies within the canvas extended by its own size on every side, and no size, line width or text size is more than three times its longer side. Text running further is cut there. These keep a mistaken plugin from costing the window time; a real drawing never comes near them.
The pointer
pub struct Pointer {
pub kind: PointerKind, // Down, Drag (moved with a button held) or Up
pub at: Point, // in the canvas's units
pub button: PointerButton, // Primary, Secondary or Middle
}
A press gives Down, moving with the button held gives Drags, letting go gives Up (wherever the pointer is). Drags the plugin hasn’t answered yet are merged, so a slow plugin gets the latest position rather than a backlog. There is no hover: the pointer is reported only while a button is held. The template finds the bar under a press:
/// The string whose bar is under the pointer when a button goes down.
fn bar_at(pointer: Pointer, strings: usize) -> Option<usize> {
let Point { x, y } = pointer.at;
let inside = (0.0..WIDTH).contains(&x) && (0.0..HEIGHT).contains(&y);
(pointer.kind == PointerKind::Down && inside && strings > 0)
.then(|| ((x / slot(strings)) as usize).min(strings - 1))
}
Tips
- One tab, one purpose. A left or right area is narrow: a column of widgets fits better there than a wide canvas. The bottom area is as tall as the track list (up to five tracks) until the user drags its splitter, so a tab there should still make sense when short. A canvas shrinks to fit, keeping its proportions, but a tab doesn’t scroll: rows that don’t fit are cut off, so put buttons and toggles above a canvas, as the template does.
- Lay a canvas out in round units of your own (the template’s chart is 120 × 60) and let the core scale it.
- Keep
contentcheap: build it from state you already have. Do slow work (reading the score, a download) when an event or a click asks for it, and callredrawwhen it’s done. - A plugin’s settings belong in its own tab; the core draws no settings forms (Settings).