// 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.
///|
pub(all) enum ToastKind {
Info
Success
Warning
Danger
} derive(Eq, Debug)
///|
pub struct Toast {
id : String
title : String
message : String
kind : ToastKind
mut elapsed : Double
duration : Double
} derive(Eq, Debug)
///|
pub fn Toast::new(
id : String,
title : String,
message? : String = "",
kind? : ToastKind = Info,
duration? : Double = 3.0,
) -> Toast {
{ id, title, message, kind, elapsed: 0.0, duration, }
}
///|
pub fn Toast::info(
title : String,
message? : String = "",
duration? : Double = 3.0,
) -> Toast {
Toast::new(title, title, message~, kind=Info, duration~)
}
///|
pub fn Toast::success(
title : String,
message? : String = "",
duration? : Double = 3.0,
) -> Toast {
Toast::new(title, title, message~, kind=Success, duration~)
}
///|
pub fn Toast::warning(
title : String,
message? : String = "",
duration? : Double = 3.0,
) -> Toast {
Toast::new(title, title, message~, kind=Warning, duration~)
}
///|
pub fn Toast::danger(
title : String,
message? : String = "",
duration? : Double = 3.0,
) -> Toast {
Toast::new(title, title, message~, kind=Danger, duration~)
}
///|
pub(all) enum ToastAnchor {
TopRight
BottomRight
BottomCenter
} derive(Eq, Debug)
///|
pub struct ToastResponse {
active_toasts : Array[Toast]
dismissed_id : String?
} derive(Eq, Debug)
///|
/// Render a floating toast stack in Studio Light porcelain style.
/// Toasts automatically decay over elapsed frames, stack gracefully according
/// to the chosen anchor, and allow manual dismissal via the close button.
pub fn toast_stack(
ctx : UIContext,
toasts : Array[Toast],
anchor? : ToastAnchor = BottomRight,
viewport_size? : @math.Vec2,
) -> ToastResponse {
let dt = ctx.input.dt()
let next_active : Array[Toast] = []
let mut dismissed_id : String? = None
// 1. Advance elapsed time and filter expired toasts
for toast in toasts {
toast.elapsed = toast.elapsed + dt
if toast.elapsed < toast.duration {
next_active.push(toast)
}
}
if next_active.is_empty() {
return { active_toasts: [], dismissed_id: None, }
}
// 2. Viewport and layout calculation
let scale = ctx.style.scale
let clip_opt = ctx.current_clip()
let vw = match viewport_size {
Some(vp) => if vp.x > 0.0 { vp.x } else { 800.0 * scale }
None =>
if ctx.available_width() > 0.0 {
ctx.available_width()
} else {
match clip_opt {
Some(c) => if c.w > 0.0 { c.x + c.w } else { 800.0 * scale }
None => 800.0 * scale
}
}
}
let vh = match viewport_size {
Some(vp) => if vp.y > 0.0 { vp.y } else { 600.0 * scale }
None =>
match clip_opt {
Some(c) => if c.h > 0.0 { c.y + c.h } else { 600.0 * scale }
None => 600.0 * scale
}
}
let toast_w = 280.0 * scale
let spacing = 8.0 * scale
let margin = 20.0 * scale
let placed_rects : Array[@math.Rect] = []
match anchor {
BottomRight => {
let x = vw - toast_w - margin
let mut cur_bottom = vh - margin
for toast in next_active {
let h = if toast.message.is_empty() {
38.0 * scale
} else {
54.0 * scale
}
let y = cur_bottom - h
placed_rects.push(@math.Rect::new(x, y, toast_w, h))
cur_bottom = cur_bottom - h - spacing
}
}
TopRight => {
let x = vw - toast_w - margin
let mut cur_top = margin
for toast in next_active {
let h = if toast.message.is_empty() {
38.0 * scale
} else {
54.0 * scale
}
let y = cur_top
placed_rects.push(@math.Rect::new(x, y, toast_w, h))
cur_top = cur_top + h + spacing
}
}
BottomCenter => {
let x = (vw - toast_w) * 0.5
let mut cur_bottom = vh - margin
for toast in next_active {
let h = if toast.message.is_empty() {
38.0 * scale
} else {
54.0 * scale
}
let y = cur_bottom - h
placed_rects.push(@math.Rect::new(x, y, toast_w, h))
cur_bottom = cur_bottom - h - spacing
}
}
}
for rect in placed_rects {
ctx.block_hover(rect)
}
ctx.begin_foreground()
// Close-button hit-testing runs inside the foreground pass: on the main
// layer the toast's own hover blocker (registered one frame earlier) would
// swallow is_hovered and the cross could never be clicked again after the
// first frame.
for i = 0; i < next_active.length(); i = i + 1 {
let rect = placed_rects[i]
let close_size = 20.0 * scale
let close_btn_rect = @math.Rect::new(
rect.x + toast_w - 26.0 * scale,
rect.y + 6.0 * scale,
close_size,
close_size,
)
if ctx.is_hovered(close_btn_rect) {
ctx.set_cursor_icon("pointer")
if ctx.input.mouse_pressed {
dismissed_id = Some(next_active[i].id)
break
}
}
if ctx.is_hovered(rect) {
ctx.set_wants_capture_mouse(true)
}
}
let final_active : Array[Toast] = []
let final_rects : Array[@math.Rect] = []
for i = 0; i < next_active.length(); i = i + 1 {
let toast = next_active[i]
if Some(toast.id) != dismissed_id {
final_active.push(toast)
final_rects.push(placed_rects[i])
}
}
for i = 0; i < final_active.length(); i = i + 1 {
let toast = final_active[i]
let rect = final_rects[i]
let scale = ctx.style.scale
let toast_radius = (ctx.style.radius_md + 1.0) * scale
let theme = ctx.theme
ctx
.painter()
.add_rect(
@math.Rect::new(rect.x, rect.y + 4.0 * scale, rect.w, rect.h),
theme.shadow,
toast_radius,
)
ctx
.painter()
.add_rect(
@math.Rect::new(rect.x, rect.y + 1.0 * scale, rect.w, rect.h),
theme.shadow,
toast_radius,
)
ctx.painter().add_rect(rect, theme.bg_window, toast_radius)
ctx
.painter()
.add_rect_stroke(rect, theme.border_default, 1.0 * scale, toast_radius)
let kind_color = match toast.kind {
Info => theme.accent_primary
Success => theme.success
Warning => theme.warning
Danger => theme.danger
}
let font_title = ctx.style.font_normal * scale
let font_detail = ctx.style.font_small * scale
let font_close = ctx.style.font_small * scale
ctx
.painter()
.add_rect(
@math.Rect::new(
rect.x,
rect.y + 5.0 * scale,
3.5 * scale,
rect.h - 10.0 * scale,
),
kind_color,
1.5 * scale,
)
let has_detail = !toast.message.is_empty()
let title_y = if has_detail {
rect.y + 10.0 * scale
} else {
rect.y + 12.0 * scale
}
ctx
.painter()
.add_text(
@math.Vec2::new(rect.x + 16.0 * scale, title_y),
toast.title,
font_title,
theme.text_primary,
)
if has_detail {
ctx
.painter()
.add_text(
@math.Vec2::new(rect.x + 16.0 * scale, rect.y + 29.0 * scale),
toast.message,
font_detail,
theme.text_muted,
)
}
let close_hover = ctx.is_hovered(
@math.Rect::new(
rect.x + toast_w - 24.0 * scale,
rect.y + 6.0 * scale,
18.0 * scale,
18.0 * scale,
),
)
let close_col = if close_hover {
theme.text_primary
} else {
theme.text_disabled
}
ctx
.painter()
.add_text(
@math.Vec2::new(rect.x + toast_w - 18.0 * scale, rect.y + 9.0 * scale),
"x",
font_close,
close_col,
)
}
ctx.end_foreground()
{ active_toasts: final_active, dismissed_id, }
}