///|
/// Live scroll state owned by the markdown editor element.
///
/// The app should not receive a message per wheel tick: that turns a scroll
/// gesture into a stream of root rebuilds while the content itself only moves
/// inside the editor's paint. The element keeps the glide here, paints it from
/// the frame clock, and reports one settled offset at the end of the gesture.
///
/// The slot survives root rebuilds while the editor element identity is reused
/// (the app keys the editor), so a settle message and the following rebuild do
/// not reset the visible position.
priv struct MarkdownSessionScrollState {
/// The eased offset. `from_y == to_y` means at rest.
glide : MarkdownScrollGlide
/// Monotonic `ScrollRequest` cursor. `-2` means the slot has never been
/// seeded from the node's controlled glide; `-1` means seeded with no
/// request applied.
applied_request_id : Int32
/// Motion happened and the settled notification has not fired yet.
settle_pending : Bool
/// The settled notification already fired for this gesture.
settle_notified : Bool
/// Animation clock time of the most recent wheel input, used to classify a
/// dense stream and to debounce the settle notification.
last_motion_ms : Double
}
///|
/// A wheel event closer than this to the previous one belongs to one stream
/// (trackpad gesture or platform momentum). Mirrors the MoMark app's previous
/// classification so the feel does not change with ownership.
fn markdown_session_scroll_stream_gap_ms() -> Double {
24.0
}
///|
/// After the glide reaches its target, wait this long for another wheel event
/// before reporting the settled offset. A momentum tail emits events faster
/// than this; a finished gesture does not.
fn markdown_session_scroll_settle_gap_ms() -> Double {
80.0
}
///|
/// Time constant for a dense stream: just enough smoothing to land the OS's
/// irregular event cadence on the display's frame grid.
fn markdown_session_scroll_stream_tau_ms() -> Double {
28.0
}
///|
/// Time constant for an isolated wheel notch: a visible ease the user can aim
/// with.
fn markdown_session_scroll_notch_tau_ms() -> Double {
90.0
}
///|
impl @core.ViewStateValue for MarkdownSessionScrollState with fn view_state_type_key() {
@core.DeclarationKey::string("moui_richtext.MarkdownSessionScrollState.v1")
}
///|
impl @core.ViewStateValue for MarkdownSessionScrollState with fn encode_view_state(
self,
) {
let output = Buffer(size_hint=46)
output.write_double_be(self.glide.from_y)
output.write_double_be(self.glide.to_y)
output.write_double_be(self.glide.start_ms)
output.write_double_be(self.glide.tau_ms)
output.write_int_be(self.applied_request_id)
output.write_byte(if self.settle_pending { 1 } else { 0 })
output.write_byte(if self.settle_notified { 1 } else { 0 })
output.write_double_be(self.last_motion_ms)
output.to_bytes()
}
///|
impl @core.ViewStateValue for MarkdownSessionScrollState with fn decode_view_state(
_self,
bytes,
) {
if bytes.length() != 46 {
return None
}
match
(
@core.ViewStateValue::decode_view_state(0.0, bytes[0:8].to_owned()),
@core.ViewStateValue::decode_view_state(0.0, bytes[8:16].to_owned()),
@core.ViewStateValue::decode_view_state(0.0, bytes[16:24].to_owned()),
@core.ViewStateValue::decode_view_state(0.0, bytes[24:32].to_owned()),
@core.ViewStateValue::decode_view_state(0, bytes[32:36].to_owned()),
@core.ViewStateValue::decode_view_state(0.0, bytes[38:46].to_owned()),
) {
(
Some(from_y),
Some(to_y),
Some(start_ms),
Some(tau_ms),
Some(id),
Some(last_motion_ms),
) =>
Some({
glide: { from_y, to_y, start_ms, tau_ms, },
applied_request_id: id,
settle_pending: bytes[36].to_int() != 0,
settle_notified: bytes[37].to_int() != 0,
last_motion_ms,
})
_ => None
}
}
///|
fn markdown_session_scroll_state_slot() -> @core.ViewStateSlot[
MarkdownSessionScrollState,
] {
@core.ViewStateSlot::new(
key=@core.DeclarationKey::string("moui_richtext.editor.scroll"),
initial={
glide: MarkdownScrollGlide::settled(0.0),
applied_request_id: -2,
settle_pending: false,
settle_notified: true,
last_motion_ms: 0.0,
},
)
}
///|
/// Seed the slot from the controlled glide on first use and apply a newer
/// programmatic scroll request. Read-only: callers that paint use this without
/// mutating the slot; event handlers write the returned value back.
fn markdown_session_effective_scroll_state(
stored : MarkdownSessionScrollState,
seed_glide : MarkdownScrollGlide,
scroll_request : @core.ScrollRequest?,
max_scroll~ : Double,
) -> MarkdownSessionScrollState {
let base = if stored.applied_request_id == -2 {
{ ..stored, glide: seed_glide, applied_request_id: -1, }
} else {
stored
}
match scroll_request {
Some(request) if request.id > base.applied_request_id =>
{
glide: MarkdownScrollGlide::settled(
request.offset.y.max(0.0).min(max_scroll),
),
applied_request_id: request.id,
settle_pending: false,
settle_notified: true,
last_motion_ms: 0.0,
}
_ => base
}
}
///|
/// Maximum scroll offset for the editor's current document and viewport.
fn markdown_session_max_scroll_y(
session : MarkdownDocumentSession,
font : @core.FontSpec,
source_mode~ : Bool,
viewport_height : Double,
content_width : Double,
) -> Double {
if viewport_height <= 0.0 {
return 0.0
}
let content_height = session.estimated_content_height(
base=font,
source_mode~,
content_width~,
) +
96.0
(content_height - viewport_height).max(0.0)
}
///|
/// Apply one wheel delta to the live state. Returns `None` when the offset is
/// already at a hard edge and nothing is moving, so the host can use the
/// platform's edge behavior.
fn markdown_session_transient_wheel(
state : MarkdownSessionScrollState,
wheel_delta_y : Double,
now_ms : Double,
max_scroll : Double,
) -> MarkdownSessionScrollState? {
if wheel_delta_y == 0.0 {
return None
}
let shown = state.glide.exact_offset_at(now_ms).max(0.0).min(max_scroll)
let target = (state.glide.to_y - wheel_delta_y).max(0.0).min(max_scroll)
let moving = state.glide.is_animating(now_ms) || state.settle_pending
if target == shown && !moving {
return None
}
let since_last = now_ms - state.last_motion_ms
let continuous = state.last_motion_ms > 0.0 &&
since_last >= 0.0 &&
since_last <= markdown_session_scroll_stream_gap_ms() &&
moving
let glide = MarkdownScrollGlide::new(
from_y=shown,
to_y=target,
start_ms=now_ms,
tau_ms=if continuous {
markdown_session_scroll_stream_tau_ms()
} else {
markdown_session_scroll_notch_tau_ms()
},
)
Some({
glide,
applied_request_id: state.applied_request_id,
settle_pending: true,
settle_notified: false,
last_motion_ms: now_ms,
})
}
///|
/// Freeze the glide at the offset currently shown. Called before a text edit so
/// content reflow starts from the position the user sees, not from a target
/// that may still be easing.
fn markdown_session_pin_scroll(
state : MarkdownSessionScrollState,
now_ms : Double,
max_scroll : Double,
) -> MarkdownSessionScrollState {
if !state.glide.is_animating(now_ms) && !state.settle_pending {
return state
}
let shown = state.glide.exact_offset_at(now_ms).max(0.0).min(max_scroll)
{
glide: MarkdownScrollGlide::settled(shown),
applied_request_id: state.applied_request_id,
settle_pending: false,
settle_notified: true,
last_motion_ms: state.last_motion_ms,
}
}
///|
/// If the glide has reached its target and the stream has been quiet long
/// enough, clear the settle latch and return the offset to report exactly once.
fn markdown_session_transient_settle(
state : MarkdownSessionScrollState,
now_ms : Double,
max_scroll : Double,
) -> (MarkdownSessionScrollState, Double?) {
if !state.settle_pending || state.glide.is_animating(now_ms) {
return (state, None)
}
if now_ms - state.last_motion_ms < markdown_session_scroll_settle_gap_ms() {
return (state, None)
}
let shown = state.glide.exact_offset_at(now_ms).max(0.0).min(max_scroll)
({ ..state, settle_pending: false, settle_notified: true, }, Some(shown))
}
///|
/// Event entry for the transient-scroll editor mode.
///
/// Wheel input writes only the element slot: `changed=true` keeps the host
/// consuming the wheel, `activated=false` and an empty message list keep the
/// program root untouched. Pointer hit-testing, keyboard caret moves and text
/// input use the same frame-local slot sample as paint. A `FrameTick` reports
/// the offset once after the gesture settles.
fn[Msg] markdown_session_editor_event_transient(
ctx : @core.ViewEventContext,
session~ : MarkdownDocumentSession,
selection~ : MarkdownEditorSelection,
on_transaction~ : (MarkdownEditTransaction) -> Msg,
font~ : @core.FontSpec,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
on_selection_change~ : ((Int32, @core.TextRange?) -> Msg)?,
on_prefix_click~ : ((Int32) -> Msg)?,
on_command_click~ : ((Int32) -> Msg)?,
accepts_tab_input~ : Bool,
seed_glide~ : MarkdownScrollGlide,
scroll_request~ : @core.ScrollRequest?,
on_scroll_settled~ : ((Double) -> Msg)?,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewEventResult[Msg] {
let slot = markdown_session_scroll_state_slot()
let content_width = rich_text_editor_content_rect(ctx.frame).size.width
let max_scroll = markdown_session_max_scroll_y(
session,
font,
source_mode~,
visible_viewport_height,
content_width,
)
let stored = markdown_session_effective_scroll_state(
ctx.state.read_slot(slot),
seed_glide,
scroll_request,
max_scroll~,
)
match ctx.event {
Pointer(event) =>
if ctx.frame.contains(event.position) &&
(event.wheel_delta.x != 0.0 || event.wheel_delta.y != 0.0) {
let mut state = stored
let compensation = markdown_scroll_take_geometry_compensation()
if compensation != 0.0 {
state = {
..state,
glide: state.glide.with_anchor_compensation(
compensation,
max_scroll,
now_ms=ctx.now_ms,
),
}
}
match
markdown_session_transient_wheel(
state,
event.wheel_delta.y,
ctx.now_ms,
max_scroll,
) {
Some(next) =>
return view_event_result(
changed=true,
captured=true,
state=Some(ctx.state.with_slot(slot, next)),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
)
None => return @core.ViewEventResult::ignored()
}
}
FrameTick(frame) => {
let (next, settled) = markdown_session_transient_settle(
stored,
frame.time_ms,
max_scroll,
)
if next.settle_pending == stored.settle_pending {
return @core.ViewEventResult::ignored()
}
let messages : Array[Msg] = match (settled, on_scroll_settled) {
(Some(y), Some(handler)) => [handler(y)]
_ => []
}
return view_event_result(
state=Some(ctx.state.with_slot(slot, next)),
messages~,
)
}
_ => ()
}
let visible_scroll_y = stored.glide.display_offset(
ctx.now_ms,
compensation=markdown_scroll_peek_geometry_compensation(),
)
let result = markdown_session_editor_event(
ctx,
session~,
selection~,
on_transaction~,
font~,
source_mode~,
focus_mode~,
base_dir~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
accepts_tab_input~,
visible_scroll_y~,
on_scroll=None,
visible_viewport_height~,
visible_overscan~,
)
match ctx.event {
Keyboard(_) | TextInput(_) => {
let pinned = markdown_session_pin_scroll(stored, ctx.now_ms, max_scroll)
let base = result.state.unwrap_or(ctx.state)
{ ..result, state: Some(base.with_slot(slot, pinned)), }
}
_ => result
}
}