// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Split direction for a docking container node.
pub(all) enum DockSplitDirection {
Horizontal
Vertical
} derive(Eq, Debug)
///|
pub fn DockSplitDirection::horizontal() -> DockSplitDirection {
Horizontal
}
///|
pub fn DockSplitDirection::vertical() -> DockSplitDirection {
Vertical
}
///|
/// Single tab descriptor inside a dock leaf panel.
pub struct DockTab {
id : String
title : String
closable : Bool
} derive(Eq, Debug)
///|
/// Create a new dock tab.
pub fn DockTab::new(
id : String,
title : String,
closable? : Bool = false,
) -> DockTab {
{ id, title, closable, }
}
///|
/// Recursive layout tree node for multi-window docking.
pub(all) enum DockNode {
Leaf(Array[DockTab], Int) // tabs, active_tab_index
Split(DockSplitDirection, Double, DockNode, DockNode) // direction, ratio, first, second
Empty
} derive(Eq, Debug)
///|
/// Container managing a mutable docking layout tree.
pub struct DockTree {
mut root : DockNode
} derive(Eq, Debug)
///|
/// Create a new dock tree from a root node.
pub fn DockTree::new(root : DockNode) -> DockTree {
{ root, }
}
///|
/// Create an empty dock tree.
pub fn DockTree::empty() -> DockTree {
{ root: Empty, }
}
///|
/// Create a leaf dock tree with tabs.
pub fn DockTree::leaf(tabs : Array[DockTab], active_tab? : Int = 0) -> DockTree {
{ root: Leaf(tabs, active_tab), }
}
///|
/// Create a split dock tree combining two subtrees.
pub fn DockTree::split(
direction : DockSplitDirection,
ratio : Double,
first : DockTree,
second : DockTree,
) -> DockTree {
{ root: Split(direction, ratio, first.root, second.root), }
}
///|
/// Retrieve current root node.
pub fn DockTree::root(self : DockTree) -> DockNode {
self.root
}
///|
/// Helper to find a tab and its leaf index recursively.
fn find_tab_in_node(node : DockNode, tab_id : String) -> (DockTab, Int)? {
match node {
Leaf(tabs, active_idx) => {
for i = 0; i < tabs.length(); i = i + 1 {
if tabs[i].id == tab_id {
return Some((tabs[i], active_idx))
}
}
None
}
Split(_, _, first, second) =>
match find_tab_in_node(first, tab_id) {
Some(res) => Some(res)
None => find_tab_in_node(second, tab_id)
}
Empty => None
}
}
///|
/// Find a tab by its ID across the entire dock tree.
pub fn DockTree::find_tab(self : DockTree, tab_id : String) -> (DockTab, Int)? {
find_tab_in_node(self.root, tab_id)
}
///|
/// Helper to activate a tab by its ID recursively.
fn activate_tab_in_node(node : DockNode, tab_id : String) -> (DockNode, Bool) {
match node {
Leaf(tabs, _) => {
let mut found_idx = -1
for i = 0; i < tabs.length(); i = i + 1 {
if tabs[i].id == tab_id {
found_idx = i
break
}
}
if found_idx >= 0 {
(Leaf(tabs, found_idx), true)
} else {
(node, false)
}
}
Split(dir, ratio, first, second) => {
let (new_first, f_ok) = activate_tab_in_node(first, tab_id)
if f_ok {
(Split(dir, ratio, new_first, second), true)
} else {
let (new_second, s_ok) = activate_tab_in_node(second, tab_id)
(Split(dir, ratio, new_first, new_second), s_ok)
}
}
Empty => (Empty, false)
}
}
///|
/// Set active tab by tab ID across the dock tree.
pub fn DockTree::set_active_tab(self : DockTree, tab_id : String) -> Bool {
let (new_root, ok) = activate_tab_in_node(self.root, tab_id)
self.root = new_root
ok
}
///|
/// Helper to close a tab by its ID recursively.
fn close_tab_in_node(node : DockNode, tab_id : String) -> (DockNode, Bool) {
match node {
Leaf(tabs, active_idx) => {
let new_tabs = []
let mut removed = false
for i = 0; i < tabs.length(); i = i + 1 {
if tabs[i].id == tab_id {
removed = true
} else {
new_tabs.push(tabs[i])
}
}
if removed {
if new_tabs.is_empty() {
(Empty, true)
} else {
let new_active = if active_idx >= new_tabs.length() {
new_tabs.length() - 1
} else {
active_idx
}
(Leaf(new_tabs, new_active), true)
}
} else {
(node, false)
}
}
Split(dir, ratio, first, second) => {
let (new_first, f_ok) = close_tab_in_node(first, tab_id)
let (new_second, s_ok) = if f_ok {
(second, false)
} else {
close_tab_in_node(second, tab_id)
}
if f_ok || s_ok {
// Collapse if one branch became Empty
match (new_first, new_second) {
(Empty, remaining) => (remaining, true)
(remaining, Empty) => (remaining, true)
_ => (Split(dir, ratio, new_first, new_second), true)
}
} else {
(node, false)
}
}
Empty => (Empty, false)
}
}
///|
/// Close a tab by its ID across the dock tree.
pub fn DockTree::close_tab(self : DockTree, tab_id : String) -> Bool {
let (new_root, ok) = close_tab_in_node(self.root, tab_id)
self.root = new_root
ok
}
///|
/// Interaction response for a dock area layout frame.
pub struct DockResponse {
resp : Response
active_tab_changed : (String, String)? // (old_id, new_id)
tab_closed : String?
ratio_changed : Bool
} derive(Eq, Debug)
///|
/// Standalone DockArea widget struct.
pub struct DockArea {
id_salt : String
size : @math.Vec2
tree : DockTree
render_tab : (UIContext, String, @math.Rect) -> Unit
}
///|
/// Create a new DockArea widget.
pub fn DockArea::new(
id_salt : String,
size : @math.Vec2,
tree : DockTree,
render_tab : (UIContext, String, @math.Rect) -> Unit,
) -> DockArea {
{ id_salt, size, tree, render_tab, }
}
///|
pub impl @core.Widget for DockArea with fn ui(self, ctx : UIContext) -> Response {
let d_resp = self.show(ctx)
d_resp.resp
}
///|
/// Render the DockArea and return interaction details.
pub fn DockArea::show(self : DockArea, ctx : UIContext) -> DockResponse {
dock_area(ctx, self.id_salt, self.size, self.tree, self.render_tab)
}
///|
/// Internal rendering function for a single dock node.
fn render_dock_node(
ctx : UIContext,
node : DockNode,
rect : @math.Rect,
path : String,
render_tab : (UIContext, String, @math.Rect) -> Unit,
) -> (DockNode, (String, String)?, String?, Bool) {
let scale = ctx.style.scale
let min_pane_size = 40.0 * scale
match node {
Empty => {
if rect.w > 60.0 * scale && rect.h > 30.0 * scale {
let painter = ctx.painter()
painter.add_rect(rect, ctx.theme.bg_subtle, 4.0 * scale)
painter.add_rect_stroke(
rect,
ctx.theme.border_muted,
1.0 * scale,
4.0 * scale,
)
let font_sz = ctx.style.font_small * scale
let text_sz = ctx.measure_text("无活动标签页", font_sz)
let pad_x = ctx.style.spacing_sm * scale
let badge_w = pad_x * 2.0 + text_sz.x
let badge_h = (ctx.style.control_h_sm - ctx.style.radius_xs) * scale
let old_cursor = ctx.cursor()
let old_avail = ctx.available_width()
ctx.set_cursor(
@math.Vec2::new(
rect.x + (rect.w - badge_w) * 0.5,
rect.y + (rect.h - badge_h) * 0.5,
),
)
ctx.set_available_width(badge_w)
let _ = @widgets.badge(
ctx,
"无活动标签页",
kind=@widgets.BadgeKind::Default,
)
ctx.set_cursor(old_cursor)
ctx.set_available_width(old_avail)
}
(Empty, None, None, false)
}
Leaf(tabs, active_idx) => {
if tabs.is_empty() {
return render_dock_node(ctx, Empty, rect, path, render_tab)
}
let valid_active = if active_idx < 0 || active_idx >= tabs.length() {
0
} else {
active_idx
}
let tab_bar_h = 28.0 * scale
let header_rect = @math.Rect::new(rect.x, rect.y, rect.w, tab_bar_h)
let content_rect = @math.Rect::new(
rect.x,
rect.y + tab_bar_h,
rect.w,
rect.h - tab_bar_h,
)
let painter = ctx.painter()
// Header tab-bar background
painter.add_rect(header_rect, @color.Color::bg_surface(), 0.0)
painter.add_line(
@math.Vec2::new(rect.x, rect.y + tab_bar_h),
@math.Vec2::new(rect.x + rect.w, rect.y + tab_bar_h),
@color.Color::border_default(),
1.0,
)
// Render tab buttons
let mut cur_tab_x = rect.x + 4.0 * scale
let mut new_active_idx = valid_active
let mut tab_switched = None
let mut closed_tab_id = None
for i = 0; i < tabs.length(); i = i + 1 {
let tab = tabs[i]
let font_sz = ctx.style.font_small * scale
let title_w = ctx.measure_text(tab.title, font_sz).x
let close_btn_w = if tab.closable { 16.0 * scale } else { 0.0 }
let tab_w = title_w + close_btn_w + 16.0 * scale
let tab_rect = @math.Rect::new(
cur_tab_x,
rect.y + 2.0 * scale,
tab_w,
tab_bar_h - 2.0 * scale,
)
let is_current = i == valid_active
let is_hovered = ctx.is_hovered(tab_rect)
let tab_focus_id = ctx.id_stack.derive_id(path + "_tab::" + tab.id)
ctx.register_focusable(tab_focus_id)
let tab_focused = ctx.has_focus(tab_focus_id)
let key_switch = tab_focused &&
!is_current &&
(
ctx.input().key_pressed(Key::Enter) ||
ctx.input().key_pressed(Key::Space)
)
if key_switch {
ctx.input().consume_key(Key::Enter)
ctx.input().consume_key(Key::Space)
}
// A tab is a click target as a whole; the close cross overrides this
// with its own pointer further down.
if is_hovered {
ctx.set_cursor_icon("pointer")
}
// Tab background pill
if is_current {
painter.add_rect(tab_rect, @color.Color::bg_window(), 4.0 * scale)
painter.add_rect_stroke(
tab_rect,
@color.Color::border_default(),
1.0,
4.0 * scale,
)
// Top accent line
painter.add_rect(
@math.Rect::new(
cur_tab_x + 4.0 * scale,
rect.y + 2.0 * scale,
tab_w - 8.0 * scale,
2.0 * scale,
),
@color.Color::accent_primary(),
1.0,
)
} else if is_hovered {
painter.add_rect(tab_rect, @color.Color::bg_hover(), 4.0 * scale)
}
if tab_focused {
painter.add_rect_stroke(
tab_rect,
@color.Color::border_focus(),
1.0,
4.0 * scale,
)
}
// Tab title text
let text_col = if is_current {
@color.Color::text_strong()
} else {
@color.Color::text_muted()
}
painter.add_text(
@math.Vec2::new(cur_tab_x + 8.0 * scale, rect.y + 7.0 * scale),
tab.title,
font_sz,
text_col,
)
// Close button if closable
if tab.closable {
let close_x = cur_tab_x + tab_w - 14.0 * scale
let close_y = rect.y + 8.0 * scale
let close_rect = @math.Rect::new(
close_x - 2.0 * scale,
close_y - 2.0 * scale,
12.0 * scale,
12.0 * scale,
)
let close_hovered = ctx.is_hovered(close_rect)
if close_hovered {
painter.add_rect(close_rect, @color.Color::bg_active(), 2.0 * scale)
ctx.set_cursor_icon("pointer")
}
let close_col = if close_hovered {
@color.Color::danger()
} else {
@color.Color::text_muted()
}
painter.add_text(
@math.Vec2::new(close_x, close_y - 1.0 * scale),
"×",
font_sz,
close_col,
)
if close_hovered && ctx.input().mouse_released {
closed_tab_id = Some(tab.id)
}
}
// Click or keyboard to switch tab
if (is_hovered && ctx.input().mouse_released && closed_tab_id is None) ||
(key_switch && closed_tab_id is None) {
if i != valid_active {
tab_switched = Some((tabs[valid_active].id, tab.id))
new_active_idx = i
}
}
cur_tab_x = cur_tab_x + tab_w + 4.0 * scale
}
// Handle close tab if clicked
let result_node = match closed_tab_id {
Some(cid) => {
let remaining = []
for t in tabs {
if t.id != cid {
remaining.push(t)
}
}
if remaining.is_empty() {
Empty
} else {
let next_active = if new_active_idx >= remaining.length() {
remaining.length() - 1
} else {
new_active_idx
}
Leaf(remaining, next_active)
}
}
None => Leaf(tabs, new_active_idx)
}
// Scoped content panel rendering
if content_rect.w > 0.0 && content_rect.h > 0.0 {
let content_painter = ctx.painter().sub_painter(@math.Vec2::zero())
content_painter.push_clip(content_rect)
content_painter.add_rect(content_rect, @color.Color::bg_window(), 0.0)
ctx.push_clip(content_rect)
let active_tab_id = tabs[valid_active].id
render_tab(ctx, active_tab_id, content_rect)
ctx.pop_clip()
content_painter.pop_clip()
}
(result_node, tab_switched, closed_tab_id, false)
}
Split(direction, ratio, first, second) => {
let handle_thick = 6.0 * scale
let mut cur_ratio = ratio
let mut ratio_changed = false
let (first_rect, handle_rect, second_rect) = match direction {
Horizontal => {
let total_w = rect.w - handle_thick
let (w1, w2) = if total_w <= 0.0 {
(0.0, 0.0)
} else if total_w < min_pane_size * 2.0 {
let half = total_w * 0.5
(half, total_w - half)
} else {
let mut calculated_w1 = total_w * cur_ratio
if calculated_w1 < min_pane_size {
calculated_w1 = min_pane_size
}
if total_w - calculated_w1 < min_pane_size {
calculated_w1 = total_w - min_pane_size
}
(calculated_w1, total_w - calculated_w1)
}
let r1 = @math.Rect::new(rect.x, rect.y, w1, rect.h)
let rh = @math.Rect::new(rect.x + w1, rect.y, handle_thick, rect.h)
let r2 = @math.Rect::new(
rect.x + w1 + handle_thick,
rect.y,
w2,
rect.h,
)
(r1, rh, r2)
}
Vertical => {
let total_h = rect.h - handle_thick
let (h1, h2) = if total_h <= 0.0 {
(0.0, 0.0)
} else if total_h < min_pane_size * 2.0 {
let half = total_h * 0.5
(half, total_h - half)
} else {
let mut calculated_h1 = total_h * cur_ratio
if calculated_h1 < min_pane_size {
calculated_h1 = min_pane_size
}
if total_h - calculated_h1 < min_pane_size {
calculated_h1 = total_h - min_pane_size
}
(calculated_h1, total_h - calculated_h1)
}
let r1 = @math.Rect::new(rect.x, rect.y, rect.w, h1)
let rh = @math.Rect::new(rect.x, rect.y + h1, rect.w, handle_thick)
let r2 = @math.Rect::new(
rect.x,
rect.y + h1 + handle_thick,
rect.w,
h2,
)
(r1, rh, r2)
}
}
// Handle hit-testing and dragging
let handle_hovered = ctx.is_hovered(handle_rect)
let handle_id = ctx.id_stack.derive_id(path + "_spl")
ctx.register_focusable(handle_id)
let handle_focused = ctx.has_focus(handle_id)
let cursor_icon = match direction {
Horizontal => "col-resize"
Vertical => "row-resize"
}
if handle_hovered {
ctx.set_cursor_icon(cursor_icon)
}
if handle_hovered && ctx.input().mouse_pressed {
ctx.set_active_id(handle_id)
}
let is_active = ctx.active_id() == handle_id
if is_active {
ctx.set_cursor_icon(cursor_icon)
if ctx.input().mouse_down {
match direction {
Horizontal => {
let avail = rect.w - handle_thick
if avail > 0.0 {
let local_x = ctx.input().mouse_pos().x - rect.x
let raw_ratio = local_x / avail
let clamped_ratio = if raw_ratio < 0.05 {
0.05
} else if raw_ratio > 0.95 {
0.95
} else {
raw_ratio
}
if clamped_ratio != cur_ratio {
cur_ratio = clamped_ratio
ratio_changed = true
}
}
}
Vertical => {
let avail = rect.h - handle_thick
if avail > 0.0 {
let local_y = ctx.input().mouse_pos().y - rect.y
let raw_ratio = local_y / avail
let clamped_ratio = if raw_ratio < 0.05 {
0.05
} else if raw_ratio > 0.95 {
0.95
} else {
raw_ratio
}
if clamped_ratio != cur_ratio {
cur_ratio = clamped_ratio
ratio_changed = true
}
}
}
}
} else {
ctx.set_active_id(@core.Id::zero())
}
}
// Keyboard resize: Tab-focus the divider and nudge the ratio.
if handle_focused {
let kb_step = 0.02
let grow_key = match direction {
Horizontal => Key::ArrowRight
Vertical => Key::ArrowDown
}
let shrink_key = match direction {
Horizontal => Key::ArrowLeft
Vertical => Key::ArrowUp
}
if ctx.input().key_pressed(grow_key) {
ctx.input().consume_key(grow_key)
cur_ratio = if cur_ratio + kb_step > 0.95 {
0.95
} else {
cur_ratio + kb_step
}
ratio_changed = true
}
if ctx.input().key_pressed(shrink_key) {
ctx.input().consume_key(shrink_key)
cur_ratio = if cur_ratio - kb_step < 0.05 {
0.05
} else {
cur_ratio - kb_step
}
ratio_changed = true
}
}
// Render splitter handle bar
let painter = ctx.painter()
let handle_col = if is_active {
@color.Color::accent_primary()
} else if handle_hovered {
@color.Color::accent_hover()
} else {
@color.Color::border_muted()
}
painter.add_rect(handle_rect, handle_col, 0.0)
if handle_focused {
painter.add_rect_stroke(
handle_rect.expand(1.0),
@color.Color::border_focus(),
1.0,
0.0,
)
}
// Recursively render child panes
let (new_first, f_switched, f_closed, f_rc) = render_dock_node(
ctx,
first,
first_rect,
path + "_0",
render_tab,
)
let (new_second, s_switched, s_closed, s_rc) = render_dock_node(
ctx,
second,
second_rect,
path + "_1",
render_tab,
)
let tab_switched = match f_switched {
Some(_) => f_switched
None => s_switched
}
let closed_tab = match f_closed {
Some(_) => f_closed
None => s_closed
}
// Collapse tree if one branch became Empty
let updated_node = match (new_first, new_second) {
(Empty, remaining) => remaining
(remaining, Empty) => remaining
_ => Split(direction, cur_ratio, new_first, new_second)
}
(updated_node, tab_switched, closed_tab, ratio_changed || f_rc || s_rc)
}
}
}
///|
/// Core immediate-mode dock area layout primitive.
/// Manages flexible multi-window splits, tabs, and content clipping.
pub fn dock_area(
ctx : UIContext,
id_salt : String,
size : @math.Vec2,
tree : DockTree,
render_tab : (UIContext, String, @math.Rect) -> Unit,
) -> DockResponse {
ctx.id_stack.push(id_salt)
let (_, rect, resp) = ctx.allocate_space(size)
// Outer border
let painter = ctx.painter()
painter.add_rect_stroke(rect, @color.Color::border_default(), 1.0, 4.0)
let (new_root, active_tab_changed, tab_closed, ratio_changed) = render_dock_node(
ctx,
tree.root,
rect,
id_salt,
render_tab,
)
tree.root = new_root
ctx.id_stack.pop()
{ resp, active_tab_changed, tab_closed, ratio_changed, }
}