///|
fn markdown_session_clamped_scroll_y(
session : MarkdownDocumentSession,
font : @core.FontSpec,
source_mode~ : Bool,
scroll_y : Double,
viewport_height : Double,
content_width? : Double = 0.0,
) -> Double {
if viewport_height <= 0.0 {
return max_double(0.0, scroll_y)
}
let content_height = session.estimated_content_height(
base=font,
source_mode~,
content_width~,
) +
96.0
let max_scroll = max_double(0.0, content_height - viewport_height)
clamp_double(scroll_y, 0.0, max_scroll)
}
///|
fn markdown_session_display_session(
session : MarkdownDocumentSession,
display_text : String,
) -> MarkdownDocumentSession {
if display_text == session.source() {
session
} else {
// Composition is live: paints and host IME polls re-derive this
// session with the same display text until the candidate changes,
// so memoize the full parse instead of re-parsing every poll.
markdown_document_session_parse_cached(display_text)
}
}
///|
fn markdown_session_visible_window(
session : MarkdownDocumentSession,
selection : MarkdownEditorSelection,
font : @core.FontSpec,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
visible_scroll_y~ : Double,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
content_width? : Double = 0.0,
text_system? : @core.TextSystem = @core.TextSystem::fallback(),
) -> MarkdownDocumentRichTextWindow {
let scroll_y = markdown_session_clamped_scroll_y(
session,
font,
source_mode~,
visible_scroll_y,
visible_viewport_height,
content_width~,
)
let window = session.rich_text_window(
selection,
base=font,
source_mode~,
focus_mode~,
base_dir~,
scroll_y~,
viewport_height=visible_viewport_height,
overscan=visible_overscan,
content_width~,
)
// Paint, caret, hit-test, selection, and the measured-height feed all
// consume this one soft-wrapped document, so a click lands the caret
// exactly where the text was drawn (ADR 0001).
if source_mode || content_width <= 0.0 {
window
} else {
{
..window,
document: rich_text_document_soft_wrap(
window.document,
content_width,
font,
text_system,
),
}
}
}
///|
/// Input transform bound to a session: hands the chain the session's
/// current block structure and text store so code-block detection reads
/// block ranges instead of re-parsing the whole document per keystroke.
fn markdown_session_input_transform_for(
session : MarkdownDocumentSession,
split_paragraph_on_enter~ : Bool,
) -> (String, Int, @core.TextRange?, String) -> RichTextInputTransform? {
let context : MarkdownEditorInputContext = {
blocks: session.editor_blocks,
store: session.text_store,
}
fn(
source : String,
caret : Int,
range : @core.TextRange?,
inserted : String,
) {
markdown_editor_transform_input(
source,
caret,
range,
inserted,
context=Some(context),
split_paragraph_on_enter~,
)
}
}
///|
fn[Msg] markdown_session_on_input_edit(
session : MarkdownDocumentSession,
on_transaction : (MarkdownEditTransaction) -> Msg,
) -> (String, Int, @core.TextRange?, RichTextInputEdit?) -> Msg {
(next_source, caret, next_selection, edit) => {
let transaction = match edit {
// The control knows the exact range it replaced; carrying it
// out skips the whole-document prefix/suffix diff that
// `from_sources` performs to recover it.
Some(edit) =>
MarkdownEditTransaction::from_edit(
session.source(),
next_source,
edit.start,
deleted=edit.deleted,
replacement=edit.replacement,
caret~,
selection=next_selection,
reason=TextInput,
)
None =>
MarkdownEditTransaction::from_sources(
session.source(),
next_source,
caret~,
selection=next_selection,
reason=TextInput,
)
}
on_transaction(transaction)
}
}
///|
fn markdown_session_editor_paint(
ctx : @core.ViewPaintContext,
session~ : MarkdownDocumentSession,
selection~ : MarkdownEditorSelection,
placeholder~ : String,
font~ : @core.FontSpec,
foreground~ : @core.Color,
placeholder_color~ : @core.Color,
background~ : @core.Brush,
border~ : @core.BorderStyle?,
focused_border~ : @core.BorderStyle?,
cursor_color~ : @core.Color,
corner_radius~ : Double,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
visible_scroll_y~ : Double,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> (@core.ViewPaintPlan, Double) {
let commands : Array[@core.DrawCommand] = []
let overlay_commands : Array[@core.DrawCommand] = []
let rounded = @core.RoundedRect::new(rect=ctx.frame, radius=corner_radius)
commands.push(FillRoundedRectBrush(rounded, background))
append_rich_text_border(
commands,
rounded,
if ctx.focused {
focused_border
} else {
border
},
)
let text = session.source()
let display_text = rich_text_with_composition(
text,
ctx.text_control,
focused=ctx.focused,
)
let display_session = markdown_session_display_session(session, display_text)
let content = rich_text_editor_content_rect(ctx.frame)
commands.push(PushClip(content))
let child_layers : Array[@core.ViewPaintLayer] = []
let mut geometry_compensation = 0.0
if display_text == "" {
commands.push(
DrawText(
@core.TextRun::new(
text=placeholder,
frame=content,
font~,
color=placeholder_color,
align=TextStart,
),
),
)
} else {
let mut window = markdown_session_visible_window(
display_session,
selection,
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
content_width=content.size.width,
text_system=ctx.text_system,
)
if display_text == session.source() &&
!source_mode &&
content.size.width > 0.0 {
geometry_compensation = markdown_session_record_measured_block_heights(
session,
window,
content_width=content.size.width,
base=font,
base_dir~,
text_system=ctx.text_system,
visible_scroll_y~,
)
// Measurement commits geometry during paint. Recompute the window for
// this same frame after an above-anchor change so its prefix and block
// positions use the same geometry as the compensated scroll offset.
// Without this second lookup, one frame combines new heights with the
// old top padding and reads as a small rebound before the next repaint.
if geometry_compensation != 0.0 {
window = markdown_session_visible_window(
display_session,
selection,
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y=visible_scroll_y + geometry_compensation,
visible_viewport_height~,
visible_overscan~,
content_width=content.size.width,
text_system=ctx.text_system,
)
}
}
// Derive the paint-space content rect only after measurement has settled
// the final window. Keeping the rect from the pre-measurement window
// combines the new prefix with the old offset and produces a one-frame
// reverse-looking jump when a block above the anchor grows or shrinks.
let window_content = window.content_rect(content)
// Position blocks inside the offset `window_content`, but cull against
// the original `content` viewport. Without this, the non-windowed cull
// uses `window_content.origin.y + height` which is shifted up by
// `top_padding - scroll_y`, cutting off the bottom of the viewport.
append_rich_text_document_layers(
child_layers,
window.document,
window_content,
font,
foreground,
text_system=ctx.text_system,
clip_rect=Some(content),
// Blocks are already pre-positioned into paint space, so the band is the
// clip plus overscan and no extra scroll. Passing the viewport height is
// what turns the windowed path on; without it the builder falls back to
// culling whole blocks against the clip, which never culls a block
// taller than the viewport — and a soft-wrapped paragraph holding a
// whole novel is exactly that.
visible_scroll_y=0.0,
visible_viewport_height=content.size.height,
)
if ctx.focused {
// The clipped window may not contain the caret (parked off-screen
// while the user scrolls). The document walk pins such a caret to
// the first rendered line — the "caret at the very front" defect —
// so the session-level estimate, which answers for any offset,
// stands in whenever the window does not cover the caret.
let paint_caret = @core.clamp_int(
rich_text_visual_caret(text, ctx.text_control, focused=true),
0,
text_length(display_text),
)
let caret_rect_override = if markdown_session_window_covers_caret(
window, paint_caret,
) {
None
} else {
Some(
markdown_session_estimated_caret_rect(
display_session,
paint_caret,
content,
font,
source_mode~,
scroll_y=window.scroll_y,
base_dir~,
text_system=ctx.text_system,
),
)
}
append_rich_text_editor_overlay(
overlay_commands,
text~,
display_text~,
document=window.document,
content=window_content,
font~,
cursor_color~,
control=ctx.text_control,
text_system=ctx.text_system,
clip_rect=Some(content),
caret_rect_override~,
)
}
}
// PopClip must go in post_commands (not commands) so that child_layers —
// which are rendered between commands and post_commands — stay inside the
// content clip. Without this, overscan blocks bleed past the editor frame
// and overlap the chrome/status bar during scrolling.
(
{
commands,
child_layers,
post_commands: [PopClip],
overlay_commands,
repaint_boundary: false,
animating: false,
},
geometry_compensation,
)
}
///|
fn[Msg] markdown_session_editor_event(
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~ : ((Int, @core.TextRange?) -> Msg)?,
on_prefix_click~ : ((Int) -> Msg)?,
on_command_click~ : ((Int) -> Msg)?,
accepts_tab_input~ : Bool,
visible_scroll_y~ : Double,
on_scroll~ : ((Double, Double) -> Msg)?,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewEventResult[Msg] {
let value = session.source()
let on_input_edit = markdown_session_on_input_edit(session, on_transaction)
match ctx.event {
Pointer(event) =>
markdown_session_pointer_event(
ctx,
session~,
selection~,
font~,
source_mode~,
focus_mode~,
base_dir~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
event~,
visible_scroll_y~,
on_scroll~,
visible_viewport_height~,
visible_overscan~,
)
Keyboard(event) => {
// ArrowUp/ArrowDown 需要用 visible_window 的 document 来正确计算
// 视觉位置(考虑 scroll offset),所以在这里预处理,而不是走
// rich_text_keyboard_event 的通用 format(text) 路径。
if event.pressed && (event.key == "ArrowUp" || event.key == "ArrowDown") {
let text = session.source()
let window = markdown_session_visible_window(
session,
selection,
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
content_width=rich_text_editor_content_rect(ctx.frame).size.width,
text_system=ctx.text_system,
)
let content = window.content_rect(
rich_text_editor_content_rect(ctx.frame),
)
return rich_text_vertical_caret_move_result(
ctx,
text,
window.document,
content,
font,
up=event.key == "ArrowUp",
extend_selection=event.modifiers.shift,
on_selection_change,
)
}
rich_text_keyboard_event(
ctx,
value~,
format=_source => {
session.rich_text(
selection,
base=font,
source_mode~,
focus_mode~,
base_dir~,
)
},
font~,
on_input=None,
on_input_edit=Some(on_input_edit),
on_selection_change~,
input_transform=Some(
markdown_session_input_transform_for(
session,
split_paragraph_on_enter=!source_mode,
),
),
accepts_tab_input~,
event~,
)
}
TextInput(event) =>
rich_text_input_event(
ctx,
value~,
on_input=None,
on_input_edit=Some(on_input_edit),
on_selection_change~,
input_transform=Some(
markdown_session_input_transform_for(
session,
split_paragraph_on_enter=!source_mode,
),
),
event~,
record_history=true,
)
_ => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] markdown_session_pointer_event(
ctx : @core.ViewEventContext,
session~ : MarkdownDocumentSession,
selection~ : MarkdownEditorSelection,
font~ : @core.FontSpec,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
on_prefix_click~ : ((Int) -> Msg)?,
on_command_click~ : ((Int) -> Msg)?,
event~ : @core.PointerEvent,
visible_scroll_y~ : Double,
on_scroll~ : ((Double, Double) -> Msg)?,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewEventResult[Msg] {
// Secondary/middle presses reach the view tree now that host backends route
// right-button downs for per-target context menus. A right press only places
// the caret (see the Down branch), so the menu the host builds next describes
// what was pressed; every other button and phase is ignored, which keeps a
// right-drag from selecting and leaves the wheel path (button 0) untouched.
if event.button != 0 && !(event.button == 1 && event.phase == Down) {
return @core.ViewEventResult::ignored()
}
let inside = ctx.frame.contains(event.position)
let content_width = rich_text_editor_content_rect(ctx.frame).size.width
if inside && (event.wheel_delta.x != 0.0 || event.wheel_delta.y != 0.0) {
return markdown_session_scroll_event(
event~,
on_scroll~,
clock_ms=ctx.now_ms,
)
}
match event.phase {
Down =>
if inside {
let text = session.source()
let window = markdown_session_visible_window(
session,
selection,
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
content_width~,
text_system=ctx.text_system,
)
let content = window.content_rect(
rich_text_editor_content_rect(ctx.frame),
)
// A right press moves the caret the way mature editors do, so the
// context menu the host builds next describes the text that was
// pressed. A press inside the current selection keeps it: that is what
// makes "Bold" in the menu apply to the selected run. The result
// reports `activated=false` on purpose — the enclosing
// `on_secondary_tap` observer must still run, so the app records its
// menu target from the same press — and leaves no selection anchor
// behind, so a right-drag never grows a selection.
if event.button == 1 {
let caret = rich_text_document_source_offset_at_point(
window.document,
event.position,
content,
font,
ctx.text_system,
).unwrap_or(text_length(text))
if rich_text_selection_covers(selection.selection, caret) {
return view_event_result(focused=true)
}
return view_event_result(
changed=true,
focused=true,
text_control=Some({
..ctx.text_control,
caret,
selection: None,
text_selection_anchor: None,
goal_column: None,
text_selection_origin: None,
composition: None,
composition_cursor: None,
}),
dirty=ViewPaintDirty,
messages=selection_messages(on_selection_change, caret, None),
)
}
let command_modifiers = event.modifiers.meta || event.modifiers.control
match (command_modifiers, on_command_click) {
(true, Some(handler)) => {
let offset = rich_text_document_source_offset_at_point(
window.document,
event.position,
content,
font,
ctx.text_system,
).unwrap_or(text_length(text))
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some(ctx.text_control),
dirty=ViewPaintDirty,
messages=[handler(offset)],
)
}
_ => {
let prefix_messages = match on_prefix_click {
Some(handler) =>
match
rich_text_document_prefix_source_offset_at_point(
window.document,
event.position,
content,
font,
ctx.text_system,
) {
Some(offset) => [handler(offset)]
None => []
}
None => []
}
let caret = rich_text_document_source_offset_at_point(
window.document,
event.position,
content,
font,
ctx.text_system,
).unwrap_or(text_length(text))
let next_control = {
..ctx.text_control,
caret,
selection: None,
text_selection_anchor: Some(caret),
goal_column: None,
text_selection_origin: Some(event.position),
composition: None,
composition_cursor: None,
}
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some(next_control),
dirty=ViewPaintDirty,
messages=prefix_messages +
selection_messages(on_selection_change, caret, None),
)
}
}
} else {
@core.ViewEventResult::ignored()
}
Move =>
match ctx.text_control.text_selection_anchor {
Some(anchor) => {
let text = session.source()
// A Move on the gesture's own down point is the click's event
// stream, not pointer travel: the view may have scrolled
// (typewriter centering) or expanded hidden markers in between,
// and re-deriving the caret from the screen point would then
// fabricate a selection. Same point => nothing moves.
let stationary = match ctx.text_control.text_selection_origin {
Some(origin) =>
(event.position.x - origin.x).abs() <= 1.0 &&
(event.position.y - origin.y).abs() <= 1.0
None => false
}
if stationary {
view_event_result(
focused=true,
captured=true,
text_control=Some(ctx.text_control),
)
} else {
// Hit-test against the document frozen at the gesture anchor,
// not the live selection: a growing selection keeps revealing
// new markers mid-drag, which would shift the mapping under
// the pointer and inflate the selection on its own.
let window = markdown_session_visible_window(
session,
{ caret: anchor, selection: None, },
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
content_width~,
text_system=ctx.text_system,
)
let content = window.content_rect(
rich_text_editor_content_rect(ctx.frame),
)
let caret = rich_text_document_source_offset_at_point(
window.document,
event.position,
content,
font,
ctx.text_system,
).unwrap_or(text_length(text))
let next_selection = if caret == anchor {
None
} else {
Some(@core.TextRange::new(start=anchor, end=caret))
}
let changed = ctx.text_control.caret != caret ||
ctx.text_control.selection != next_selection
if changed {
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some({
..ctx.text_control,
caret,
selection: next_selection,
composition: None,
composition_cursor: None,
}),
dirty=ViewPaintDirty,
messages=selection_messages(
on_selection_change, caret, next_selection,
),
)
} else {
view_event_result(
focused=true,
captured=true,
text_control=Some(ctx.text_control),
)
}
}
}
None => @core.ViewEventResult::ignored()
}
Up =>
if ctx.text_control.text_selection_anchor is Some(_) {
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some({
..ctx.text_control,
text_selection_anchor: None,
goal_column: None,
text_selection_origin: None,
}),
dirty=ViewPaintDirty,
)
} else {
@core.ViewEventResult::ignored()
}
_ => @core.ViewEventResult::ignored()
}
}
///|
/// Forward a wheel event to the app as (raw delta, animation-clock ms). The
/// delta is deliberately NOT resolved into an absolute offset against
/// `visible_scroll_y`: that prop is the position at the last view build,
/// which lags the eased offset mid-glide, so an absolute offset computed
/// here would land behind the displayed position and snap the scroll
/// backward on momentum tail events. The app applies the delta to its own
/// current position and owns the clamping.
///
/// The clock value is the runtime animation clock (`ctx.now_ms`) — the same
/// timeline the glide is evaluated on at paint time — so an app can stamp a
/// new glide with it and get a self-consistent animation. The pointer
/// contract's own `time_ms` is not used: the input path does not populate
/// it (it stays 0.0).
fn[Msg] markdown_session_scroll_event(
event~ : @core.PointerEvent,
on_scroll~ : ((Double, Double) -> Msg)?,
clock_ms~ : Double,
) -> @core.ViewEventResult[Msg] {
match on_scroll {
Some(handler) => {
let delta_y = event.wheel_delta.y
view_event_result(
changed=delta_y != 0.0,
activated=delta_y != 0.0,
captured=true,
dirty=ViewPaintDirty,
messages=if delta_y != 0.0 { [handler(delta_y, clock_ms)] } else { [] },
)
}
None => @core.ViewEventResult::ignored()
}
}
///|
/// Feed the scroll geometry with the pixel heights actually laid out for
/// the visible block window. Heights are computed from the pre-reveal
/// rich blocks (cache-hot at this point) so temporary marker reveals do
/// not leak into the content-keyed measurements. Blank gap blocks are the
/// one exception: they render collapsed (a 0.4em separator vs a full writing
/// line, see `markdown_editor_collapse_inactive_blank_gaps`), which is
/// structural WYSIWYG geometry rather than a transient reveal. Measuring
/// the uncollapsed block overstates every paragraph separator by ~1.55em
/// (≈26px at 17px body text); the spurious growth above the anchor is then
/// published as geometry compensation, and the next wheel event re-keys
/// from the shifted offset — scrolling up through blanks reads as a small
/// rollback. Blank gaps therefore measure the painted (collapsed) window
/// block, which already carries the correct separator/writing-line state
/// including the caret-active case.
fn markdown_session_record_measured_block_heights(
session : MarkdownDocumentSession,
window : MarkdownDocumentRichTextWindow,
content_width~ : Double,
base~ : @core.FontSpec,
base_dir~ : String?,
text_system~ : @core.TextSystem,
visible_scroll_y~ : Double,
) -> Double {
if window.visible_start < 0 {
return 0.0
}
// Keep the range that was used to produce this window. A measurement can
// change the document's max offset even when it is below the anchor; if the
// user was at the bottom, that range change must be folded into the same
// compensation transaction instead of letting the next paint clamp behind
// the current glide.
let old_max_scroll = max_double(
0.0,
window.content_height + 96.0 - window.viewport_height,
)
let was_at_bottom = old_max_scroll > 0.0 &&
visible_scroll_y >= old_max_scroll - 1.0
let updates : Array[(Int, Double)] = []
for offset, window_block in window.document.blocks {
match session.editor_blocks.get(window.visible_start + offset) {
Some(editor_block) => {
// Skip a block the line window sliced. Measuring it would materialise
// the whole block — for an oversized block that is the entire
// document, on every paint — and would publish a height that covers
// only the visible lines, pulling the scroll geometry out of line with
// what is drawn. The block keeps its indexed estimate, which is what
// the window itself used to choose the band.
// "Covers less source than the session block" is the exact signature
// of a line-window slice. Comparing offsets for inequality was too
// loose: revealing a marker or an HTML block legitimately shifts or
// widens a block's range, and treating those as slices skipped
// measurements the geometry depends on.
let sliced = markdown_document_block_needs_line_window(
session,
base,
window.visible_start + offset,
window.viewport_height,
) &&
(match window_block.source_range {
Some(range) =>
range.end - range.start <
editor_block.source_range.end - editor_block.source_range.start
None => false
})
if sliced {
continue
}
// Measured heights must come from the block's real inline runs.
let editor_block = markdown_editor_block_materialized(
session.source(),
source_length=session.source_length,
source_digest=session.source_digest,
editor_block,
)
let height = if rich_text_block_is_blank_gap(window_block) {
// Collapsed WYSIWYG height (separator margin or writing line),
// already soft-wrapped in the window; re-wrapping is a no-op for
// run-less gaps. Using the uncollapsed rebuild here would record
// a full line box for every separator.
rich_text_block_total_height(
window_block,
base,
text_system,
block_width=content_width,
)
} else {
rich_text_block_total_height(
rich_text_block_soft_wrap(
markdown_editor_block_to_rich_text(
session.source(),
editor_block,
base,
base_dir~,
),
content_width,
base,
text_system,
),
base,
text_system,
block_width=content_width,
)
}
let identity = session.blocks.get(window.visible_start + offset)
let _ = rich_text_record_block_measured_height(
base,
width=content_width,
block_id=match identity {
Some(session_block) => session_block.id
None => window.visible_start + offset
},
block_revision=match identity {
Some(session_block) => session_block.revision
None => 0
},
height~,
source_digest=Some(session.document_identity.to_uint64()),
)
// Avoid feeding identical measurements back into the geometry. This
// is especially important for retained paints: an unchanged height
// must not look like a fresh update that can be compensated again.
let block_index = window.visible_start + offset
let old = markdown_document_block_height_at(
session,
base,
content_width~,
block_index,
)
if (height - old).abs() > 0.0001 {
updates.push((block_index, height))
}
}
None => ()
}
}
// Apply the complete visible-window batch after all measurements have been
// collected. Only changes above the first visible block move the viewport.
let offset = visible_scroll_y
let anchor_index = markdown_document_anchor_index(
session,
base,
content_width~,
offset~,
)
let mut compensation = 0.0
for update in updates {
let index = update.0
let height = update.1
let old = markdown_document_block_height_at(
session,
base,
content_width~,
index,
)
markdown_document_apply_measured_height(
session,
base,
block_index=index,
width=content_width,
height~,
)
if index < anchor_index {
compensation = compensation + height - old
}
}
// A fresh document starts with an empty negative geometry entry. Once this
// batch has populated the measured-height table, materialize the live
// geometry exactly once so subsequent paints point-update it instead of
// re-publishing the same delta forever.
if !updates.is_empty() {
ignore(markdown_document_block_geometry(session, base, content_width~))
}
if !updates.is_empty() {
let new_content_height = session.estimated_content_height(
base~,
source_mode=false,
content_width~,
)
let new_max_scroll = max_double(
0.0,
new_content_height + 96.0 - window.viewport_height,
)
if was_at_bottom {
// Bottom anchoring wins over a block anchor: the scrollbar thumb and
// the final line stay attached to the viewport edge as content grows or
// shrinks anywhere in the document.
compensation = new_max_scroll - visible_scroll_y
} else if visible_scroll_y > new_max_scroll {
// A range shrink away from the bottom still cannot leave the displayed
// point outside the new scrollable interval.
compensation = new_max_scroll - visible_scroll_y
}
}
markdown_scroll_publish_geometry_compensation(compensation)
compensation
}
///|
fn markdown_session_estimated_caret_inset_x(
session : MarkdownDocumentSession,
source_caret : Int,
font~ : @core.FontSpec,
base_dir~ : String?,
content_width~ : Double,
text_system~ : @core.TextSystem,
) -> Double {
match
markdown_editor_block_index_at_offset(session.editor_blocks, source_caret) {
None => 0.0
Some(block_index) => {
let editor_block = session.editor_blocks[block_index]
let block_start = editor_block.source_range.start
let scan_end = markdown_editor_min_int(
source_caret,
editor_block.source_range.end,
)
// A caret can resolve to a block it sits before (the index lookup
// clamps); pin the range so the substring below always has start <= end.
let scan_end = if scan_end < block_start { block_start } else { scan_end }
// The caret's own line start comes from the document's cached line scan
// in O(log n), with no copy of the text before the caret and no
// approximation: the window then begins exactly at that line.
//
// The bounded scan below stays as the fallback for a source the line
// scan has not seen. It was the only path before, and it approximates
// when the caret's line is longer than the window.
let (window_start, line_start) = match
markdown_editor_line_at_offset(session.source(), scan_end) {
Some((_, start)) if start >= block_start && start <= scan_end =>
(start, 0)
_ => {
let start = if scan_end > block_start + 4096 {
scan_end - 4096
} else {
block_start
}
let window = markdown_editor_substring(
session.source(),
start,
scan_end,
)
let mut offset = if start > block_start { -1 } else { 0 }
for index, ch in window {
if ch == '\n' {
offset = index + 1
}
}
(start, offset)
}
}
let window = markdown_editor_substring(
session.source(),
window_start,
scan_end,
)
// Caret geometry only needs the caret's own line, so materialise the
// caret's neighbourhood rather than the whole block. A block can hold
// the entire document — a soft-wrapped paragraph with no blank lines —
// and parsing all of it on every click is what made clicking a large
// document stall. The full block is still used when the window holds no
// line break, which means the caret's line is longer than the window and
// the exact answer still matters.
let rich_source_block = if line_start >= 0 {
let slice_end = if scan_end + 4096 < editor_block.source_range.end {
scan_end + 4096
} else {
editor_block.source_range.end
}
{
block: {
..editor_block.block,
text: markdown_editor_substring(
session.source(),
window_start,
slice_end,
),
inlines: [],
},
source_range: { start: window_start, end: slice_end, },
content_range: { start: window_start, end: slice_end, },
}
} else {
editor_block
}
// Caret geometry needs the block's real inline runs.
let rich_block = markdown_editor_block_to_rich_text(
session.source(),
markdown_editor_block_materialized(
session.source(),
source_length=session.source_length,
source_digest=session.source_digest,
rich_source_block,
),
font,
base_dir~,
)
let block_font = rich_block.font.unwrap_or(font)
let line_text = if line_start >= 0 {
markdown_editor_substring(
window,
line_start,
markdown_editor_text_length(window),
)
} else {
// No newline inside the window and it does not reach the block start,
// so the line begins earlier: scan the whole block range exactly.
let before_caret = markdown_editor_substring(
session.source(),
block_start,
scan_end,
)
let mut exact_start = 0
for index, ch in before_caret {
if ch == '\n' {
exact_start = index + 1
}
}
markdown_editor_substring(
before_caret,
exact_start,
markdown_editor_text_length(before_caret),
)
}
// The caret rect fallback maps canonical source text; hidden inline
// markers make this an over-estimate, so clamp to the content width.
let line_width = min_double(
rich_text_width(line_text, block_font, text_system),
max_double(0.0, content_width - rich_block.inset.left),
)
rich_block.inset.left +
rich_text_block_marker_lane(rich_block, block_font, text_system) +
line_width
}
}
}
///|
/// Whether the window's document actually contains `caret`. A window
/// clipped to the visible band does not, and asking its document for such
/// a caret returns a rect pinned to the first rendered line instead of
/// failing — callers must fall back to the session-level estimate.
fn markdown_session_window_covers_caret(
window : MarkdownDocumentRichTextWindow,
caret : Int,
) -> Bool {
match (window.document.blocks.get(0), window.document.blocks.last()) {
(Some(first), Some(last)) =>
match (first.source_range, last.source_range) {
(Some(start), Some(end)) => caret >= start.start && caret <= end.end
_ => true
}
_ => false
}
}
///|
fn markdown_session_input_state(
text~ : String,
frame~ : @core.Rect,
session~ : MarkdownDocumentSession,
selection~ : MarkdownEditorSelection,
font~ : @core.FontSpec,
control~ : @core.TextControlStateContext,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
visible_scroll_y~ : Double,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
text_system~ : @core.TextSystem,
) -> @core.TextInputState {
let control = {
..control,
caret: selection.caret,
selection: selection.selection,
}
let caret = @core.clamp_int(control.caret, 0, session.source_length)
if frame.size.width <= 0.0 || frame.size.height <= 0.0 {
return {
focused: true,
text,
caret,
selection: control.selection,
composition: control.composition,
composition_cursor: control.composition_cursor,
frame,
caret_rect: @core.Rect::new(
x=frame.origin.x,
y=frame.origin.y,
width=1.0,
height=font.size,
),
}
}
let display_text = rich_text_with_composition(text, control, focused=true)
let display_session = markdown_session_display_session(session, display_text)
let visual_caret = match control.composition {
Some(_) =>
@core.clamp_int(
rich_text_visual_caret(text, control, focused=true),
0,
text_length(display_text),
)
None => caret
}
let window = markdown_session_visible_window(
display_session,
{ caret: visual_caret, selection: control.selection, },
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
content_width=rich_text_editor_content_rect(frame).size.width,
text_system~,
)
let base_content = rich_text_editor_content_rect(frame)
let content = window.content_rect(base_content)
// The window may be clipped to the visible band, in which case a caret
// outside that band is simply not in `window.document`. Asking the document
// for such a caret does not come back empty — it returns a rect pinned to the
// first rendered line, which painted the caret at the start of the block
// instead of at its own offset. Only consult the document when it actually
// covers the caret; otherwise let the session's height index answer, since it
// knows every offset regardless of the window.
let caret_in_window = markdown_session_window_covers_caret(
window, visual_caret,
)
let window_caret_rect = if caret_in_window {
rich_text_document_caret_rect_at_source(
window.document,
visual_caret,
content,
font,
text_system~,
)
} else {
None
}
let caret_rect = match window_caret_rect {
Some(rect) => rect
None =>
markdown_session_estimated_caret_rect(
display_session,
visual_caret,
base_content,
font,
source_mode~,
scroll_y=window.scroll_y,
base_dir~,
text_system~,
)
}
{
focused: true,
text,
caret,
selection: control.selection,
composition: control.composition,
composition_cursor: control.composition_cursor,
frame,
caret_rect,
}
}
///|
fn markdown_session_estimated_caret_rect(
session : MarkdownDocumentSession,
source_caret : Int,
content : @core.Rect,
font : @core.FontSpec,
source_mode~ : Bool,
scroll_y~ : Double,
base_dir~ : String?,
text_system~ : @core.TextSystem,
) -> @core.Rect {
let source_y = session.source_offset_y(
source_caret,
base=font,
source_mode~,
content_width=content.size.width,
)
let inset_x = markdown_session_estimated_caret_inset_x(
session,
source_caret,
font~,
base_dir~,
content_width=content.size.width,
text_system~,
)
@core.Rect::new(
x=content.origin.x + inset_x,
y=content.origin.y + source_y - scroll_y + 4.0,
width=1.5,
height=max_double(10.0, font.size * 1.55 - 8.0),
)
}
///|
fn[Msg] markdown_session_editor_command(
ctx : @core.ViewEventContext,
session~ : MarkdownDocumentSession,
selection~ : MarkdownEditorSelection,
on_transaction~ : (MarkdownEditTransaction) -> Msg,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
font~ : @core.FontSpec,
source_mode~ : Bool,
focus_mode~ : Bool,
base_dir~ : String?,
intent : @core.CommandIntent,
paste_text~ : String?,
) -> @core.ViewTextCommandResult[Msg] {
let value = session.source()
let on_input_edit = markdown_session_on_input_edit(session, on_transaction)
let format_selection = match intent {
Copy | Cut => ({ caret: -1, selection: None, } : MarkdownEditorSelection)
_ => selection
}
let format = display_source => {
if display_source == session.source() {
session.rich_text(
format_selection,
base=font,
source_mode~,
focus_mode~,
base_dir~,
)
} else if source_mode {
markdown_editor_format_source_mode(display_source, font)
} else {
markdown_editor_format_for_writing_mode(
display_source,
format_selection,
base=font,
focus_mode~,
base_dir~,
)
}
}
rich_text_editor_command(
ctx,
value~,
format~,
on_input=None,
on_input_edit=Some(on_input_edit),
on_selection_change~,
input_transform=Some(
markdown_session_input_transform_for(
session,
split_paragraph_on_enter=!source_mode,
),
),
intent,
paste_text~,
)
}