// 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.
///|
/// Standalone resizable split container widget supporting fluent configuration.
pub struct Splitter {
id_salt : String
split_ratio : Double
size : @math.Vec2
min_ratio : Double
max_ratio : Double
min_px : Double
vertical : Bool
} derive(Eq, Debug)
///|
/// Create a horizontal splitter container.
pub fn Splitter::horizontal(
id_salt : String,
split_ratio : Double,
size : @math.Vec2,
) -> Splitter {
{
id_salt,
split_ratio,
size,
min_ratio: 0.15,
max_ratio: 0.85,
min_px: 60.0,
vertical: false,
}
}
///|
/// Create a vertical splitter container.
pub fn Splitter::vertical(
id_salt : String,
split_ratio : Double,
size : @math.Vec2,
) -> Splitter {
{
id_salt,
split_ratio,
size,
min_ratio: 0.15,
max_ratio: 0.85,
min_px: 60.0,
vertical: true,
}
}
///|
/// Configure minimum split ratio boundary.
pub fn Splitter::min_ratio(self : Splitter, min_ratio : Double) -> Splitter {
{ ..self, min_ratio, }
}
///|
/// Configure maximum split ratio boundary.
pub fn Splitter::max_ratio(self : Splitter, max_ratio : Double) -> Splitter {
{ ..self, max_ratio, }
}
///|
/// Configure minimum pixel width/height per pane.
pub fn Splitter::min_px(self : Splitter, min_px : Double) -> Splitter {
{ ..self, min_px, }
}
///|
/// Show the splitter and execute closures for first and second panes.
pub fn Splitter::show(
self : Splitter,
ctx : UIContext,
first : (UIContext, @math.Rect) -> Unit,
second : (UIContext, @math.Rect) -> Unit,
) -> Double {
if self.vertical {
split_vertical(
ctx,
self.id_salt,
self.split_ratio,
self.size,
first,
second,
min_ratio=self.min_ratio,
max_ratio=self.max_ratio,
min_px=self.min_px,
)
} else {
split_horizontal(
ctx,
self.id_salt,
self.split_ratio,
self.size,
first,
second,
min_ratio=self.min_ratio,
max_ratio=self.max_ratio,
min_px=self.min_px,
)
}
}
///|
/// Scoped horizontal split container:
/// Automatically divides available space into left and right panes,
/// manages clipping for both sides, and handles interactive divider dragging.
pub fn split_horizontal(
ctx : UIContext,
id_salt : String,
split_ratio : Double,
size : @math.Vec2,
left : (UIContext, @math.Rect) -> Unit,
right : (UIContext, @math.Rect) -> Unit,
min_ratio? : Double = 0.15,
max_ratio? : Double = 0.85,
min_px? : Double = 60.0,
) -> Double {
ctx.push_id(id_salt)
let (_id, container_rect, _resp) = ctx.allocate_space(size)
ctx.pop_id()
let w = container_rect.w
let h = container_rect.h
let raw_ratio = if split_ratio < min_ratio {
min_ratio
} else if split_ratio > max_ratio {
max_ratio
} else {
split_ratio
}
if w <= 0.0 || h <= 0.0 {
return raw_ratio
}
let safe_min_px = if min_px * 2.0 > w { w * 0.5 } else { min_px }
let mut left_w = w * raw_ratio
if left_w < safe_min_px {
left_w = safe_min_px
}
if w - left_w < safe_min_px {
left_w = w - safe_min_px
}
let mut effective_ratio = left_w / w
let bar_x = container_rect.x + left_w
let hit_w = 10.0 * ctx.style.scale
let hit_rect = @math.Rect::new(
bar_x - hit_w * 0.5,
container_rect.y,
hit_w,
h,
)
let bar_id = ctx.id_stack.derive_id("splitter_h::" + id_salt)
let hovered = ctx.is_hovered(hit_rect)
ctx.register_focusable(bar_id)
let is_focused = ctx.has_focus(bar_id)
if hovered {
ctx.set_cursor_icon("col-resize")
}
if hovered && ctx.input.mouse_pressed {
ctx.set_active_id(bar_id)
}
let is_active = ctx.active_id() == bar_id
if is_active {
ctx.set_wants_capture_mouse(true)
ctx.set_cursor_icon("col-resize")
if ctx.input.mouse_down {
let mouse_rel_x = ctx.input.mouse_pos.x - container_rect.x
let mut clamped_x = mouse_rel_x
if clamped_x < safe_min_px {
clamped_x = safe_min_px
}
if w - clamped_x < safe_min_px {
clamped_x = w - safe_min_px
}
effective_ratio = clamped_x / w
if effective_ratio < min_ratio {
effective_ratio = min_ratio
}
if effective_ratio > max_ratio {
effective_ratio = max_ratio
}
}
if ctx.input.mouse_released {
ctx.set_active_id(@core.Id::zero())
}
}
// Keyboard resize: the divider is Tab-reachable; arrows nudge the ratio.
if is_focused {
let kb_step = 0.02
if ctx.input.key_pressed(Key::ArrowRight) {
ctx.input.consume_key(Key::ArrowRight)
effective_ratio = if effective_ratio + kb_step > max_ratio {
max_ratio
} else {
effective_ratio + kb_step
}
}
if ctx.input.key_pressed(Key::ArrowLeft) {
ctx.input.consume_key(Key::ArrowLeft)
effective_ratio = if effective_ratio - kb_step < min_ratio {
min_ratio
} else {
effective_ratio - kb_step
}
}
if ctx.input.key_pressed(Key::Home) {
ctx.input.consume_key(Key::Home)
effective_ratio = min_ratio
}
if ctx.input.key_pressed(Key::End) {
ctx.input.consume_key(Key::End)
effective_ratio = max_ratio
}
let coarse_step = 0.10
if ctx.input.key_pressed(Key::PageDown) {
ctx.input.consume_key(Key::PageDown)
effective_ratio = if effective_ratio + coarse_step > max_ratio {
max_ratio
} else {
effective_ratio + coarse_step
}
}
if ctx.input.key_pressed(Key::PageUp) {
ctx.input.consume_key(Key::PageUp)
effective_ratio = if effective_ratio - coarse_step < min_ratio {
min_ratio
} else {
effective_ratio - coarse_step
}
}
}
left_w = w * effective_ratio
let right_w = w - left_w
let left_rect = @math.Rect::new(container_rect.x, container_rect.y, left_w, h)
let right_rect = @math.Rect::new(
container_rect.x + left_w,
container_rect.y,
right_w,
h,
)
let saved_cursor = ctx.cursor()
let saved_avail = ctx.available_width()
ctx.push_clip(left_rect)
ctx.set_cursor(@math.Vec2::new(left_rect.x, left_rect.y))
ctx.set_available_width(left_rect.w)
left(ctx, left_rect)
ctx.pop_clip()
ctx.push_clip(right_rect)
ctx.set_cursor(@math.Vec2::new(right_rect.x, right_rect.y))
ctx.set_available_width(right_rect.w)
right(ctx, right_rect)
ctx.pop_clip()
ctx.set_cursor(saved_cursor)
ctx.set_available_width(saved_avail)
let scale = ctx.style.scale
let theme = ctx.theme
let divider_x = container_rect.x + left_w
let line_col = if is_active || hovered {
theme.accent_primary
} else {
theme.border_muted
}
let line_w = (if is_active || hovered { 2.0 } else { 1.0 }) * scale
ctx
.painter()
.add_line(
@math.Vec2::new(divider_x, container_rect.y),
@math.Vec2::new(divider_x, container_rect.y + h),
line_col,
line_w,
)
let mid_y = container_rect.y + h * 0.5
let dot_col = if is_active || hovered {
theme.accent_primary
} else {
theme.border_strong
}
let dot_r = 1.5 * scale
let dot_spacing = 6.0 * scale
ctx
.painter()
.add_circle(@math.Vec2::new(divider_x, mid_y - dot_spacing), dot_r, dot_col)
ctx.painter().add_circle(@math.Vec2::new(divider_x, mid_y), dot_r, dot_col)
ctx
.painter()
.add_circle(@math.Vec2::new(divider_x, mid_y + dot_spacing), dot_r, dot_col)
if is_focused {
ctx
.painter()
.add_rect_stroke(
hit_rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
ctx.style.radius_sm * scale,
)
}
effective_ratio
}
///|
/// Scoped vertical split container:
/// Divides space into top and bottom panes with interactive vertical divider.
pub fn split_vertical(
ctx : UIContext,
id_salt : String,
split_ratio : Double,
size : @math.Vec2,
top : (UIContext, @math.Rect) -> Unit,
bottom : (UIContext, @math.Rect) -> Unit,
min_ratio? : Double = 0.15,
max_ratio? : Double = 0.85,
min_px? : Double = 60.0,
) -> Double {
ctx.push_id(id_salt)
let (_id, container_rect, _resp) = ctx.allocate_space(size)
ctx.pop_id()
let w = container_rect.w
let h = container_rect.h
let raw_ratio = if split_ratio < min_ratio {
min_ratio
} else if split_ratio > max_ratio {
max_ratio
} else {
split_ratio
}
if w <= 0.0 || h <= 0.0 {
return raw_ratio
}
let safe_min_px = if min_px * 2.0 > h { h * 0.5 } else { min_px }
let mut top_h = h * raw_ratio
if top_h < safe_min_px {
top_h = safe_min_px
}
if h - top_h < safe_min_px {
top_h = h - safe_min_px
}
let mut effective_ratio = top_h / h
let bar_y = container_rect.y + top_h
let hit_h = 10.0 * ctx.style.scale
let hit_rect = @math.Rect::new(
container_rect.x,
bar_y - hit_h * 0.5,
w,
hit_h,
)
let bar_id = ctx.id_stack.derive_id("splitter_v::" + id_salt)
let hovered = ctx.is_hovered(hit_rect)
ctx.register_focusable(bar_id)
let is_focused = ctx.has_focus(bar_id)
if hovered {
ctx.set_cursor_icon("row-resize")
}
if hovered && ctx.input.mouse_pressed {
ctx.set_active_id(bar_id)
}
let is_active = ctx.active_id() == bar_id
if is_active {
ctx.set_wants_capture_mouse(true)
ctx.set_cursor_icon("row-resize")
if ctx.input.mouse_down {
let mouse_rel_y = ctx.input.mouse_pos.y - container_rect.y
let mut clamped_y = mouse_rel_y
if clamped_y < safe_min_px {
clamped_y = safe_min_px
}
if h - clamped_y < safe_min_px {
clamped_y = h - safe_min_px
}
effective_ratio = clamped_y / h
if effective_ratio < min_ratio {
effective_ratio = min_ratio
}
if effective_ratio > max_ratio {
effective_ratio = max_ratio
}
}
if ctx.input.mouse_released {
ctx.set_active_id(@core.Id::zero())
}
}
// Keyboard resize: the divider is Tab-reachable; arrows nudge the ratio.
if is_focused {
let kb_step = 0.02
if ctx.input.key_pressed(Key::ArrowDown) {
ctx.input.consume_key(Key::ArrowDown)
effective_ratio = if effective_ratio + kb_step > max_ratio {
max_ratio
} else {
effective_ratio + kb_step
}
}
if ctx.input.key_pressed(Key::ArrowUp) {
ctx.input.consume_key(Key::ArrowUp)
effective_ratio = if effective_ratio - kb_step < min_ratio {
min_ratio
} else {
effective_ratio - kb_step
}
}
if ctx.input.key_pressed(Key::Home) {
ctx.input.consume_key(Key::Home)
effective_ratio = min_ratio
}
if ctx.input.key_pressed(Key::End) {
ctx.input.consume_key(Key::End)
effective_ratio = max_ratio
}
let coarse_step = 0.10
if ctx.input.key_pressed(Key::PageDown) {
ctx.input.consume_key(Key::PageDown)
effective_ratio = if effective_ratio + coarse_step > max_ratio {
max_ratio
} else {
effective_ratio + coarse_step
}
}
if ctx.input.key_pressed(Key::PageUp) {
ctx.input.consume_key(Key::PageUp)
effective_ratio = if effective_ratio - coarse_step < min_ratio {
min_ratio
} else {
effective_ratio - coarse_step
}
}
}
top_h = h * effective_ratio
let bottom_h = h - top_h
let top_rect = @math.Rect::new(container_rect.x, container_rect.y, w, top_h)
let bottom_rect = @math.Rect::new(
container_rect.x,
container_rect.y + top_h,
w,
bottom_h,
)
let saved_cursor = ctx.cursor()
let saved_avail = ctx.available_width()
ctx.push_clip(top_rect)
ctx.set_cursor(@math.Vec2::new(top_rect.x, top_rect.y))
ctx.set_available_width(top_rect.w)
top(ctx, top_rect)
ctx.pop_clip()
ctx.push_clip(bottom_rect)
ctx.set_cursor(@math.Vec2::new(bottom_rect.x, bottom_rect.y))
ctx.set_available_width(bottom_rect.w)
bottom(ctx, bottom_rect)
ctx.pop_clip()
ctx.set_cursor(saved_cursor)
ctx.set_available_width(saved_avail)
let scale = ctx.style.scale
let theme = ctx.theme
let divider_y = container_rect.y + top_h
let line_col = if is_active || hovered {
theme.accent_primary
} else {
theme.border_muted
}
let line_w = (if is_active || hovered { 2.0 } else { 1.0 }) * scale
ctx
.painter()
.add_line(
@math.Vec2::new(container_rect.x, divider_y),
@math.Vec2::new(container_rect.x + w, divider_y),
line_col,
line_w,
)
let mid_x = container_rect.x + w * 0.5
let dot_col = if is_active || hovered {
theme.accent_primary
} else {
theme.border_strong
}
let dot_r = 1.5 * scale
let dot_spacing = 6.0 * scale
ctx
.painter()
.add_circle(@math.Vec2::new(mid_x - dot_spacing, divider_y), dot_r, dot_col)
ctx.painter().add_circle(@math.Vec2::new(mid_x, divider_y), dot_r, dot_col)
ctx
.painter()
.add_circle(@math.Vec2::new(mid_x + dot_spacing, divider_y), dot_r, dot_col)
if is_focused {
ctx
.painter()
.add_rect_stroke(
hit_rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
ctx.style.radius_sm * scale,
)
}
effective_ratio
}