Owner-Relative Coordinates¶
Borland's TView stores origin and size relative to its owner. A button at
(2, 3) inside a dialog stays at (2, 3) no matter where the dialog is dragged;
TView::writeLine walks the owner chain to find where that lands on screen, and
TView::makeLocal walks it the other way to turn a screen click into a view
coordinate. The crate does not have the owner chain, and until now it compensated
by converting every child's bounds to screen coordinates the moment it was added.
That trade has costs that grew with the 3.0.0 hierarchy work, and this document
describes the change that removes it: children keep owner-relative bounds, views
draw in their own coordinate space, and the translation lives in the two places
that already know about ownership, the Terminal and GroupLike.
This ships as part of 3.0.0. The tag exists locally and the crate is not yet published, so the changelog entry is amended rather than a second major release being opened a day after the first.
What absolute bounds cost today¶
Group::add and Window::add add the owner's origin to the child's bounds.
group_set_bounds therefore has to compute a delta and shift every child whenever
a window moves, and Window does the same for its frame and its interior Group
by hand. Every leaf draw reads self.core.bounds.a and adds it to each cell it
writes; there are some 270 such sites in the crate and extras. Mouse hit tests
compare screen positions against screen bounds, so a view that wants a local
column has to subtract its own origin. Desktop::add has to constrain a window
after conversion and not before, a bug that took an issue to find (#95). Tests
that build a dialog and inspect a child have to know where the dialog was placed.
None of this is wrong, but all of it is bookkeeping that the ownership model already makes unnecessary. A child has exactly one owner, and that owner is on the stack whenever the child draws or handles an event. The owner can translate.
The model¶
ViewCore.bounds becomes owner-relative: a is Borland's origin, b - a is
size. The accessor keeps its name and type. A view's own coordinate space starts
at (0, 0) in its top-left corner, and a new extent() on View returns
Rect::new(0, 0, w, h), Borland's getExtent. bounds() is the rectangle the
view occupies in its owner's space; extent() is the rectangle it draws into.
Group::add and Window::add store the bounds they are given. group_set_bounds
drops the delta shift and keeps only the grow bits, which is precisely
TGroup::changeBounds driving TView::calcBounds: a child with Grow::NONE
does not move when its owner moves, because its position was always relative.
Window no longer recomputes its frame and interior positions on move, only on
resize.
The root of the tree is Application. Its menu bar, desktop and status line are
positioned in application space, and since the application fills the screen that
is screen space. Nothing above the application translates.
flowchart LR
subgraph screen["Screen (Application at 0,0)"]
subgraph desktop["Desktop bounds (0,1)-(80,24)"]
subgraph dialog["Dialog bounds (10,3)-(50,15) in Desktop space"]
subgraph interior["Interior Group bounds (1,1)-(39,11) in Dialog space"]
button["Button bounds (2,2)-(12,4) in Interior space<br/>draws at its own (0,0)"]
end
end
end
end
button -. "screen cell = (0,1) + (10,3) + (1,1) + (2,2) = (13,7)" .-> screen
Drawing¶
Terminal already keeps a clip stack. It gains an origin stack beside it:
pub fn push_origin(&mut self, origin: Point); // accumulates
pub fn pop_origin(&mut self);
fn origin(&self) -> Point; // sum of the stack
write_cell, write_line, read_cell, show_cursor and push_clip take
coordinates in the caller's local space and add the accumulated origin before
touching the buffer. Coordinates become i16 on these calls, because a local
coordinate is allowed to be negative before translation, and the terminal drops
anything that lands off screen as it does now. The clip stack keeps storing screen
rectangles; push_clip translates on the way in, so get_clip_rect and
is_clipped are unchanged.
group_draw becomes:
terminal.push_clip(self.extent().grown(1, 1)); // as today, in local space
for child in children {
child.set_palette_chain(Some(node.clone()));
if self.extent().intersects(&child.bounds()) {
terminal.push_origin(child.bounds().a);
child.draw(terminal);
terminal.pop_origin();
}
}
terminal.pop_clip();
The one-cell clip growth stays, so scroll bars that sit on a window frame and the
shadow a window paints outside its own extent keep working exactly as they do
now. There is no per-child clip; a child is free to draw outside its extent, as
Borland's TView is, and its owner's clip is what stops it.
window_draw, Desktop::draw and Application::draw follow the same pattern
for the children they hold directly (frame, interior, menu bar, status line).
group_update_cursor pushes the focused child's origin before delegating so that
show_cursor lands in the right cell. Two helpers in views::view capture the
pattern for any view that holds children by value: draw_child(terminal, child)
and dispatch_to_child(child, event). Code outside the tree that draws a
top-level view itself, as several examples do in hand-written loops, calls
terminal.draw_view(&mut view), which is the same push, draw, pop. write_line_to_terminal and
draw_shadow_bounds lose their y < 0 and x.max(0) guards, since the terminal
now clips after translation.
Every leaf draw writes at (0, 0)..(w, h). In practice each of the 270 sites
changes from self.core.bounds.a.x + i to i, and a handful that computed
bounds.b for a right edge use extent().b or width() instead.
Events¶
A group receives mouse events in its own local space and dispatches them in the
child's. group_handle_event hit-tests children against their relative bounds,
which are already in the group's space, then translates for the child and
translates back on return:
let origin = child.bounds().a;
event.mouse.pos -= origin;
child.handle_event(event);
event.mouse.pos += origin;
The translation back is unconditional. It does not matter whether the child
consumed the event, transformed it into a command, or ignored it: mouse.pos is
always in the space of whoever holds the event. That single rule replaces the
owner chain for upward position reporting. History already puts its anchor in
event.mouse.pos when it turns a click into CM_SHOW_HISTORY, and
ComboBox does the same for CM_SHOW_DROPDOWN; by the time the event reaches
Application::execute_modal or exec_view, both of which sit at the root, the
anchor is in desktop space without either control knowing where it is.
sequenceDiagram
participant App as Application
participant Dlg as Dialog (10,3)
participant Int as Interior (1,1)
participant H as History (30,2)
App->>Dlg: MouseDown pos=(43,7)
Dlg->>Int: pos -= (10,3) -> (33,4)
Int->>H: pos -= (1,1) -> (32,3)
H-->>Int: Command CM_SHOW_HISTORY, pos=(32,3)
Int-->>Dlg: pos += (1,1) -> (33,4)
Dlg-->>App: pos += (10,3) -> (43,7)
App->>App: open HistoryWindow below (43,7)
A dragging or resizing child keeps receiving MouseMove and MouseUp even when
the pointer has left its extent, as today; the coordinates it sees are simply
local and may be negative. Window's drag code works with the pointer expressed
in its owner's space, which is local + self.bounds().a, so the window's new
origin is pointer_in_owner - drag_offset and nothing else changes. The desktop
extent replaces the desktop bounds as the limit rectangle.
make_global and make_local on View become one-hop conversions between the
view's space and its owner's, which is all they can be without a chain and all
the crate ever used them for. Their docs say so.
Keyboard, command and broadcast events carry no position and are untouched.
Positioning helpers¶
set_parent_bounds is renamed set_owner_extent and is called with the owner's
extent. A window's drag limits, Desktop::center_view and
constrain_to_parent_bounds all work against (0, 0, w, h). Desktop::add no
longer has to order centering and constraining around a conversion step, because
there is no conversion step. Dialog::execute off the desktop passes the desktop
extent as explicit drag limits, as it does now.
Window::update_frame_child, Window::add, Desktop::cascade, Desktop::tile
and the zoom logic in Window all operate in the owner's space already once the
conversion is gone; they need their comments updated, not their arithmetic.
What downstream code has to change¶
A custom view that implemented View against 3.0.0 and drew with
self.bounds().a.x + i now writes i. Where it compared event.mouse.pos
against self.bounds() it compares against self.extent(), or simply reads the
position as a local column and row. Code that positioned a child inside a
Group or Dialog was already passing owner-relative bounds and does not change.
Code that inspected a child's bounds() after adding it and expected screen
coordinates now gets owner coordinates.
The changelog entry for 3.0.0 gains this under its migration section:
// 3.0.0 before this change: draw at screen position
let x = self.bounds().a.x;
write_line_to_terminal(terminal, x, self.bounds().a.y + row, &buf);
if self.bounds().contains(event.mouse.pos) { .. }
// 3.0.0: draw at (0,0); mouse positions arrive view-local
write_line_to_terminal(terminal, 0, row, &buf);
if self.extent().contains(event.mouse.pos) { .. }
Implementation plan¶
The work goes in this order so that the tree compiles at every step and each step is testable on its own.
- Terminal. Add the origin stack, translate in
write_cell,write_line,read_cell,show_cursor,push_clip; widen the coordinate parameters toi16. With an empty stack behaviour is identical, so nothing else changes yet. Unit tests: nested pushes accumulate, pop restores, negative local coordinates clip, a clip pushed under an origin is stored translated. View::extent()default method,set_parent_boundsrenamed toset_owner_extent,make_global/make_localdocumented as one-hop.- Group, Window, Desktop, Application. Remove the conversion in
add, the delta ingroup_set_bounds, and the frame and interior re-positioning on move; push origins ingroup_draw,window_draw,Desktop::draw,Application::drawand the cursor path; translate mouse positions ingroup_handle_eventand in the window's direct dispatch to frame and interior. After this step the tree draws children at the wrong place until step 4 lands, so 3 and 4 are one commit. - Leaf views. Every
drawand mouse handler insrc/viewsandextras/srcmoves to local coordinates. Mechanical, file by file, guided bygrep -n 'bounds.a\.\|bounds().a\.'. - Tests. Flip
test_coordinate_conversion_on_addto assert the bounds are stored as given. Add: a desktop holding a window holding a button, drawn intotest_terminal, with the button's text asserted at the computed screen cell; the same scene afterset_boundsmoves the window, asserting the child'sbounds()did not change and the cell did; aMouseDownon the button throughApplication::handle_event, asserting the button saw a local position and emitted its command; a frame drag that crosses the window's edge; aHistoryclick whoseCM_SHOW_HISTORYreaches the root with a desktop-space anchor. - Examples and docs.
cargo build --examplesand fix the examples that define their own views. Runshowcase,pascal_ideand a dialog-heavy example by hand. UpdateCLAUDE.md's architecture notes andTURBO-VISION-DESIGN.md's coordinate section, add the migration text toCHANGELOG.mdunder 3.0.0, and move thev3.0.0tag.
Gate for each commit: warning-free cargo build --workspace --all-targets and a
green cargo test --workspace, committed with --no-verify as for the rest of
the 3.0.0 work.
Decisions taken and alternatives not taken¶
Two smaller designs were considered. The first kept views drawing at
self.bounds().a and added only the origin stack, so that a view's bounds were
owner-relative but its draw code untouched. It saves the 270 edits but leaves
every view mixing two spaces, which is the confusion this change is meant to
remove. The second passed an explicit DrawContext { origin, clip } into
View::draw instead of storing the origin in the terminal. It is the more
explicit API, but it changes the View signature for every downstream crate a
second time and buys nothing the terminal stack does not, since a view already
receives the terminal and never needs to see the origin.
For mouse positions the alternative was to keep screen coordinates in the event
and cache each view's screen origin in ViewCore, refreshed at draw time the way
the palette chain is. The cache is stale between a move and the next draw, and
every handler has to remember to convert. Translating at the group boundary has
neither problem and matches how the palette chain is already pushed down.