MessageScroller
MessageScroller coordinates a variable-height virtual list with the behavior
conversation screens usually need: follow the live tail, keep the current
anchor while older history is inserted, navigate to an unread row, and expose
whether the reader has left the tail.
The application owns the message collection, stable message IDs, unread meaning, row renderer, composer, and empty/error states. The component owns only virtual-list bookkeeping and the optional jump-to-latest affordance. There is one state entity per scroller.
#Import
use std::{rc::Rc, time::Duration};
use gpui_kit::{
IntoElement as _, ParentElement as _, StyleRefinement, Styled as _,
prelude::FluentBuilder as _,
};
use gpui_kit::component::{
ActiveTheme as _,
button::ButtonVariants as _,
message_scroller::{MessageScroller, MessageScrollerState},
Sizable as _,
v_flex,
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ChatTurn, MessageScroller } from "message-scroller.slint";use crate::components::button::{Button, Variant as ButtonVariant};
use crate::components::group_box::{self, GroupBox};
use crate::components::input::Input;
use crate::components::message_scroller::{MessageScroller, Turn, jump_to_end};
use crate::components::theme::{self, ControlSize, Tokens};
use iced::widget::{Id, button, column, container, mouse_area, row, rule, scrollable, svg, text};
use iced::{Alignment as Axis, Color, Element, Length, Padding, Task, Theme};
use std::cell::Cell;
use std::rc::Rc;#Create state and choose the starting position
// iced has no separate state entity: the id names the region, so a caller's
// own jump control can pass it to `jump_to_end`, and the turns are the
// application's collection. The starting position is the live tail, because
// the region anchors to its end while rows arrive; there is no persisted
// offset and no arbitrary start index to choose.
let starting = panel(MessageScroller::new(Id::new("starting"), app.turns.clone()));Create the state beside the application-owned message vector. The constructor receives the entity context because GPUI’s list scroll handler notifies the entity after the list releases its internal borrow:
let scroller = cx.new(|cx| MessageScrollerState::new(messages.len(), cx));
cx.observe(&scroller, |_, _, cx| cx.notify()).detach();MessageScrollerState::new(...) starts with tail following enabled. That is
the expected starting position for a live conversation. A saved thread or a
deep link can choose a row after the initial data has been installed:
let saved_index = messages
.iter()
.position(|message| message.id == saved_message_id)
.unwrap_or(messages.len().saturating_sub(1));
scroller.update(cx, |state, cx| {
state.reset(messages.len(), cx);
let _ = state.scroll_to_item(saved_index, cx);
});description: "Scroll upward, append a row, jump to unread, or prepend history to exercise each behavior.";
HorizontalLayout {
horizontal-stretch: 1;
alignment: center;
Rectangle {
width: min(Theme.viewport - 48px, 384px);
height: 540px;
border-radius: Theme.radius-xl;
border-width: 1px;
border-color: Theme.border;
background: Theme.background;
clip: true;
VerticalLayout {
spacing: 0;
// Header
VerticalLayout {
spacing: 0;
HorizontalLayout {
horizontal-stretch: 1;
padding: 20px;
spacing: 8px;
VerticalLayout {
horizontal-stretch: 1;
spacing: 2px;
Text {
text: "New Chat";
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-weight: 600;
}
Text {
text: "How can I help you today?";
color: Theme.muted-foreground;
font-size: Theme.font-size-xs;
}
}
Button {
icon: "refresh-cw";
outline: true;
size: ControlSize.small;
}
}
Rectangle {
width: 100%;
height: 1px;
background: Theme.border;
}
}
// The list sits in its own band. The scroller states
// `height: 100%`, so the band must already be the leftover
// height or the composer below is squeezed out.
Rectangle {
vertical-stretch: 1;
min-height: 0px;
background: transparent;
MessageScroller {
unread-index: root.unread;
scrolled-up: root.jumped-up;
messages: [
{ role: MessageRole.user, body: "You won't. Scrolling up releases tail following, so the reading position is preserved while new rows keep arriving below." },
{ role: MessageRole.user, body: "A jump-to-latest button appears once the reader leaves the tail; one click returns to the newest row and resumes following." },
{ role: MessageRole.user, body: "And loading older history when they reach the top?" },
{ role: MessageRole.user, body: "The application prepends those rows and the scroller keeps the visible anchor steady." },
{ role: MessageRole.assistant, body: "That is the anchor rule: the row the reader is looking at stays where it is." },
{ role: MessageRole.user, body: "So a burst of new rows never yanks the view." },
{ role: MessageRole.assistant, body: "Correct. Following resumes only when the reader returns to the tail." },
];
}
}
// Composer
VerticalLayout {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
Rectangle {
border-radius: Theme.radius-lg;
background: Theme.muted;
VerticalLayout {
padding: 8px;
spacing: 6px;
Input {
appearance: false;
placeholder: "Write a message…";
}
HorizontalLayout {
horizontal-stretch: 1;
alignment: end;
// Clipped to a circle, the way GPUI rounds its
// send button to `radius_full()`.
Rectangle {
width: 28px;
height: 28px;
border-radius: 14px;
clip: true;
Button {
width: 100%;
height: 100%;
icon: "arrow-up";
variant: ButtonVariant.primary;
size: ControlSize.small;
}
}
}
}
}
}
}
}
}There is no starting_position field or persisted scroll-offset API. Keep the
application’s saved message ID or index, then resolve it to the current index
after records are loaded. reset(...) replaces the known row count and
re-engages tail following; call scroll_to_item(...) after it when the product
needs a different initial row.
#Render rows and an empty state
// A row is a bubble, chosen by role: a sent turn is the muted surface at the
// trailing edge and a received turn is ghost text at the leading edge, so the
// component draws the rows itself from the turns. An empty list draws
// nothing, so the empty state stays with the application beside it.
let rows = panel(MessageScroller::new(Id::new("rows"), app.turns.clone()));
let empty = container(
column![
line("No messages yet", theme::FONT_SIZE_SM, |tokens| {
tokens.foreground
}),
line(
"Ask a question to start the thread.",
theme::FONT_SIZE_XS,
|tokens| tokens.muted_foreground,
),
]
.spacing(theme::SPACING_XS),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill);
let render = row![rows, empty].spacing(theme::SPACING_MD);Pass an indexed renderer. GPUI virtualizes the rows, so the closure is called for the rows needed by the current viewport and overdraw region:
let messages = Rc::new(messages.clone());
MessageScroller::new(
"conversation",
scroller.clone(),
move |index, _window, _cx| {
let Some(message) = messages.get(index) else {
return gpui_kit::div().into_any_element();
};
gpui_kit::div()
.id(("message-row", message.id))
.min_w_0()
.child(render_message(message))
.into_any_element()
},
)
.w_full()
.h_96()The row ID in this example belongs to the application. MessageScroller does
not retain an index-to-ID map; the ID lets an application-owned row keep its
own element-local state when data changes.
An empty list is valid (MessageScrollerState::new(0, cx)), but the scroller
does not invent an empty placeholder. Render the empty, loading, error, or
permission-denied state in the surrounding view and mount the scroller once
there are rows:
if messages.is_empty() {
empty_conversation_view.into_any_element()
} else {
MessageScroller::new("conversation", scroller.clone(), render_message)
.into_any_element()
}Rectangle {
width: 100%;
height: 200px;
border-width: 1px;
border-color: Theme.border;
border-radius: Theme.radius-lg;
clip: true;
background: Theme.background;
VerticalLayout {
alignment: center;
// `alignment` is the vertical axis here; without this the icon
// and the copy stay left-aligned inside the centred stack.
cross-axis-alignment: center;
spacing: 10px;
Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
background: Theme.muted;
Icon {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
name: "message-circle";
size: 20px;
color: Theme.muted-foreground;
}
}
Text {
text: "No messages yet";
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-weight: 600;
horizontal-alignment: center;
}
Text {
text: "Ask a question to start the thread.";
color: Theme.muted-foreground;
font-size: Theme.font-size-xs;
horizontal-alignment: center;
}
}
}Keep the empty state separate from the scroll region so it can provide a meaningful action such as “Start a new conversation” without pretending there is a message to scroll to.
#Append, streaming, and follow-tail behavior
// Appending while the reader follows the tail keeps the newest row in view;
// appending after they scroll up preserves their place and raises the fade
// and the control. iced owns the offset, so the component reports the
// crossing and the caller feeds it back through `scrolled_up`.
let tail = column![
panel(
MessageScroller::new(Id::new(CONVERSATION), app.turns.clone())
.scrolled_up(scrolled_up)
.unread_index(unread_index)
.on_scroll({
let flag = app.scrolled_up.clone();
move |scrolled_up| {
flag.set(scrolled_up);
Message::Scrolled(scrolled_up)
}
})
.on_jump(Message::JumpTo(CONVERSATION)),
),
row![
chip(
format!("received turn ({})", app.appended),
Message::Received
),
chip("sent turn", Message::Sent),
line(
"the newest turn stays in view only while the reader is at the tail",
theme::FONT_SIZE_XS,
|tokens| tokens.muted_foreground,
),
]
.spacing(theme::SPACING_SM)
.align_y(Axis::Center),
]
.spacing(theme::SPACING_SM);Update application data and virtual-list count together. Appending while the reader follows the tail keeps the latest row visible. Appending after the reader scrolls up preserves the reader’s position and makes the built-in jump button available:
messages.push(new_message);
scroller.update(cx, |state, cx| {
let _ = state.append(1, cx);
});
cx.notify();Streaming token growth changes a row’s height without changing the item count. Update the message body, then remeasure that row:
messages[index].body.push_str(next_token);
scroller.update(cx, |state, cx| {
let _ = state.remeasure_items(index..index + 1, cx);
});
cx.notify();remeasure_items(...) preserves an item anchor while recalculating the selected
rows. Use remeasure(...) after a global width, typography, or theme change
that can affect many row heights. When streaming creates a new message rather
than growing an existing one, call append(1, cx) first; remeasure the row
again if its first render and later content have different heights.
The state readers make the follow-tail decision visible to the surrounding view:
let following_tail = scroller.read(cx).is_following_tail();
let show_new_messages = scroller.read(cx).is_scrolled_up();is_following_tail() is true when the list follows appended content.
is_scrolled_up() is true when there is scrollable content below the current
viewport and the reader is away from the end. The component does not decide
whether to show a toast, unread count, or “new messages” copy; use these readers
to drive an application-owned indicator.
Resume following and move to the latest row explicitly:
scroller.update(cx, |state, cx| state.scroll_to_end(cx));description: "Append one assistant response, grow its text progressively, and remeasure only that row.";
HorizontalLayout {
horizontal-stretch: 1;
alignment: center;
Rectangle {
width: min(Theme.viewport - 48px, 384px);
height: 300px;
border-radius: Theme.radius-xl;
border-width: 1px;
border-color: Theme.border;
background: Theme.background;
clip: true;
Rectangle {
width: 100%;
height: 100%;
background: transparent;
MessageScroller {
messages: [
{ role: MessageRole.assistant, body: "Conversation message 4" },
{ role: MessageRole.assistant, body: "Message 5 wraps across more than one line to keep virtual-row measurement realistic." },
{ role: MessageRole.assistant, body: "Conversation message 6" },
{ role: MessageRole.user, body: "How does streaming preserve my position?" },
];
}
}
}
}Normal scrolling to the end also allows GPUI’s list to resume tail following.
#Prepend earlier history
// Older records are the application's to load and splice in at the front.
// The region keeps its anchor, so the turns the reader is looking at do not
// move when earlier ones arrive above them.
let history = panel(MessageScroller::new(Id::new("history"), app.turns.clone()));
let prepend = column![
history,
chip(
format!("load earlier ({})", app.prepended),
Message::Prepend,
),
line(
"a prepend inserts above the anchor, so the visible turn stays put",
theme::FONT_SIZE_XS,
|tokens| tokens.muted_foreground,
),
]
.spacing(theme::SPACING_SM);Insert older records at the front and tell the state the number of inserted
rows. prepend(...) uses GPUI list splicing to preserve the visible item
anchor:
let earlier_messages = load_earlier_messages();
let count = earlier_messages.len();
messages.splice(0..0, earlier_messages);
scroller.update(cx, |state, cx| {
let _ = state.prepend(count, cx);
});
cx.notify();Rectangle {
width: 100%;
height: 200px;
border-width: 1px;
border-color: Theme.border;
border-radius: Theme.radius-lg;
clip: true;
MessageScroller {
messages: [
{ role: MessageRole.assistant, body: "… 48 earlier messages loaded above." },
{ role: MessageRole.user, body: "Where were we?" },
{ role: MessageRole.assistant, body: "Picking a framework. Both ports render the same gallery." },
];
}
}Use splice(old_range, count, cx) for replacements or deletions elsewhere in
the collection. The range is half-open and must stay within the current item
count; invalid ranges return false and leave the state unchanged.
For a “Load earlier” control, keep the loading state in the application, fetch
the records, splice the vector, then call prepend. Do not call reset for
ordinary history pagination because reset intentionally returns to tail
following and loses the incremental anchor semantics.
#Unread and arbitrary navigation
// The unread rule is the index of the first turn the reader has not seen — a
// value the application owns, because it is the one that knows an unread,
// searched or bookmarked identity. iced 0.14 exposes only `jump_to_end`, so
// arbitrary scroll-to-index has no counterpart here; the rule marks the
// boundary and the jump returns to the tail.
let navigation = panel(
MessageScroller::new(Id::new("navigation"), app.turns.clone()).unread_index(unread_index),
);
let navigating = column![
navigation,
chip(
if app.unread {
"unread rule on"
} else {
"unread rule off"
},
Message::ToggleUnread,
),
]
.spacing(theme::SPACING_SM);Unread identity belongs to the application. Resolve a stable message ID to the
current vector index, then use scroll_to_item(...):
if let Some(index) = messages.iter().position(|message| message.id == unread_id) {
scroller.update(cx, |state, cx| {
let _ = state.scroll_to_item(index, cx);
});
}scroll_to_item(...) returns false for an out-of-range index. It is the
single navigation primitive: an unread boundary, a search result, a bookmarked
message, a reply target, and a deep link all resolve to an index in the
application first.
The current API does not expose turn anchors, peek previews, visible IDs, or stable-ID navigation. Map domain IDs to the current index in the application; keep an index map if lookup cost matters. The scroller does not know whether a row is a turn, a reply, an unread boundary, or a search result.
#Dynamic row heights and structural updates
Rows may contain multiline text, attachments, streamed content, or an application-owned composer and can therefore have different heights. The underlying GPUI list measures rendered rows. Keep the renderer’s height-affecting data in the owning view and notify the state after a mutation:
| Change | State operation |
|---|---|
| Add rows at the tail | append(count, cx) |
| Add rows at the front | prepend(count, cx) |
| Replace/delete a range | splice(range, count, cx) |
| Token growth in known rows | remeasure_items(range, cx) |
| Global width/font/theme change | remeasure(cx) |
| Replace the whole conversation | reset(item_count, cx) |
Do not mutate the vector length without the matching state operation. The renderer receives an index, so data and virtual-list count must remain aligned for the same render pass.
#Jump-to-latest controls
// The control is drawn while `scrolled_up` is true and reports the press; the
// caller runs `jump_to_end` on the region's id, because iced owns the offset.
// Asking for `scrolled_up(true)` here is what draws the control without a
// scroll, the way the Slint example sets `scrolled-up`.
let jump = panel(
MessageScroller::new(Id::new("jump"), app.turns.clone())
.scrolled_up(true)
.jump_button(app.jump_button)
.on_jump(Message::JumpTo("jump")),
);
let jumping = column![
jump,
chip(
if app.jump_button {
"built-in control on"
} else {
"built-in control off"
},
Message::ToggleJumpButton,
),
]
.spacing(theme::SPACING_SM);The built-in jump button is enabled by default and appears when
is_scrolled_up() becomes true. It is a configured Button with a secondary
variant, icon-button sizing, full radius, arrow-down icon, theme border/background,
and a localized tooltip label. It keeps the scroll action owned by the state:
MessageScroller::new("conversation", scroller.clone(), render_message)
.with_jump_button_label("Jump to newest")
.with_jump_button_transition(Duration::from_millis(250))Use Duration::ZERO to disable the enter/leave transition. Reduced-motion
preferences use the final state immediately regardless of the configured
duration.
Refine its style after the built-in defaults:
MessageScroller::new("conversation", scroller.clone(), render_message)
.with_jump_button_style(
StyleRefinement::default()
.bg(cx.theme().primary)
.border_color(cx.theme().primary)
.text_color(cx.theme().primary_foreground),
)Use the renderer callback when the application needs a different Button
variant, size, icon, or instance style. The callback receives the fully
configured button and must return a Button; the built-in scroll action stays
attached:
MessageScroller::new("conversation", scroller.clone(), render_message)
.with_jump_button_renderer(|button| button.outline().small().label("Latest"))During its leave transition the built-in button is rendered disabled while its
opacity reaches zero. A renderer should preserve that state rather than force a
disabled button to be active. The button’s accessible name comes from its
.label(...) value; with_jump_button_label(...) supplies the tooltip label
only. Set a visible label in with_jump_button_renderer(...) when the jump
action needs a named accessible control. The current public renderer callback
can change Button styling and content, but it does not expose a separate
accessibility-label builder.
Disable the built-in affordance when the surrounding view provides its own:
MessageScroller::new("conversation", scroller.clone(), render_message)
.jump_button(false)Rectangle {
width: 100%;
height: 240px;
border-width: 1px;
border-color: Theme.border;
border-radius: Theme.radius-lg;
clip: true;
MessageScroller {
scrolled-up: true;
messages: [
{ role: MessageRole.user, body: "Scroll up to read the history." },
{ role: MessageRole.assistant, body: "The fade marks the bottom edge while the newest turn is out of view." },
{ role: MessageRole.user, body: "What does the round control do?" },
{ role: MessageRole.assistant, body: "It returns the viewport to the end of the conversation." },
];
}
}Compose an application-owned button from is_scrolled_up() and
scroll_to_end(...) when its placement, text, or accessibility contract needs
to be product-specific.
#Scrollbar and style slots
// The builder carries the style slots the page documents: the list inset, the
// muted bubble's width cap and the scrollbar. A host-drawn control is
// `jump_button(false)` plus an application button that runs `jump_to_end` on
// the same id, which is why this frame keeps `scrolled_up` true.
let slots = panel(
MessageScroller::new(Id::new("slots"), app.turns.clone())
.scrolled_up(true)
.jump_button(false)
.scrollbar(false)
.padding(theme::SPACING_LG)
.max_bubble_width(300.0),
);
let latest = Button::new("Latest")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Small)
.on_press(Message::JumpTo("slots"));
let styled = column![slots, latest].spacing(theme::SPACING_SM);The root implements Styled, and the internal regions have separate style
refinements:
MessageScroller::new("conversation", scroller.clone(), render_message)
.p_2()
.bg(cx.theme().group_box)
.with_content_style(StyleRefinement::default().bg(cx.theme().background))
.with_list_style(StyleRefinement::default().p_4())
.with_row_style(StyleRefinement::default().pb_6())
.scrollbar(false)Rectangle {
width: 100%;
height: 200px;
border-width: 1px;
border-color: Theme.border;
border-radius: Theme.radius-lg;
clip: true;
MessageScroller {
jump-button: false;
messages: [
{ role: MessageRole.user, body: "Give me the short version." },
{ role: MessageRole.assistant, body: "Copy a component, import it, and it runs." },
{ role: MessageRole.user, body: "That is short." },
{ role: MessageRole.assistant, body: "The long version is on the Dock page." },
];
}
HorizontalLayout {
x: 0px;
// The row states its own height: a layout left unconstrained
// fills its parent, and `self.height` would then be the whole
// frame, putting the pill at the top.
y: parent.height - self.height - 12px;
width: parent.width;
height: 28px;
padding-right: 12px;
// `end` keeps the pill at its own size; a bare rectangle with no
// width stretches to the frame and pushes its label off-centre.
alignment: end;
Rectangle {
height: 28px;
border-radius: 14px;
border-width: 1px;
border-color: Theme.border;
background: Theme.background;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 6px;
// The cross axis centres the glyph and the label on the
// pill's own height.
cross-axis-alignment: center;
Icon {
name: "arrow-down";
size: 13px;
color: Theme.foreground;
}
Text {
text: "Latest";
color: Theme.foreground;
font-size: Theme.font-size-xs;
vertical-alignment: center;
}
}
}
}
}The boundaries are:
- Root
Styledmethods refine the full-width element that owns the viewport. with_content_style(...)refines the viewport containing the list and optional vertical scrollbar.with_list_style(...)refines the GPUI virtual list after its defaultpx_3()/py_2()padding. GPUI lists offset rows only vertically, so the horizontal padding component — the default and any refinement — is carried by every row wrapper.with_row_style(...)refines the full-width wrapper around each rendered row; the default wrapper includespb_8()between rows, like a CSS gap. The list’s own bottom padding owns the gap between the last row and whatever sits below the transcript.scrollbar(false)hides the built-in vertical scrollbar; it does not disable scrolling or remove keyboard/wheel interaction.with_bottom_fade(color)fades the transcript’s bottom edge into the given color, so a partially visible row melts into the surrounding surface instead of clipping mid-line. It shows only while the reader is away from the live edge — at the bottom nothing is clipped. Pass the color of the surface behind the scroller; the fade is off by default.
Use theme roles such as group_box, background, border, and foreground
for custom surfaces. Keep content padding in the surrounding conversation
shell when it belongs to the shell’s header/composer relationship; use list or
row style when it belongs to every transcript row.
#Virtualization, accessibility, and application boundaries
// The first example is the one the reference leads with: the whole
// conversation shell. The scroller owns the transcript band and nothing
// else. The shell around it — the header with its circular restart, and the
// composer with its circular send — is the application's, which is the
// boundary this heading documents when it places the composer outside the
// list. The rows the scroller draws are unchanged: a sent turn is the muted
// bubble at the trailing edge and a received turn is the ghost variant,
// which is plain text on the surface with no fill, border, radius or
// padding.
let restart = Button::new("")
.outline()
.icon(icon(
REFRESH_CW,
ControlSize::Medium.icon_size(),
|tokens| tokens.foreground,
))
.rounded(CIRCLE_RADIUS)
.on_press(Message::Restart);
let header = container(
row![
column![
text("New Chat").size(theme::FONT_SIZE_SM).font(iced::Font {
weight: iced::font::Weight::Semibold,
..iced::Font::DEFAULT
}),
line("How can I help you today?", theme::FONT_SIZE_XS, |tokens| {
tokens.muted_foreground
}),
]
.spacing(theme::SPACING_XS)
.width(Length::Fill),
restart,
]
.spacing(theme::SPACING_SM)
.align_y(Axis::Center),
)
.width(Length::Fill)
.padding(SHELL_PADDING);
let send = Button::new("")
.variant(ButtonVariant::Primary)
.icon(icon(ARROW_UP, ControlSize::Medium.icon_size(), |tokens| {
tokens.primary_foreground
}))
.rounded(CIRCLE_RADIUS)
.on_press(Message::Send);
// The composer is a borderless field on the muted surface, with the send
// control on the trailing edge — GPUI's `Input.appearance(false)` inside a
// `rounded_xl()` `bg(muted)` column, and the Slint example's composer.
let composer = container(
column![
Input::new("composer")
.value(app.composer.clone())
.placeholder("Write a message…")
.appearance(false)
.on_input(Message::ComposerChanged)
.on_submit(Message::Send),
container(send).width(Length::Fill).align_x(Axis::End),
]
.spacing(theme::SPACING_XS),
)
.width(Length::Fill)
.padding(theme::SPACING_SM)
.style(|theme: &Theme| {
let tokens = Tokens::of(theme);
container::Style {
background: Some(tokens.muted.into()),
text_color: Some(tokens.foreground),
border: iced::border::rounded(theme::RADIUS_XL),
..container::Style::default()
}
});
let shell = container(
column![
header,
hairline(),
MessageScroller::new(Id::new("shell"), app.turns.clone())
.unread_index(unread_index)
.scrolled_up(scrolled_up)
.on_scroll({
let flag = app.scrolled_up.clone();
move |scrolled_up| {
flag.set(scrolled_up);
Message::Scrolled(scrolled_up)
}
})
.on_jump(Message::JumpTo("shell")),
container(composer).width(Length::Fill).padding(Padding {
top: 0.0,
right: SHELL_PADDING,
bottom: SHELL_PADDING,
left: SHELL_PADDING,
}),
]
// The column owns the frame's height so the transcript band, a `Fill`
// child, receives the space the header and composer leave.
.height(Length::Fill),
)
.width(SHELL_WIDTH)
.height(SHELL_HEIGHT)
.clip(true)
.style(|theme: &Theme| {
let tokens = Tokens::of(theme);
container::Style {
background: Some(tokens.background.into()),
text_color: Some(tokens.foreground),
border: iced::border::rounded(theme::RADIUS_XL)
.color(tokens.border)
.width(1.0),
..container::Style::default()
}
});
let boundaries = column![
row![
Button::new("Append").on_press(Message::Received),
Button::new("Prepend history").on_press(Message::Prepend),
]
.spacing(theme::SPACING_SM),
line(
"the scroller owns the transcript band; the header, the composer and \
their controls are the application's",
theme::FONT_SIZE_XS,
|tokens| tokens.muted_foreground,
),
shell,
]
.spacing(theme::SPACING_MD);MessageScroller delegates viewport layout, variable-height measurement,
scroll anchoring, and overdraw to GPUI’s ListState. Only visible rows and the
configured overdraw region need rendering. Keep row closures deterministic and
avoid doing network work or mutating the message collection during rendering.
For keyboard and screen-reader behavior:
- Keep the scroller inside a layout with a real height and
min_h_0()so the scroll region can receive wheel and keyboard navigation. - The transcript viewport announces itself as a log region (
Role::Log), so assistive technology can treat appended rows as live additions.- Wheel scrolling over the transcript is contained: while the list can move, the event never scrolls an ancestor scroller; at the top or bottom edge it chains to the ancestor, matching platform scroll containers. - Give rows meaningful text and stable application IDs; an index by itself is not a user-facing label.
- Give the jump control an explicit visible label when it must be exposed as a named accessible action. Its tooltip is supplemental.
- Place “Load earlier”, retry, composer, and unread controls outside the list in
semantic
ButtonorLinkcontrols. - Keep empty, loading, error, and permission states readable without relying on animation or scrollbar position.
The component intentionally has no React-style Provider, Viewport, Content, or Item exports. GPUI’s list already supplies those layers; an indexed renderer is the item boundary. It also has no turn-anchor, peek, visible-range, or stable-ID API. Those concepts vary by product and belong in the application model around this component.
#API reference
#MessageScrollerState
| Method | Default/return | Purpose |
|---|---|---|
new(item_count, cx) | tail following enabled | Create state for the current row count. |
item_count() | current count | Read the virtual-list row count. |
is_scrolled_up() | false until away from tail | Report whether a jump/new-content affordance is useful. |
is_following_tail() | true initially | Report whether appended rows are followed. |
reset(item_count, cx) | re-engages tail | Replace the row count and reset list state. |
splice(range, count, cx) | true if valid | Replace a half-open range while preserving list bookkeeping. |
append(count, cx) | splice at tail | Add rows at the end. |
prepend(count, cx) | splice at index 0 | Add earlier rows while preserving the current anchor. |
remeasure(cx) | — | Remeasure all rows after global layout changes. |
remeasure_items(range, cx) | true if valid | Remeasure selected dynamic rows. |
scroll_to_item(index, cx) | false if out of range | Navigate to an arbitrary row index. |
scroll_to_end(cx) | tail following enabled | Move to the latest row and resume following. |
#MessageScroller
| Method | Default | Purpose |
|---|---|---|
new(id, state, renderer) | scrollbar and jump button enabled | Create a virtualized scroller. |
scrollbar(bool) | true | Show or hide the internal scrollbar. |
jump_button(bool) | true | Show or hide the built-in jump control. |
with_jump_button_label(label) | Jump to latest | Set the jump tooltip/localized label. |
with_content_style(style) | empty refinement | Style the viewport and scrollbar region. |
with_list_style(style) | list px_3() / py_2() | Style the virtual list. |
with_row_style(style) | row pb_8() | Style every rendered row wrapper. |
with_jump_button_style(style) | themed secondary button | Refine the built-in button. |
with_jump_button_renderer(callback) | default Button | Adjust the configured button while keeping its action. |
with_jump_button_transition(duration) | 200 ms | Set enter/leave duration; reduced motion skips it. |
with_bottom_fade(color) | off | Fade the bottom edge into the surrounding surface color. |
Styled methods | full-size, clipped root | Style the outer scroller element. |
#ChatTurn
| Name | Kind | Type | Default |
|---|---|---|---|
role | field | MessageRole | |
body | field | string |
#MessageScroller
A conversation that stays pinned to the newest turn. Mirrors GPUI Component's `MessageScroller`. Rows are bubbles, not `Message`: GPUI's story renders "no avatars or author names, sent rows on a muted surface, received rows as ghost text", and a `Bubble` is what draws that. Slint cannot read a scroll position, so the application owns whether the view is at the newest turn: it sets `scrolled-up`, and this component shows the bottom fade and the jump control from that. GPUI derives the same two values from the list state it owns.
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
messages | in-out | [ChatTurn] | ||
unread-index | in | int | -1 | Draws the "Unread" rule before this row. -1 draws none. |
scrolled-up | in | bool | false | True while the newest turn is out of view. |
jump-button | in | bool | true | |
list-padding | in | length | 20px | The inset around the list. GPUI's story passes `p_5` here. |
jump-requested | callback | () | Called when the person asks to return to the newest turn. |