///|
/// Native message viewport adapted from RUI b1291945 (MIT), with explicit keyed updates.
pub(all) struct MessageItem {
key : String
content : @minimoon.Node
} derive(Eq)
///|
pub fn message_item(key~ : String, content~ : @minimoon.Node) -> MessageItem {
guard key != "" else { abort("message item requires a stable key") }
{ key, content, }
}
///|
pub(all) enum MessageScrollDirection {
ScrollStart
ScrollEnd
} derive(Eq, Debug)
///|
priv struct MessageScrollState {
token : Int
keys : Array[String]
top : Double
content_height : Double
viewport_height : Double
at_start : Bool
at_end : Bool
scroll_target : String
prepend : Bool
} derive(Eq)
///|
priv enum MessageScrollMsg {
ItemsChanged(Array[String])
Scrolled(@minimoon.ScrollDetail)
Jump(MessageScrollDirection)
Reached(MessageScrollDirection)
Pause
Refresh
MeasuredScroll(Int, Result[Array[@minimoon.NodeRect?], @minimoon.HostError])
}
///|
fn messages_prepended(
previous : Array[String],
current : Array[String],
) -> Bool {
if previous.is_empty() || current.length() <= previous.length() {
return false
}
let offset = current.length() - previous.length()
for index, key in previous {
if current[index + offset] != key {
return false
}
}
true
}
///|
fn message_signature(items : Array[MessageItem], revision : Int) -> String {
let seen : Set[String] = Set([])
let parts = [revision.to_string()]
for item in items {
guard item.key != "" && !seen.contains(item.key) else {
abort("message scroller requires unique stable item keys")
}
seen.add(item.key)
parts.push(item.key.length().to_string() + ":" + item.key)
}
parts.join("|")
}
///|
/// Increment content_revision for streamed edits or asynchronously resized content
/// without a key change. Appending/prepending keyed items is observed automatically.
/// render_button receives direction, guarded jump command, and disabled state.
/// Keyed height-delta anchoring replaces browser per-element overflow-anchor.
pub fn message_scroller(
context : UiContext,
id~ : String,
items~ : @minimoon.Val[Array[MessageItem]],
content_revision? : @minimoon.Val[Int] = @minimoon.Val::constant(0),
initially_at_end? : Bool = true,
height? : String = "320px",
show_buttons? : Bool = true,
render_button? : (MessageScrollDirection, @minimoon.Cmd, Bool) -> @minimoon.Node,
) -> @minimoon.Val[@minimoon.Node] {
let input = @minimoon.Val::map2(items, content_revision, (items, revision) => {
(items, revision)
})
let initial = MessageScrollState::{
token: 0,
keys: [],
top: 0.0,
content_height: 0.0,
viewport_height: 0.0,
at_start: !initially_at_end,
at_end: initially_at_end,
scroll_target: "",
prepend: false,
}
let (state, emit) = @minimoon.create_state_with_input(
input~,
init=(_, _) => @minimoon.no_cmd(initial),
update=(state, input, msg : MessageScrollMsg, emit) => {
match msg {
Pause =>
@minimoon.no_cmd({
..state,
token: state.token + 1,
prepend: false,
scroll_target: "",
})
ItemsChanged(_) | Refresh => {
let keys = match msg {
ItemsChanged(keys) => keys
_ => input.0.map(item => item.key)
}
let token = state.token + 1
(
{
..state,
token,
keys,
prepend: !state.at_end && messages_prepended(state.keys, keys),
scroll_target: if state.at_end {
id + "-end-" + token.to_string()
} else {
""
},
},
@minimoon.measure_nodes(
[id + "-viewport", id + "-content"],
emit.map(result => MeasuredScroll(token, result)),
),
)
}
MeasuredScroll(token, result) => {
guard token == state.token &&
result is Ok([Some(viewport), Some(content)]) else {
return @minimoon.no_cmd(state)
}
let limit = (content.height - viewport.height).max(0.0)
let top = if state.at_end {
limit
} else if state.prepend {
(state.top + content.height - state.content_height).clamp(
min=0.0,
max=limit,
)
} else {
state.top.min(limit)
}
@minimoon.no_cmd({
..state,
top,
content_height: content.height,
viewport_height: viewport.height,
at_start: top <= 2.0,
at_end: top >= limit - 2.0,
prepend: false,
})
}
Scrolled(detail) => {
let top = detail.scroll_top.max(0.0)
@minimoon.no_cmd({
..state,
top,
content_height: detail.scroll_height,
at_start: top <= 2.0,
at_end: if state.viewport_height > 0.0 {
top + state.viewport_height >= detail.scroll_height - 2.0
} else {
state.at_end
},
scroll_target: "",
})
}
Jump(direction) => {
let token = state.token + 1
let end = direction == ScrollEnd
@minimoon.no_cmd({
..state,
token,
top: if end {
(state.content_height - state.viewport_height).max(0.0)
} else {
0.0
},
at_start: !end,
at_end: end,
prepend: false,
scroll_target: if end {
id + "-end-" + token.to_string()
} else {
id + "-start"
},
})
}
Reached(direction) =>
@minimoon.no_cmd(
if direction == ScrollEnd {
{ ..state, at_end: true, }
} else {
{ ..state, at_start: true, }
},
)
}
},
subscriptions=(_, _, emit) => {
@minimoon.Sub::batch([
context.page.on_hide(emit(Pause)),
context.page.on_show(emit(Refresh)),
])
},
)
// A keyed dynamic scope owns the one-shot update command. No effects run from a view.
let watcher = input.switch_by(
(input, _) => {
let (_, _) = @minimoon.create_state_with_init(
init=_ => ((), emit(ItemsChanged(input.0.map(item => item.key)))),
update=(_, _ : Unit, _) => @minimoon.no_cmd(()),
)
@minimoon.Val::constant(@minimoon.nothing())
},
by=input => message_signature(input.0, input.1),
)
let view = @minimoon.Val::view2(items, state, (items, state) => {
let messages = items.map(item => {
@minimoon.keyed(
item.key,
ui_container(
"message-scroller-item",
[item.content],
style="flex-shrink:0;min-width:0;",
),
)
})
ui_container(
"message-scroller",
id~,
style="position:relative;height:" + height + ";",
[
@minimoon.scroll_view(
id=id + "-viewport",
class="mmui-message-scroller-viewport",
style="height:100%;",
scroll_y=true,
scroll_top_px=state.top,
scroll_into_view=state.scroll_target,
upper_threshold=2,
lower_threshold=2,
on_scroll=emit.map(detail => Scrolled(detail)),
scroll_key=id + "/scroll",
on_upper=emit(Reached(ScrollStart)),
upper_key=id + "/upper",
on_lower=emit(Reached(ScrollEnd)),
lower_key=id + "/lower",
[
ui_container(
"message-scroller-content",
id=id + "-content",
style="display:flex;flex-direction:column;min-height:100%;",
[
@minimoon.div(id=id + "-start", @minimoon.nothing()),
@minimoon.keyed_fragment(messages),
@minimoon.div(
id=id + "-end-" + state.token.to_string(),
@minimoon.nothing(),
),
],
),
],
),
if show_buttons {
message_scroll_control(
id,
ScrollStart,
state.at_start,
emit(Jump(ScrollStart)),
render_button,
)
} else {
@minimoon.nothing()
},
if show_buttons {
message_scroll_control(
id,
ScrollEnd,
state.at_end,
emit(Jump(ScrollEnd)),
render_button,
)
} else {
@minimoon.nothing()
},
],
)
})
@minimoon.Val::view2(view, watcher, (view, watcher) => {
@minimoon.fragment([view, watcher])
})
}
///|
fn message_scroll_control(
id : String,
direction : MessageScrollDirection,
disabled : Bool,
command : @minimoon.Cmd,
render : ((MessageScrollDirection, @minimoon.Cmd, Bool) -> @minimoon.Node)?,
) -> @minimoon.Node {
let start = direction == ScrollStart
let class = "mmui-message-scroller-" + (if start { "start" } else { "end" })
let style = "position:absolute;" +
(if start { "top" } else { "bottom" }) +
":8px;right:8px;"
let command = if disabled { @minimoon.none } else { command }
match render {
Some(render) =>
@minimoon.div(class~, style~, render(direction, command, disabled))
None =>
@minimoon.button(
class~,
style~,
disabled~,
on_tap=command,
event_key=id + (if start { "/start" } else { "/end" }),
if start {
"Oldest messages"
} else {
"Latest messages"
},
)
}
}