///|
pub fn[Msg] rich_text_editor(
value~ : @core.Binding[String],
placeholder~ : String,
size~ : @core.Size,
format~ : RichTextFormatter,
font~ : @core.FontSpec,
foreground~ : @core.Color,
placeholder_color~ : @core.Color,
background~ : @core.Brush,
border? : @core.BorderStyle? = None,
focused_border? : @core.BorderStyle? = None,
cursor_color~ : @core.Color,
corner_radius? : Double = 0.0,
on_input? : ((String) -> Msg)? = None,
on_input_edit? : ((String, Int, @core.TextRange?) -> Msg)? = None,
on_selection_change? : ((Int, @core.TextRange?) -> Msg)? = None,
on_prefix_click? : ((Int) -> Msg)? = None,
on_command_click? : ((Int) -> Msg)? = None,
input_transform? : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)? = None,
accepts_tab_input? : Bool = false,
visible_scroll_y? : Double = 0.0,
visible_viewport_height? : Double = 0.0,
visible_overscan? : Double = 320.0,
selection_caret? : Int = -1,
selection_range? : @core.TextRange? = None,
kind? : String = "RichTextEditor",
key? : String? = None,
) -> @core.View[Msg] {
@core.View::node(
identity=() => @core.ViewIdentity::new(kind~, key~),
revision=() => {
"rich-text-editor:\{value.get()}:\{placeholder}:\{view_size_revision(size)}:\{view_font_revision(font)}:\{view_color_revision(foreground)}:\{view_color_revision(placeholder_color)}:\{view_brush_revision(background)}:\{view_border_option_revision(border)}:\{view_border_option_revision(focused_border)}:\{view_color_revision(cursor_color)}:\{corner_radius}:\{accepts_tab_input}:\{selection_caret}:\{view_text_range_option_revision(selection_range)}"
},
layout=ctx => {
@core.ViewLayoutResult::new(size=ctx.constraints.constrain(size))
},
paint=ctx => {
rich_text_editor_paint(
ctx,
value~,
placeholder~,
format~,
font~,
foreground~,
placeholder_color~,
background~,
border~,
focused_border~,
cursor_color~,
corner_radius~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
)
},
event=ctx => {
rich_text_editor_event(
ctx,
value~,
format~,
font~,
on_input~,
on_input_edit~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
input_transform~,
accepts_tab_input~,
)
},
text_input_state=ctx => {
Some(
rich_text_input_state(
text=value.get(),
frame=ctx.frame,
format~,
font~,
control=ctx.text_control,
selection_caret~,
selection_range~,
text_system=ctx.text_system,
),
)
},
text_command=(ctx, intent, paste_text) => {
rich_text_editor_command(
ctx,
value~,
format~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
intent,
paste_text~,
)
},
semantics=() => {
@core.ViewSemanticsInfo::new(
role=@core.SemanticsRole::TextField,
label=placeholder,
value=value.get(),
actions=[
@core.SemanticsAction::Focus,
@core.SemanticsAction::SetText,
@core.SemanticsAction::Submit,
],
)
},
focusable=() => true,
)
}
///|
pub fn[Msg] controlled_rich_text_editor(
value~ : String,
on_input~ : (String) -> Msg,
placeholder~ : String,
size~ : @core.Size,
format~ : RichTextFormatter,
font~ : @core.FontSpec,
foreground~ : @core.Color,
placeholder_color~ : @core.Color,
background~ : @core.Brush,
border? : @core.BorderStyle? = None,
focused_border? : @core.BorderStyle? = None,
cursor_color~ : @core.Color,
corner_radius? : Double = 0.0,
on_selection_change? : ((Int, @core.TextRange?) -> Msg)? = None,
on_prefix_click? : ((Int) -> Msg)? = None,
on_command_click? : ((Int) -> Msg)? = None,
input_transform? : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)? = None,
accepts_tab_input? : Bool = false,
visible_scroll_y? : Double = 0.0,
visible_viewport_height? : Double = 0.0,
visible_overscan? : Double = 320.0,
selection_caret? : Int = -1,
selection_range? : @core.TextRange? = None,
kind? : String = "RichTextEditor",
key? : String? = None,
) -> @core.View[Msg] {
rich_text_editor(
value=@core.Binding::new(get=() => value, set=_next => ()),
placeholder~,
size~,
format~,
font~,
foreground~,
placeholder_color~,
background~,
border~,
focused_border~,
cursor_color~,
corner_radius~,
on_input=Some(on_input),
on_input_edit=None,
on_selection_change~,
on_prefix_click~,
on_command_click~,
input_transform~,
accepts_tab_input~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
selection_caret~,
selection_range~,
kind~,
key~,
)
}
///|
pub fn[Msg] controlled_rich_text_document_editor(
value~ : String,
document~ : RichTextDocument,
on_input~ : (String) -> Msg,
placeholder~ : String,
size~ : @core.Size,
font~ : @core.FontSpec,
foreground~ : @core.Color,
placeholder_color~ : @core.Color,
background~ : @core.Brush,
border? : @core.BorderStyle? = None,
focused_border? : @core.BorderStyle? = None,
cursor_color~ : @core.Color,
corner_radius? : Double = 0.0,
on_selection_change? : ((Int, @core.TextRange?) -> Msg)? = None,
on_prefix_click? : ((Int) -> Msg)? = None,
on_command_click? : ((Int) -> Msg)? = None,
input_transform? : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)? = None,
accepts_tab_input? : Bool = false,
visible_scroll_y? : Double = 0.0,
visible_viewport_height? : Double = 0.0,
visible_overscan? : Double = 320.0,
selection_caret? : Int = -1,
selection_range? : @core.TextRange? = None,
kind? : String = "RichTextDocumentEditor",
key? : String? = None,
) -> @core.View[Msg] {
controlled_rich_text_editor(
value~,
on_input~,
placeholder~,
size~,
format=_source => document,
font~,
foreground~,
placeholder_color~,
background~,
border~,
focused_border~,
cursor_color~,
corner_radius~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
input_transform~,
accepts_tab_input~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
selection_caret~,
selection_range~,
kind~,
key~,
)
}
///|
fn markdown_session_current_scroll_y(
visible_scroll_y : Double,
visible_scroll_state : @core.ScrollState?,
) -> Double {
match visible_scroll_state {
Some(state) => state.offset().y
None => visible_scroll_y
}
}
///|
fn markdown_session_clamped_scroll_y(
session : MarkdownDocumentSession,
font : @core.FontSpec,
source_mode~ : Bool,
scroll_y : Double,
viewport_height : Double,
) -> Double {
if viewport_height <= 0.0 {
return max_double(0.0, scroll_y)
}
let content_height = session.estimated_content_height(base=font, source_mode~) +
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 {
MarkdownDocumentSession::new(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,
visible_scroll_state~ : @core.ScrollState?,
) -> MarkdownDocumentRichTextWindow {
let scroll_y = markdown_session_clamped_scroll_y(
session,
font,
source_mode~,
markdown_session_current_scroll_y(visible_scroll_y, visible_scroll_state),
visible_viewport_height,
)
session.rich_text_window(
selection,
base=font,
source_mode~,
focus_mode~,
base_dir~,
scroll_y~,
viewport_height=visible_viewport_height,
overscan=visible_overscan,
)
}
///|
fn markdown_session_input_transform(
source : String,
caret : Int,
range : @core.TextRange?,
inserted : String,
) -> RichTextInputTransform? {
markdown_editor_transform_input(source, caret, range, inserted)
}
///|
fn[Msg] markdown_session_on_input_edit(
session : MarkdownDocumentSession,
on_transaction : (MarkdownEditTransaction) -> Msg,
) -> (String, Int, @core.TextRange?) -> Msg {
(next_source, caret, next_selection) => {
on_transaction(
MarkdownEditTransaction::from_sources(
session.source,
next_source,
caret~,
selection=next_selection,
reason=TextInput,
),
)
}
}
///|
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,
visible_scroll_state~ : @core.ScrollState?,
) -> @core.ViewPaintPlan {
let commands : Array[@core.DrawCommand] = []
let overlay_commands : Array[@core.DrawCommand] = []
let rounded = @core.RoundedRect::new(rect=ctx.frame, radius=corner_radius)
commands.push(@core.DrawCommand::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(@core.DrawCommand::PushClip(content))
let child_layers : Array[@core.ViewPaintLayer] = []
if display_text == "" {
commands.push(
@core.DrawCommand::DrawText(
@core.TextRun::new(
text=placeholder,
frame=content,
font~,
color=placeholder_color,
align=@core.TextAlign::TextStart,
),
),
)
} else {
let window = markdown_session_visible_window(
display_session,
selection,
font,
source_mode~,
focus_mode~,
base_dir~,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
visible_scroll_state~,
)
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),
)
if ctx.focused {
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),
)
}
}
// 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: [@core.DrawCommand::PopClip],
overlay_commands,
platform_views: [],
repaint_boundary: false,
animating: false,
}
}
///|
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,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
visible_scroll_state~ : @core.ScrollState?,
) -> @core.ViewEventResult[Msg] {
let value = @core.Binding::new(get=() => session.source, set=_next => ())
let on_input_edit = markdown_session_on_input_edit(session, on_transaction)
match ctx.event {
@core.AppEvent::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~,
visible_viewport_height~,
visible_overscan~,
visible_scroll_state~,
)
@core.AppEvent::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~,
visible_scroll_state~,
)
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=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),
accepts_tab_input~,
event~,
)
}
@core.AppEvent::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),
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,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
visible_scroll_state~ : @core.ScrollState?,
) -> @core.ViewEventResult[Msg] {
let inside = ctx.frame.contains(event.position)
if inside && (event.wheel_delta.x != 0.0 || event.wheel_delta.y != 0.0) {
return markdown_session_scroll_event(
session,
font,
source_mode~,
event~,
visible_viewport_height~,
visible_scroll_state~,
)
}
match event.phase {
@core.PointerPhase::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~,
visible_scroll_state~,
)
let content = window.content_rect(
rich_text_editor_content_rect(ctx.frame),
)
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=@core.ViewDirtyHint::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),
composition: None,
composition_cursor: None,
}
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=prefix_messages +
selection_messages(on_selection_change, caret, None),
)
}
}
} else {
@core.ViewEventResult::ignored()
}
@core.PointerPhase::Move =>
match ctx.text_control.text_selection_anchor {
Some(anchor) => {
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~,
visible_scroll_state~,
)
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=@core.ViewDirtyHint::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()
}
@core.PointerPhase::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 }),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
)
} else {
@core.ViewEventResult::ignored()
}
_ => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] markdown_session_scroll_event(
session : MarkdownDocumentSession,
font : @core.FontSpec,
source_mode~ : Bool,
event~ : @core.PointerEvent,
visible_viewport_height~ : Double,
visible_scroll_state~ : @core.ScrollState?,
) -> @core.ViewEventResult[Msg] {
match visible_scroll_state {
Some(state) => {
let viewport_height = if visible_viewport_height > 0.0 {
visible_viewport_height
} else {
0.0
}
let content_height = session.estimated_content_height(
base=font,
source_mode~,
) +
96.0
let max_scroll = max_double(0.0, content_height - viewport_height)
let current = state.offset()
let next_y = min_double(
max_double(0.0, current.y - event.wheel_delta.y),
max_scroll,
)
state.scroll_to(x=0.0, y=next_y)
view_event_result(
changed=next_y != current.y,
captured=true,
dirty=@core.ViewDirtyHint::ViewPaintDirty,
)
}
None => @core.ViewEventResult::ignored()
}
}
///|
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,
visible_scroll_state~ : @core.ScrollState?,
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,
element_id: None,
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~,
visible_scroll_state~,
)
let base_content = rich_text_editor_content_rect(frame)
let content = window.content_rect(base_content)
let caret_rect = match
rich_text_document_caret_rect_at_source(
window.document,
visual_caret,
content,
font,
text_system~,
) {
Some(rect) => rect
None =>
markdown_session_estimated_caret_rect(
display_session,
visual_caret,
base_content,
font,
source_mode~,
scroll_y=window.scroll_y,
)
}
{
focused: true,
element_id: None,
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,
) -> @core.Rect {
let source_y = session.source_offset_y(source_caret, base=font, source_mode~)
@core.Rect::new(
x=content.origin.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 = @core.Binding::new(get=() => session.source, set=_next => ())
let on_input_edit = markdown_session_on_input_edit(session, on_transaction)
let format = display_source => {
if display_source == session.source {
session.rich_text(
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,
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),
intent,
paste_text~,
)
}
///|
fn rich_text_editor_paint(
ctx : @core.ViewPaintContext,
value~ : @core.Binding[String],
placeholder~ : String,
format~ : RichTextFormatter,
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,
visible_scroll_y~ : Double,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewPaintPlan {
let commands : Array[@core.DrawCommand] = []
let overlay_commands : Array[@core.DrawCommand] = []
let rounded = @core.RoundedRect::new(rect=ctx.frame, radius=corner_radius)
commands.push(@core.DrawCommand::FillRoundedRectBrush(rounded, background))
append_rich_text_border(
commands,
rounded,
if ctx.focused {
focused_border
} else {
border
},
)
let text = value.get()
let display_text = rich_text_with_composition(
text,
ctx.text_control,
focused=ctx.focused,
)
let content = rich_text_editor_content_rect(ctx.frame)
commands.push(@core.DrawCommand::PushClip(content))
let child_layers : Array[@core.ViewPaintLayer] = []
if display_text == "" {
commands.push(
@core.DrawCommand::DrawText(
@core.TextRun::new(
text=placeholder,
frame=content,
font~,
color=placeholder_color,
align=@core.TextAlign::TextStart,
),
),
)
} else {
let document = format(display_text)
append_rich_text_document_layers(
child_layers,
document,
content,
font,
foreground,
text_system=ctx.text_system,
visible_scroll_y~,
visible_viewport_height~,
visible_overscan~,
)
if ctx.focused {
append_rich_text_editor_overlay(
overlay_commands,
text~,
display_text~,
document~,
content~,
font~,
cursor_color~,
control=ctx.text_control,
text_system=ctx.text_system,
)
}
}
// PopClip must go in post_commands so child_layers stay inside the clip.
{
commands,
child_layers,
post_commands: [@core.DrawCommand::PopClip],
overlay_commands,
platform_views: [],
repaint_boundary: false,
animating: false,
}
}
///|
fn append_rich_text_border(
commands : Array[@core.DrawCommand],
rounded : @core.RoundedRect,
border : @core.BorderStyle?,
) -> Unit {
match border {
Some(border) =>
if border.width > 0.0 {
commands.push(
@core.DrawCommand::StrokeRoundedRectBrush(
rounded,
border.brush,
border.width,
),
)
}
None => ()
}
}
///|
fn append_rich_text_document_layers(
layers : Array[@core.ViewPaintLayer],
document : RichTextDocument,
content : @core.Rect,
base_font : @core.FontSpec,
foreground : @core.Color,
text_system~ : @core.TextSystem,
visible_scroll_y? : Double = 0.0,
visible_viewport_height? : Double = 0.0,
visible_overscan? : Double = 320.0,
clip_rect? : @core.Rect? = None,
) -> Unit {
// `content` sets the block positioning origin; `clip` sets the culling
// bounds. They are the same for the regular (non-session) rich text editor
// path, but the Markdown session path positions blocks inside an offset
// `window_content` rect while culling must respect the original viewport.
let clip = match clip_rect {
Some(r) => r
None => content
}
let mut y = content.origin.y
let windowed = visible_viewport_height > 0.0
let visible_top = clip.origin.y + visible_scroll_y - 12.0 - visible_overscan
let visible_bottom = visible_top +
visible_viewport_height +
visible_overscan * 2.0
for index, block in document.blocks {
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_height = line_height + block.inset.top + block.inset.bottom
let block_bottom = y + block_height
if windowed && block_bottom < visible_top {
y = block_bottom
continue
}
if windowed && y > visible_bottom {
return
}
if !windowed && y > clip.origin.y + clip.size.height {
return
}
let frame = @core.Rect::new(
x=content.origin.x,
y~,
width=content.size.width,
height=block_height,
)
let commands : Array[@core.DrawCommand] = []
append_rich_text_block_commands(
commands,
block,
frame,
line_height,
base_font,
foreground,
text_system~,
)
layers.push(
@core.ViewPaintLayer::new(
frame~,
cache_key=rich_text_block_cache_key(index, block, frame),
content_revision=rich_text_block_content_revision(
block, index, frame, base_font, foreground,
),
commands~,
),
)
y = block_bottom
}
}
///|
fn append_rich_text_block_commands(
commands : Array[@core.DrawCommand],
block : RichTextBlock,
block_frame : @core.Rect,
line_height : Double,
base_font : @core.FontSpec,
foreground : @core.Color,
text_system~ : @core.TextSystem,
) -> Unit {
match block.background {
Some(background) =>
commands.push(
@core.DrawCommand::FillRoundedRectBrush(
@core.RoundedRect::new(rect=block_frame, radius=block.corner_radius),
background,
),
)
None => ()
}
match block.border_left {
Some(border) =>
if border.width > 0.0 {
commands.push(
@core.DrawCommand::FillRoundedRectBrush(
@core.RoundedRect::new(
rect=@core.Rect::new(
x=block_frame.origin.x,
y=block_frame.origin.y + block.inset.top,
width=border.width,
height=line_height,
),
radius=border.width / 2.0,
),
border.brush,
),
)
}
None => ()
}
if block.rule {
commands.push(
@core.DrawCommand::FillRect(
@core.Rect::new(
x=block_frame.origin.x + block.inset.left,
y=block_frame.origin.y + block_frame.size.height / 2.0,
width=max_double(
0.0,
block_frame.size.width - block.inset.left - block.inset.right,
),
height=1.5,
),
rich_text_block_color(block, foreground).multiply_alpha(0.25),
),
)
} else if block.table is Some(_) {
append_rich_text_block_table(
commands,
block,
@core.Rect::new(
x=block_frame.origin.x + block.inset.left,
y=block_frame.origin.y + block.inset.top,
width=max_double(
0.0,
block_frame.size.width - block.inset.left - block.inset.right,
),
height=line_height,
),
base_font,
foreground,
)
} else {
append_rich_text_block_runs(
commands,
block,
@core.Rect::new(
x=block_frame.origin.x + block.inset.left,
y=block_frame.origin.y + block.inset.top,
width=max_double(
0.0,
block_frame.size.width - block.inset.left - block.inset.right,
),
height=line_height,
),
base_font,
foreground,
text_system~,
)
}
}
///|
fn append_rich_text_block_runs(
commands : Array[@core.DrawCommand],
block : RichTextBlock,
frame : @core.Rect,
base_font : @core.FontSpec,
foreground : @core.Color,
text_system~ : @core.TextSystem,
) -> Unit {
let block_font = rich_text_block_font(block, base_font)
let block_color = rich_text_block_color(block, foreground)
let line_step = rich_text_block_line_step(block, base_font, text_system)
let mut x = frame.origin.x
let mut line = 0
if block.prefix != "" {
let width = rich_text_width(block.prefix, block_font, text_system)
commands.push(
@core.DrawCommand::DrawText(
@core.TextRun::new(
text=block.prefix,
frame=@core.Rect::new(
x~,
y=frame.origin.y,
width~,
height=frame.size.height,
),
font=block_font,
color=block_color,
align=@core.TextAlign::TextStart,
),
),
)
x = x + width
}
for run in block.runs {
let font = run.font.unwrap_or(block_font)
let color = run.color.unwrap_or(block_color)
let segments = rich_text_split_lines(rich_text_run_visual_text(run))
for index, segment in segments {
let width = rich_text_run_segment_width(run, segment, font, text_system)
let line_y = frame.origin.y + line.to_double() * line_step
let run_frame = @core.Rect::new(
x~,
y=line_y,
width=max_double(
0.0,
min_double(width, frame.origin.x + frame.size.width - x),
),
height=rich_text_run_segment_height(run, line_step),
)
if segment != "" || run.image_source is Some(_) {
append_rich_text_run_segment(
commands, run, segment, run_frame, width, font, color,
)
}
if index + 1 < segments.length() {
line = line + 1
x = frame.origin.x
} else {
x = x + width
}
}
}
}
///|
fn append_rich_text_run_segment(
commands : Array[@core.DrawCommand],
run : RichTextRun,
text : String,
run_frame : @core.Rect,
width : Double,
font : @core.FontSpec,
color : @core.Color,
) -> Unit {
match run.background {
Some(background) =>
commands.push(
@core.DrawCommand::FillRoundedRectBrush(
@core.RoundedRect::new(
rect=@core.Rect::new(
x=run_frame.origin.x - 3.0,
y=run_frame.origin.y + 4.0,
width=width + 6.0,
height=max_double(12.0, run_frame.size.height - 8.0),
),
radius=run.background_radius,
),
background,
),
)
None => ()
}
match run.image_source {
Some(source) =>
commands.push(
@core.DrawCommand::DrawImage({
source,
frame: run_frame,
opacity: 1.0,
fit: run.image_fit,
}),
)
None =>
commands.push(
@core.DrawCommand::DrawText(
@core.TextRun::new(
text~,
frame=run_frame,
font~,
color~,
align=@core.TextAlign::TextStart,
),
),
)
}
append_rich_text_run_decoration(commands, run, run_frame, width, font, color)
}
///|
fn append_rich_text_run_decoration(
commands : Array[@core.DrawCommand],
run : RichTextRun,
frame : @core.Rect,
width : Double,
font : @core.FontSpec,
color : @core.Color,
) -> Unit {
match run.decoration {
Some(RichTextDecoration::Strikethrough) => {
let thickness = max_double(1.0, font.size / 14.0)
commands.push(
@core.DrawCommand::FillRect(
@core.Rect::new(
x=frame.origin.x,
y=frame.origin.y + frame.size.height * 0.52,
width~,
height=thickness,
),
color.multiply_alpha(0.72),
),
)
}
None => ()
}
}
///|
fn append_rich_text_block_table(
commands : Array[@core.DrawCommand],
block : RichTextBlock,
frame : @core.Rect,
base_font : @core.FontSpec,
foreground : @core.Color,
) -> Unit {
match block.table {
Some(table) => {
let row_count = table.rows.length()
let column_count = rich_text_table_column_count(table)
if row_count == 0 || column_count == 0 {
return
}
let column_width = frame.size.width / column_count.to_double()
for row_index in 0..
commands.push(
@core.DrawCommand::FillRoundedRectBrush(
@core.RoundedRect::new(
rect=@core.Rect::new(
x=frame.origin.x,
y=row_y,
width=frame.size.width,
height=table.row_height,
),
radius=block.corner_radius,
),
background,
),
)
None => ()
}
}
let row = table.rows[row_index]
for column_index in 0.. ()
}
}
///|
fn append_rich_text_editor_overlay(
commands : Array[@core.DrawCommand],
text~ : String,
display_text~ : String,
document~ : RichTextDocument,
content~ : @core.Rect,
font~ : @core.FontSpec,
cursor_color~ : @core.Color,
control~ : @core.TextControlStateContext,
text_system~ : @core.TextSystem,
clip_rect? : @core.Rect? = None,
) -> Unit {
let clip = match clip_rect {
Some(r) => r
None => content
}
commands.push(@core.DrawCommand::PushClip(clip))
if control.composition is None {
append_rich_text_selection_commands(
commands,
document,
content,
font,
text_system,
control.selection,
)
} else {
append_rich_text_composition_underline(
commands,
document,
text~,
display_text~,
content~,
font~,
text_system~,
cursor_color~,
control~,
)
}
let caret = @core.clamp_int(
rich_text_visual_caret(text, control, focused=true),
0,
text_length(display_text),
)
let caret_rect = rich_text_document_caret_rect_or_plain(
document, caret, content, font, text_system, display_text,
)
commands.push(@core.DrawCommand::FillRect(caret_rect, cursor_color))
commands.push(@core.DrawCommand::PopClip)
}
///|
fn append_rich_text_composition_underline(
commands : Array[@core.DrawCommand],
document : RichTextDocument,
text~ : String,
display_text~ : String,
content~ : @core.Rect,
font~ : @core.FontSpec,
text_system~ : @core.TextSystem,
cursor_color~ : @core.Color,
control~ : @core.TextControlStateContext,
) -> Unit {
match control.composition {
Some(composition) =>
if composition != "" {
let start = @core.composition_start_index(
text,
control.caret,
control.selection,
)
let end = start + text_length(composition)
let start_rect = rich_text_document_caret_rect_or_plain(
document, start, content, font, text_system, display_text,
)
let end_rect = rich_text_document_caret_rect_or_plain(
document, end, content, font, text_system, display_text,
)
if end_rect.origin.x > start_rect.origin.x {
commands.push(
@core.DrawCommand::FillRect(
@core.Rect::new(
x=start_rect.origin.x,
y=start_rect.origin.y + start_rect.size.height - 5.0,
width=end_rect.origin.x - start_rect.origin.x,
height=1.5,
),
cursor_color.with_alpha(0.72),
),
)
}
}
None => ()
}
}
///|
fn[Msg] rich_text_editor_event(
ctx : @core.ViewEventContext,
value~ : @core.Binding[String],
format~ : RichTextFormatter,
font~ : @core.FontSpec,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?) -> Msg)?,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
on_prefix_click~ : ((Int) -> Msg)?,
on_command_click~ : ((Int) -> Msg)?,
input_transform~ : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)?,
accepts_tab_input~ : Bool,
) -> @core.ViewEventResult[Msg] {
match ctx.event {
@core.AppEvent::Pointer(event) =>
rich_text_pointer_event(
ctx,
value~,
format~,
font~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
event~,
)
@core.AppEvent::Keyboard(event) =>
rich_text_keyboard_event(
ctx,
value~,
format~,
font~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
accepts_tab_input~,
event~,
)
@core.AppEvent::TextInput(event) =>
rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event~,
record_history=true,
)
_ => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] rich_text_pointer_event(
ctx : @core.ViewEventContext,
value~ : @core.Binding[String],
format~ : RichTextFormatter,
font~ : @core.FontSpec,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
on_prefix_click~ : ((Int) -> Msg)?,
on_command_click~ : ((Int) -> Msg)?,
event~ : @core.PointerEvent,
) -> @core.ViewEventResult[Msg] {
let inside = ctx.frame.contains(event.position)
match event.phase {
@core.PointerPhase::Down =>
if inside {
let text = value.get()
let document = format(text)
let content = rich_text_editor_content_rect(ctx.frame)
// A Cmd/Ctrl-held click opens links/images instead of moving the caret:
// dispatch the command-click hook with the source offset and swallow the
// caret placement so the selection is not disturbed.
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(
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=@core.ViewDirtyHint::ViewPaintDirty,
messages=[handler(offset)],
)
}
_ => {
let prefix_messages = match on_prefix_click {
Some(handler) =>
match
rich_text_document_prefix_source_offset_at_point(
document,
event.position,
content,
font,
ctx.text_system,
) {
Some(offset) => [handler(offset)]
None => []
}
None => []
}
let caret = rich_text_document_source_offset_at_point(
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),
composition: None,
composition_cursor: None,
}
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=prefix_messages +
selection_messages(on_selection_change, caret, None),
)
}
}
} else {
@core.ViewEventResult::ignored()
}
@core.PointerPhase::Move =>
match ctx.text_control.text_selection_anchor {
Some(anchor) => {
let text = value.get()
let document = format(text)
let content = rich_text_editor_content_rect(ctx.frame)
let caret = rich_text_document_source_offset_at_point(
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
let next_control = {
..ctx.text_control,
caret,
selection: next_selection,
composition: None,
composition_cursor: None,
}
view_event_result(
changed~,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=if changed {
selection_messages(on_selection_change, caret, next_selection)
} else {
[]
},
)
}
None => @core.ViewEventResult::ignored()
}
@core.PointerPhase::Up
| @core.PointerPhase::Cancel
| @core.PointerPhase::Exit => {
let changed = ctx.text_control.text_selection_anchor is Some(_)
view_event_result(
changed~,
captured=changed,
text_control=Some({ ..ctx.text_control, text_selection_anchor: None }),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
)
}
}
}
///|
fn[Msg] rich_text_keyboard_event(
ctx : @core.ViewEventContext,
value~ : @core.Binding[String],
format~ : RichTextFormatter,
font~ : @core.FontSpec,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?) -> Msg)?,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
input_transform~ : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)?,
accepts_tab_input~ : Bool,
event~ : @core.KeyboardEvent,
) -> @core.ViewEventResult[Msg] {
if !event.pressed {
return @core.ViewEventResult::ignored()
}
if event.key == "Tab" && accepts_tab_input {
let text = if event.modifiers.shift { "\u{000b}" } else { "\t" }
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::InsertText(text),
record_history=true,
)
}
if event.key == "Enter" {
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::InsertText("\n"),
record_history=true,
)
}
if @core.is_select_all_shortcut(event) {
let next_control = select_rich_text_control(ctx.text_control, value.get())
return view_event_result(
changed=true,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
),
)
}
if @core.is_undo_shortcut(event) {
let result = restore_text_history(ctx.text_control, value.get(), undo=true)
value.set(result.text)
return view_event_result(
changed=result.changed,
captured=true,
text_control=Some(result.control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=text_input_messages(on_input, result.text) +
text_input_edit_messages(on_input_edit, result.text, result.control) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
),
)
}
if @core.is_redo_shortcut(event) {
let result = restore_text_history(ctx.text_control, value.get(), undo=false)
value.set(result.text)
return view_event_result(
changed=result.changed,
captured=true,
text_control=Some(result.control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=text_input_messages(on_input, result.text) +
text_input_edit_messages(on_input_edit, result.text, result.control) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
),
)
}
if event.key == "Backspace" {
match @core.active_text_selection(ctx.text_control.selection) {
Some(selection) =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::DeleteRange(selection),
record_history=true,
)
None =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::DeleteSurrounding(1, 0),
record_history=true,
)
}
}
if event.key == "Delete" {
match @core.active_text_selection(ctx.text_control.selection) {
Some(selection) =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::DeleteRange(selection),
record_history=true,
)
None =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::DeleteSurrounding(0, 1),
record_history=true,
)
}
}
if event.key == "ArrowUp" {
let text = value.get()
let document = format(text)
let content = rich_text_editor_content_rect(ctx.frame)
return rich_text_vertical_caret_move_result(
ctx,
text,
document,
content,
font,
up=true,
extend=event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowDown" {
let text = value.get()
let document = format(text)
let content = rich_text_editor_content_rect(ctx.frame)
return rich_text_vertical_caret_move_result(
ctx,
text,
document,
content,
font,
up=false,
extend=event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowLeft" {
return rich_text_caret_move_result(
ctx,
value.get(),
@core.TextCaretDirection::MoveLeft,
event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowRight" {
return rich_text_caret_move_result(
ctx,
value.get(),
@core.TextCaretDirection::MoveRight,
event.modifiers.shift,
on_selection_change,
)
}
if event.key == "Home" {
return rich_text_line_edge_move_result(
ctx,
value.get(),
home=true,
on_selection_change,
)
}
if event.key == "End" {
return rich_text_line_edge_move_result(
ctx,
value.get(),
home=false,
on_selection_change,
)
}
match event.text {
Some(text) =>
if text != "" {
rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::InsertText(text),
record_history=true,
)
} else {
@core.ViewEventResult::ignored()
}
None => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] rich_text_input_event(
ctx : @core.ViewEventContext,
value~ : @core.Binding[String],
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?) -> Msg)?,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
input_transform~ : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)?,
event~ : @core.TextInputEvent,
record_history~ : Bool,
) -> @core.ViewEventResult[Msg] {
let text = value.get()
let result = match event {
@core.TextInputEvent::InsertText(inserted) =>
if inserted == "" {
(
{ text, control: ctx.text_control, changed: false } :
TextInputApplyResult)
} else {
match
rich_text_transform_insert(
input_transform,
text,
ctx.text_control,
inserted,
) {
Some(transformed) =>
apply_rich_text_input_transform(
ctx.text_control,
text,
transformed,
record_history~,
)
None =>
apply_text_input(ctx.text_control, text, event, record_history~)
}
}
@core.TextInputEvent::DeleteRange(_) =>
match
rich_text_transform_insert(
input_transform,
text,
ctx.text_control,
"\u{000e}",
) {
Some(transformed) =>
apply_rich_text_input_transform(
ctx.text_control,
text,
transformed,
record_history~,
)
None => apply_text_input(ctx.text_control, text, event, record_history~)
}
@core.TextInputEvent::DeleteSurrounding(before, after) => {
let signal = if before > 0 && after == 0 {
"\u{0008}"
} else if before == 0 && after > 0 {
"\u{007f}"
} else {
""
}
if signal != "" {
match
rich_text_transform_insert(
input_transform,
text,
ctx.text_control,
signal,
) {
Some(transformed) =>
apply_rich_text_input_transform(
ctx.text_control,
text,
transformed,
record_history~,
)
None =>
apply_text_input(ctx.text_control, text, event, record_history~)
}
} else {
apply_text_input(ctx.text_control, text, event, record_history~)
}
}
_ => apply_text_input(ctx.text_control, text, event, record_history~)
}
if result.changed {
value.set(result.text)
}
view_event_result(
changed=result.changed,
captured=result.changed,
text_control=Some(result.control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=if result.changed {
text_input_messages(on_input, result.text) +
text_input_edit_messages(on_input_edit, result.text, result.control) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
///|
fn[Msg] rich_text_editor_command(
ctx : @core.ViewEventContext,
value~ : @core.Binding[String],
format~ : RichTextFormatter,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?) -> Msg)?,
on_selection_change~ : ((Int, @core.TextRange?) -> Msg)?,
input_transform~ : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)?,
intent : @core.CommandIntent,
paste_text~ : String?,
) -> @core.ViewTextCommandResult[Msg] {
let text = value.get()
let document = format(text)
match intent {
@core.CommandIntent::Copy =>
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(
clipboard_text=Some(
selected_rich_text(text, document, ctx.text_control.selection),
),
),
)
@core.CommandIntent::Cut => {
let copied = selected_rich_text(
text,
document,
ctx.text_control.selection,
)
match @core.active_text_selection(ctx.text_control.selection) {
Some(selection) => {
let event_result = rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::DeleteRange(selection),
record_history=true,
)
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(
changed=event_result.changed,
clipboard_text=Some(copied),
),
text_control=event_result.text_control,
messages=event_result.messages,
)
}
None =>
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(clipboard_text=Some("")),
)
}
}
@core.CommandIntent::Paste =>
match paste_text {
Some(value_to_paste) => {
let event_result = rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=@core.TextInputEvent::InsertText(value_to_paste),
record_history=true,
)
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(
changed=event_result.changed,
),
text_control=event_result.text_control,
messages=event_result.messages,
)
}
None =>
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(),
)
}
@core.CommandIntent::Undo => {
let result = restore_text_history(ctx.text_control, text, undo=true)
if result.changed {
value.set(result.text)
}
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(changed=result.changed),
text_control=Some(result.control),
messages=if result.changed {
text_input_messages(on_input, result.text) +
text_input_edit_messages(on_input_edit, result.text, result.control) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
@core.CommandIntent::Redo => {
let result = restore_text_history(ctx.text_control, text, undo=false)
if result.changed {
value.set(result.text)
}
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(changed=result.changed),
text_control=Some(result.control),
messages=if result.changed {
text_input_messages(on_input, result.text) +
text_input_edit_messages(on_input_edit, result.text, result.control) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
@core.CommandIntent::SelectAll => {
let next_control = select_rich_text_control(ctx.text_control, text)
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(changed=true),
text_control=Some(next_control),
messages=selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
),
)
}
_ => @core.ViewTextCommandResult::ignored()
}
}
///|
fn rich_text_input_state(
text~ : String,
frame~ : @core.Rect,
format~ : RichTextFormatter,
font~ : @core.FontSpec,
control~ : @core.TextControlStateContext,
selection_caret~ : Int,
selection_range~ : @core.TextRange?,
text_system~ : @core.TextSystem,
) -> @core.TextInputState {
let boundaries = @core.TextGraphemeBoundaries::new(text~)
let control = if selection_caret >= 0 {
{ ..control, caret: selection_caret, selection: selection_range }
} else {
control
}
let display_text = rich_text_with_composition(text, control, focused=true)
let content = rich_text_editor_content_rect(frame)
let caret = boundaries.nearest_boundary(control.caret)
let visual_caret = @core.clamp_int(
rich_text_visual_caret(text, control, focused=true),
0,
text_length(display_text),
)
let document = format(display_text)
let caret_rect = rich_text_document_caret_rect_or_plain(
document, visual_caret, content, font, text_system, display_text,
)
{
focused: true,
element_id: None,
text,
caret,
selection: rich_text_input_grapheme_selection(boundaries, control.selection),
composition: control.composition,
composition_cursor: control.composition_cursor,
frame,
caret_rect,
}
}
///|
fn[Msg] rich_text_caret_move_result(
ctx : @core.ViewEventContext,
text : String,
direction : @core.TextCaretDirection,
extend : Bool,
on_selection_change : ((Int, @core.TextRange?) -> Msg)?,
) -> @core.ViewEventResult[Msg] {
let caret_move = @core.move_text_caret(
text,
ctx.text_control.caret,
ctx.text_control.selection,
direction,
extend~,
)
let next_control = {
..ctx.text_control,
caret: caret_move.caret,
selection: caret_move.selection,
composition: None,
composition_cursor: None,
}
let changed = next_control.caret != ctx.text_control.caret ||
next_control.selection != ctx.text_control.selection ||
ctx.text_control.composition is Some(_) ||
ctx.text_control.composition_cursor is Some(_)
view_event_result(
changed~,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=if changed {
selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
)
} else {
[]
},
)
}
///|
fn rich_text_with_composition(
text : String,
control : @core.TextControlStateContext,
focused~ : Bool,
) -> String {
if !focused {
text
} else {
match control.composition {
Some(composition) =>
replace_current_selection(
text,
control.caret,
control.selection,
composition,
).text
None => text
}
}
}
///|
fn rich_text_visual_caret(
text : String,
control : @core.TextControlStateContext,
focused~ : Bool,
) -> Int {
let caret = @core.clamp_int(control.caret, 0, text_length(text))
if !focused {
caret
} else {
match control.composition {
Some(composition) =>
@core.composition_start_index(text, control.caret, control.selection) +
@core.composition_cursor_offset(control, composition)
None => caret
}
}
}
///|
fn rich_text_document_caret_rect_or_plain(
document : RichTextDocument,
source_caret : Int,
content : @core.Rect,
font : @core.FontSpec,
text_system : @core.TextSystem,
display_text : String,
) -> @core.Rect {
match
rich_text_document_caret_rect_at_source(
document,
source_caret,
content,
font,
text_system~,
) {
Some(rect) => rect
None =>
plain_rich_text_caret_rect(
display_text, source_caret, content, font, text_system,
)
}
}
///|
fn plain_rich_text_caret_rect(
text : String,
caret : Int,
content : @core.Rect,
font : @core.FontSpec,
text_system : @core.TextSystem,
) -> @core.Rect {
let prefix = string_prefix(text, caret)
let width = text_system.measure_text(
@core.TextLayoutInput::new(
text=prefix,
font~,
max_width=content.size.width,
),
).size.width
@core.Rect::new(
x=content.origin.x + width,
y=content.origin.y + 4.0,
width=1.5,
height=max_double(10.0, font.size * 1.55 - 8.0),
)
}
///|
pub fn rich_text_editor_content_rect(frame : @core.Rect) -> @core.Rect {
frame.inset(@core.Insets::symmetric(horizontal=14.0, vertical=12.0))
}
///|
fn string_prefix(text : String, end : Int) -> String {
let chars = text.to_array()
String::from_array(chars[:@core.clamp_int(end, 0, chars.length())])
}
///|
pub fn rich_text_document_height(
document : RichTextDocument,
base_font : @core.FontSpec,
text_system? : @core.TextSystem = @core.TextSystem::fallback(),
) -> Double {
let mut height = 0.0
for block in document.blocks {
height = height +
rich_text_block_line_height(block, base_font, text_system) +
block.inset.top +
block.inset.bottom
}
height
}
///|
fn rich_text_block_line_height(
block : RichTextBlock,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Double {
let text_height = rich_text_block_line_step(block, base_font, text_system)
let mut height = text_height *
rich_text_block_visual_line_count(block).to_double()
match block.table {
Some(table) =>
height = max_double(
height,
table.row_height * table.rows.length().to_double(),
)
None => ()
}
for run in block.runs {
match run.image_size {
Some(size) => height = max_double(height, size.height)
None => ()
}
}
height
}
///|
fn rich_text_block_line_step(
block : RichTextBlock,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Double {
block.line_height.unwrap_or(
text_system.measure_text(
@core.TextLayoutInput::new(
text="Mg",
font=rich_text_block_font(block, base_font),
),
).size.height,
)
}
///|
fn rich_text_block_visual_line_count(block : RichTextBlock) -> Int {
let mut line_count = 1
line_count = line_count + rich_text_text_newline_count(block.prefix)
for run in block.runs {
line_count = line_count +
rich_text_text_newline_count(rich_text_run_visual_text(run))
}
line_count
}
///|
fn rich_text_text_newline_count(text : String) -> Int {
let mut count = 0
for ch in text {
if ch == '\n' {
count = count + 1
}
}
count
}
///|
fn rich_text_run_visual_text(run : RichTextRun) -> String {
run.visual_text.unwrap_or(run.text)
}
///|
fn rich_text_block_font(
block : RichTextBlock,
base_font : @core.FontSpec,
) -> @core.FontSpec {
block.font.unwrap_or(base_font)
}
///|
fn rich_text_block_color(
block : RichTextBlock,
foreground : @core.Color,
) -> @core.Color {
block.color.unwrap_or(foreground)
}
///|
fn rich_text_width(
text : String,
font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Double {
text_system.measure_text(@core.TextLayoutInput::new(text~, font~)).size.width
}
///|
fn rich_text_run_segment_width(
run : RichTextRun,
text : String,
font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Double {
match run.image_size {
Some(size) => size.width
None => rich_text_width(text, font, text_system)
}
}
///|
fn rich_text_run_segment_height(
run : RichTextRun,
line_height : Double,
) -> Double {
match run.image_size {
Some(size) => max_double(line_height, size.height)
None => line_height
}
}
///|
fn rich_text_run_hit_width(run : RichTextRun, width : Double) -> Double? {
match run.image_source {
Some(_) => Some(width)
_ => None
}
}
///|
fn rich_text_split_lines(text : String) -> Array[String] {
let lines : Array[String] = []
let current : Array[Char] = []
for ch in text {
if ch == '\n' {
lines.push(String::from_array(current))
current.clear()
} else {
current.push(ch)
}
}
lines.push(String::from_array(current))
lines
}
///|
fn rich_text_input_grapheme_selection(
boundaries : @core.TextGraphemeBoundaries,
selection : @core.TextRange?,
) -> @core.TextRange? {
match selection {
Some(selection) => {
let start = boundaries.nearest_boundary(selection.start)
let end = boundaries.nearest_boundary(selection.end)
if start == end {
None
} else {
Some(@core.TextRange::new(start~, end~))
}
}
_ => None
}
}
///|
fn select_rich_text_control(
control : @core.TextControlStateContext,
text : String,
) -> @core.TextControlStateContext {
let length = text_length(text)
{
..control,
caret: length,
selection: Some(@core.TextRange::new(start=0, end=length)),
text_selection_anchor: None,
composition: None,
composition_cursor: None,
}
}
///|
fn[Msg] rich_text_line_edge_move_result(
ctx : @core.ViewEventContext,
text : String,
home~ : Bool,
on_selection_change : ((Int, @core.TextRange?) -> Msg)?,
) -> @core.ViewEventResult[Msg] {
let caret = if home {
multiline_line_start(text, ctx.text_control.caret)
} else {
multiline_line_end(text, ctx.text_control.caret)
}
let next_control = {
..ctx.text_control,
caret,
selection: None,
composition: None,
composition_cursor: None,
}
let changed = next_control.caret != ctx.text_control.caret ||
ctx.text_control.selection is Some(_) ||
ctx.text_control.composition is Some(_) ||
ctx.text_control.composition_cursor is Some(_)
view_event_result(
changed~,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=if changed {
selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
)
} else {
[]
},
)
}
///|
fn rich_text_vertical_target_offset(
document : RichTextDocument,
source_caret : Int,
caret_rect : @core.Rect,
content : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
up~ : Bool,
) -> Int {
if document.blocks.is_empty() {
let line_height = if caret_rect.size.height > 0.0 {
caret_rect.size.height
} else {
max_double(10.0, base_font.size * 1.55 - 8.0)
}
let target_y = if up {
caret_rect.origin.y - line_height * 0.5
} else {
caret_rect.origin.y + caret_rect.size.height + line_height * 0.5
}
let target_point = @core.Point::new(x=caret_rect.origin.x, y=target_y)
return rich_text_document_source_offset_at_point(
document, target_point, content, base_font, text_system,
).unwrap_or(source_caret)
}
let mut block_index = -1
let mut visual_line = 0
let mut y = content.origin.y
for index, block in document.blocks {
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_height = line_height + block.inset.top + block.inset.bottom
if rich_text_caret_before_block(source_caret, block) {
block_index = index
visual_line = 0
break
}
match block.table {
Some(_) => {
block_index = index
visual_line = 0
break
}
None => ()
}
match rich_text_block_visual_offset(block, source_caret) {
Some(vo) => {
block_index = index
visual_line = rich_text_visual_offset_to_line(block, vo)
break
}
None => ()
}
y = y + block_height
}
if block_index < 0 {
block_index = document.blocks.length() - 1
let last_block = document.blocks[block_index]
visual_line = rich_text_block_visual_line_count(last_block) - 1
let last_line_height = rich_text_block_line_height(
last_block, base_font, text_system,
)
let last_block_height = last_line_height +
last_block.inset.top +
last_block.inset.bottom
y = y - last_block_height
}
let block = document.blocks[block_index]
let step = rich_text_block_line_step(block, base_font, text_system)
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_top = y
let frame_top = y + block.inset.top
let line_center_y = if visual_line >= 0 &&
visual_line < rich_text_block_visual_line_count(block) {
frame_top + (visual_line.to_double() + 0.5) * step
} else {
caret_rect.origin.y + caret_rect.size.height * 0.5
}
let target_y = if up {
if visual_line == 0 {
block_top - 1.0
} else {
line_center_y - step
}
} else {
let line_count = rich_text_block_visual_line_count(block)
if visual_line >= line_count - 1 {
block_top + line_height + block.inset.top + block.inset.bottom + 1.0
} else {
line_center_y + step
}
}
let target_point = @core.Point::new(
x=caret_rect.origin.x + caret_rect.size.width * 0.5,
y=target_y,
)
match
rich_text_document_source_offset_at_point(
document, target_point, content, base_font, text_system,
) {
Some(offset) =>
if offset != source_caret {
offset
} else {
rich_text_vertical_block_boundary_offset(
document,
block_index,
source_caret,
up~,
)
}
None =>
rich_text_vertical_block_boundary_offset(
document,
block_index,
source_caret,
up~,
)
}
}
///|
fn rich_text_vertical_block_boundary_offset(
document : RichTextDocument,
block_index : Int,
source_caret : Int,
up~ : Bool,
) -> Int {
if up {
if block_index <= 0 {
return 0
}
let prev_block = document.blocks[block_index - 1]
match rich_text_block_end_source_offset(prev_block) {
Some(end) => end
None => source_caret
}
} else {
if block_index >= document.blocks.length() - 1 {
return source_caret
}
let next_block = document.blocks[block_index + 1]
match next_block.content_range {
Some(range) => range.start
None =>
match next_block.source_range {
Some(range) => range.start
None => source_caret
}
}
}
}
///|
fn rich_text_visual_offset_to_line(
block : RichTextBlock,
visual_offset : Int,
) -> Int {
let prefix_len = text_length(block.prefix)
let mut consumed = prefix_len
let mut line = 0
if visual_offset <= prefix_len {
return 0
}
for run in block.runs {
let segments = rich_text_split_lines(rich_text_run_visual_text(run))
for index, segment in segments {
let seg_len = text_length(segment)
if consumed + seg_len >= visual_offset {
return line
}
consumed = consumed + seg_len
if index + 1 < segments.length() {
if consumed == visual_offset {
return line
}
consumed = consumed + 1
line = line + 1
}
}
}
line
}
///|
fn[Msg] rich_text_vertical_caret_move_result(
ctx : @core.ViewEventContext,
text : String,
document : RichTextDocument,
content : @core.Rect,
font : @core.FontSpec,
up~ : Bool,
extend~ : Bool,
on_selection_change : ((Int, @core.TextRange?) -> Msg)?,
) -> @core.ViewEventResult[Msg] {
let display_text = rich_text_with_composition(
text,
ctx.text_control,
focused=true,
)
let visual_caret = @core.clamp_int(
rich_text_visual_caret(text, ctx.text_control, focused=true),
0,
text_length(display_text),
)
let caret_rect = rich_text_document_caret_rect_or_plain(
document,
visual_caret,
content,
font,
ctx.text_system,
display_text,
)
let target_offset = rich_text_vertical_target_offset(
document,
visual_caret,
caret_rect,
content,
font,
ctx.text_system,
up~,
)
if target_offset == ctx.text_control.caret && !extend {
return @core.ViewEventResult::ignored()
}
let next_control = if extend {
let anchor = match ctx.text_control.text_selection_anchor {
Some(a) => a
None => ctx.text_control.caret
}
let start = @core.min_int(anchor, target_offset)
let end = @core.max_int(anchor, target_offset)
{
..ctx.text_control,
caret: target_offset,
selection: Some(@core.TextRange::new(start~, end~)),
text_selection_anchor: Some(anchor),
composition: None,
composition_cursor: None,
}
} else {
{
..ctx.text_control,
caret: target_offset,
selection: None,
text_selection_anchor: None,
composition: None,
composition_cursor: None,
}
}
let changed = next_control.caret != ctx.text_control.caret ||
next_control.selection != ctx.text_control.selection ||
ctx.text_control.composition is Some(_) ||
ctx.text_control.composition_cursor is Some(_)
view_event_result(
changed~,
captured=true,
text_control=Some(next_control),
dirty=@core.ViewDirtyHint::ViewPaintDirty,
messages=if changed {
selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
)
} else {
[]
},
)
}
///|
fn multiline_line_start(text : String, caret : Int) -> Int {
let chars = text.to_array()
let mut index = @core.clamp_int(caret, 0, chars.length())
while index > 0 && chars[index - 1] != '\n' {
index = index - 1
}
index
}
///|
fn multiline_line_end(text : String, caret : Int) -> Int {
let chars = text.to_array()
let mut index = @core.clamp_int(caret, 0, chars.length())
while index < chars.length() && chars[index] != '\n' {
index = index + 1
}
index
}
///|
fn rich_text_transform_insert(
input_transform : ((String, Int, @core.TextRange?, String) -> RichTextInputTransform?)?,
text : String,
control : @core.TextControlStateContext,
inserted : String,
) -> RichTextInputTransform? {
match input_transform {
Some(transform) =>
transform(text, control.caret, control.selection, inserted)
None => None
}
}
///|
fn apply_rich_text_input_transform(
control : @core.TextControlStateContext,
previous : String,
edit : RichTextInputTransform,
record_history~ : Bool,
) -> TextInputApplyResult {
let changed = edit.text != previous ||
edit.caret != control.caret ||
edit.selection != control.selection ||
control.composition is Some(_) ||
control.composition_cursor is Some(_)
if changed {
let undo_stack : Array[@core.TextHistoryEntryContext] = []
undo_stack.append(control.undo_stack)
if record_history && previous != edit.text {
undo_stack.push({
text: previous,
caret: control.caret,
selection: control.selection,
})
}
{
text: edit.text,
control: {
..control,
caret: edit.caret,
selection: edit.selection,
composition: None,
composition_cursor: None,
undo_stack,
redo_stack: [],
},
changed: true,
}
} else {
{ text: previous, control, changed: false }
}
}
///|
fn selected_rich_text(
text : String,
document : RichTextDocument,
selection : @core.TextRange?,
) -> String {
match @core.active_text_selection(selection) {
Some(selection) =>
if rich_text_document_has_source_ranges(document) {
selected_rich_text_document_text(document, selection)
} else {
@core.selected_text(text, Some(selection))
}
None => ""
}
}
///|
fn selected_rich_text_document_text(
document : RichTextDocument,
selection : @core.TextRange,
) -> String {
let lines : Array[String] = []
for block in document.blocks {
match selected_rich_text_block_text(block, selection) {
Some(text) => lines.push(text)
None => ()
}
}
join_text_lines(lines)
}
///|
fn selected_rich_text_block_text(
block : RichTextBlock,
selection : @core.TextRange,
) -> String? {
match (block.source_range, block.content_range) {
(Some(source), Some(content)) =>
if selection.end <= source.start || selection.start >= source.end {
None
} else {
let start = @core.clamp_int(selection.start, content.start, content.end)
let end = @core.clamp_int(selection.end, content.start, content.end)
let text = selected_rich_text_runs_text(block, start, end)
if text == "" && start != end {
None
} else {
Some(text)
}
}
(_, Some(content)) =>
if selection.end <= content.start || selection.start >= content.end {
None
} else {
let start = @core.clamp_int(selection.start, content.start, content.end)
let end = @core.clamp_int(selection.end, content.start, content.end)
let text = selected_rich_text_runs_text(block, start, end)
if text == "" && start != end {
None
} else {
Some(text)
}
}
_ => None
}
}
///|
fn selected_rich_text_runs_text(
block : RichTextBlock,
start : Int,
end : Int,
) -> String {
let parts : Array[String] = []
for run in block.runs {
match run.source_range {
Some(range) =>
if end > range.start && start < range.end {
let copy_text = rich_text_run_copy_text(run)
parts.push(
rich_text_run_slice(
copy_text,
rich_text_run_copy_offset(
start - range.start,
source_text=run.text,
copy_text~,
),
rich_text_run_copy_offset(
end - range.start,
source_text=run.text,
copy_text~,
),
),
)
}
None => ()
}
}
join_text_lines(parts)
}
///|
fn rich_text_run_copy_text(run : RichTextRun) -> String {
match run.visual_text {
Some(text) => text
None =>
match run.image_source {
Some(_) => rich_text_image_alt_text(run.text).unwrap_or(run.text)
None => run.text
}
}
}
///|
fn rich_text_run_copy_offset(
source_offset : Int,
source_text~ : String,
copy_text~ : String,
) -> Int {
let source_length = text_length(source_text)
let copy_length = text_length(copy_text)
if source_length <= 0 || copy_length <= 0 {
0
} else {
@core.clamp_int(
(source_offset.to_double() *
copy_length.to_double() /
source_length.to_double())
.round()
.to_int(),
0,
copy_length,
)
}
}
///|
fn rich_text_image_alt_text(text : String) -> String? {
let chars = text.to_array()
if chars.length() < 5 || chars[0] != '!' || chars[1] != '[' {
return None
}
let mut index = 2
while index < chars.length() {
if chars[index] == ']' &&
index + 1 < chars.length() &&
chars[index + 1] == '(' {
return Some(String::from_array(chars[2:index]))
}
index = index + 1
}
None
}
///|
fn rich_text_run_slice(text : String, start : Int, end : Int) -> String {
let chars = text.to_array()
let range = @core.normalize_grapheme_range(
@core.TextGraphemeBoundaries::new(text~),
@core.TextRange::new(start~, end~),
)
let start = @core.clamp_int(range.start, 0, chars.length())
let end = @core.clamp_int(range.end, start, chars.length())
String::from_array(chars[start:end])
}
///|
fn join_text_lines(lines : Array[String]) -> String {
let chars : Array[Char] = []
for index in 0.. 0 {
chars.push('\n')
}
chars.append(lines[index].to_array())
}
String::from_array(chars)
}
///|
fn append_rich_text_selection_commands(
commands : Array[@core.DrawCommand],
document : RichTextDocument,
content : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
selection : @core.TextRange?,
) -> Unit {
match @core.active_text_selection(selection) {
Some(selection) => {
let mut y = content.origin.y
for block in document.blocks {
let line_height = rich_text_block_line_height(
block, base_font, text_system,
)
let block_height = line_height + block.inset.top + block.inset.bottom
let frame = @core.Rect::new(
x=content.origin.x + block.inset.left,
y=y + block.inset.top,
width=max_double(
0.0,
content.size.width - block.inset.left - block.inset.right,
),
height=line_height,
)
match rich_text_block_selection_visual_range(block, selection) {
Some(range) =>
append_rich_text_block_selection_rect(
commands, block, range, frame, base_font, text_system,
)
None => ()
}
y = y + block_height
}
}
None => ()
}
}
///|
fn rich_text_block_selection_visual_range(
block : RichTextBlock,
selection : @core.TextRange,
) -> @core.TextRange? {
match (block.source_range, block.content_range) {
(Some(source), Some(content)) =>
if selection.end <= source.start || selection.start >= source.end {
None
} else {
let start = rich_text_block_source_offset_to_visual(
block,
@core.clamp_int(selection.start, source.start, source.end),
content,
)
let end = rich_text_block_source_offset_to_visual(
block,
@core.clamp_int(selection.end, source.start, source.end),
content,
)
if end > start {
Some(@core.TextRange::new(start~, end~))
} else {
None
}
}
(_, Some(content)) =>
if selection.end <= content.start || selection.start >= content.end {
None
} else {
let start = rich_text_block_source_offset_to_visual(
block,
@core.clamp_int(selection.start, content.start, content.end),
content,
)
let end = rich_text_block_source_offset_to_visual(
block,
@core.clamp_int(selection.end, content.start, content.end),
content,
)
if end > start {
Some(@core.TextRange::new(start~, end~))
} else {
None
}
}
_ => None
}
}
///|
fn append_rich_text_block_selection_rect(
commands : Array[@core.DrawCommand],
block : RichTextBlock,
range : @core.TextRange,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Unit {
let start_rect = rich_text_block_caret_rect(
block,
range.start,
frame,
base_font,
text_system,
)
let end_rect = rich_text_block_caret_rect(
block,
range.end,
frame,
base_font,
text_system,
)
if end_rect.origin.x > start_rect.origin.x {
commands.push(
@core.DrawCommand::FillRect(
@core.Rect::new(
x=start_rect.origin.x,
y=frame.origin.y + 4.0,
width=end_rect.origin.x - start_rect.origin.x,
height=max_double(10.0, frame.size.height - 8.0),
),
@core.Color::blue().with_alpha(0.18),
),
)
}
}
///|
pub fn rich_text_document_caret_rect_at_source(
document : RichTextDocument,
source_caret : Int,
content : @core.Rect,
base_font : @core.FontSpec,
text_system? : @core.TextSystem = @core.TextSystem::fallback(),
) -> @core.Rect? {
rich_text_document_caret_rect(
document, source_caret, content, base_font, text_system,
)
}
///|
fn rich_text_document_caret_rect(
document : RichTextDocument,
source_caret : Int,
content : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> @core.Rect? {
if !rich_text_document_has_source_ranges(document) {
return None
}
let mut y = content.origin.y
let mut last_rect = @core.Rect::new(
x=content.origin.x,
y=content.origin.y + 4.0,
width=1.5,
height=max_double(10.0, base_font.size * 1.55 - 8.0),
)
for block in document.blocks {
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_height = line_height + block.inset.top + block.inset.bottom
let frame = @core.Rect::new(
x=content.origin.x + block.inset.left,
y=y + block.inset.top,
width=max_double(
0.0,
content.size.width - block.inset.left - block.inset.right,
),
height=line_height,
)
if rich_text_caret_before_block(source_caret, block) {
match block.table {
Some(table) =>
return Some(
rich_text_block_table_caret_rect(
block, table, source_caret, frame, base_font, text_system,
),
)
None => ()
}
return Some(
rich_text_block_caret_rect(block, 0, frame, base_font, text_system),
)
}
match block.table {
Some(table) =>
return Some(
rich_text_block_table_caret_rect(
block, table, source_caret, frame, base_font, text_system,
),
)
None => ()
}
match rich_text_block_visual_offset(block, source_caret) {
Some(visual_offset) =>
return Some(
rich_text_block_caret_rect(
block, visual_offset, frame, base_font, text_system,
),
)
None => ()
}
last_rect = rich_text_block_caret_rect(
block,
rich_text_block_visual_length(block),
frame,
base_font,
text_system,
)
y = y + block_height
}
Some(last_rect)
}
///|
fn rich_text_document_source_offset_at_point(
document : RichTextDocument,
point : @core.Point,
content : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int? {
if !rich_text_document_has_source_ranges(document) {
return None
}
let mut y = content.origin.y
let mut last_offset : Int? = None
let mut last_block : RichTextBlock? = None
let mut last_frame = @core.Rect::new(x=0.0, y=0.0, width=0.0, height=0.0)
for block in document.blocks {
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_height = line_height + block.inset.top + block.inset.bottom
let frame = @core.Rect::new(
x=content.origin.x + block.inset.left,
y=y + block.inset.top,
width=max_double(
0.0,
content.size.width - block.inset.left - block.inset.right,
),
height=line_height,
)
if point.y < y + block_height {
match
rich_text_block_table_source_offset_at_point(
block, point, frame, base_font, text_system,
) {
Some(offset) => return Some(offset)
None => ()
}
let offset = rich_text_block_source_offset_at_point(
block, point, frame, base_font, text_system,
)
return Some(offset)
}
last_block = Some(block)
last_frame = frame
last_offset = rich_text_block_end_source_offset(block)
y = y + block_height
}
match last_block {
Some(block) =>
Some(
rich_text_block_source_offset_at_point(
block, point, last_frame, base_font, text_system,
),
)
None => last_offset
}
}
///|
fn rich_text_block_end_source_offset(block : RichTextBlock) -> Int? {
match block.source_range {
Some(range) => Some(range.end)
None =>
Some(
rich_text_block_source_offset_from_visual(
block,
rich_text_block_visual_length(block),
),
)
}
}
///|
fn rich_text_caret_before_block(
source_caret : Int,
block : RichTextBlock,
) -> Bool {
match block.source_range {
Some(range) => source_caret < range.start
None => false
}
}
///|
fn rich_text_block_visual_offset(
block : RichTextBlock,
source_caret : Int,
) -> Int? {
match (block.source_range, block.content_range) {
(Some(source), Some(content)) =>
if source_caret < source.start || source_caret > source.end {
None
} else if source_caret < content.start {
Some(0)
} else if source_caret > content.end {
Some(rich_text_block_visual_length(block))
} else {
Some(
rich_text_block_source_offset_to_visual(block, source_caret, content),
)
}
(_, Some(content)) =>
if source_caret < content.start || source_caret > content.end {
None
} else {
Some(
rich_text_block_source_offset_to_visual(block, source_caret, content),
)
}
_ => None
}
}
///|
fn rich_text_document_prefix_source_offset_at_point(
document : RichTextDocument,
point : @core.Point,
content : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int? {
if !rich_text_document_has_source_ranges(document) {
return None
}
let mut y = content.origin.y
let mut fallback : Int? = None
for block in document.blocks {
let line_height = rich_text_block_line_height(block, base_font, text_system)
let block_height = line_height + block.inset.top + block.inset.bottom
let frame = @core.Rect::new(
x=content.origin.x + block.inset.left,
y=y + block.inset.top,
width=max_double(
0.0,
content.size.width - block.inset.left - block.inset.right,
),
height=line_height,
)
if fallback is None &&
rich_text_point_in_prefix_lane(
block, point, frame, base_font, text_system,
) {
fallback = match block.content_range {
Some(range) => Some(range.start)
None => None
}
}
if point.y < y + block_height {
return rich_text_block_prefix_source_offset_at_point(
block, point, frame, base_font, text_system,
)
}
y = y + block_height
}
fallback
}
///|
fn rich_text_block_prefix_source_offset_at_point(
block : RichTextBlock,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int? {
if block.prefix == "" {
return None
}
let font = rich_text_block_font(block, base_font)
let width = max_double(
rich_text_width(block.prefix, font, text_system),
font.size * 1.8,
)
if point.x >= frame.origin.x && point.x <= frame.origin.x + width {
match block.content_range {
Some(content) => Some(content.start)
None => None
}
} else {
None
}
}
///|
fn rich_text_point_in_prefix_lane(
block : RichTextBlock,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Bool {
if block.prefix == "" {
return false
}
let font = rich_text_block_font(block, base_font)
let width = max_double(
rich_text_width(block.prefix, font, text_system),
font.size * 1.8,
)
point.x >= frame.origin.x && point.x <= frame.origin.x + width
}
///|
fn rich_text_block_source_offset_at_point(
block : RichTextBlock,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int {
let visual = rich_text_block_visual_offset_at_point(
block, point, frame, base_font, text_system,
)
rich_text_block_source_offset_from_visual(block, visual)
}
///|
fn rich_text_block_visual_offset_at_point(
block : RichTextBlock,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int {
let font = rich_text_block_font(block, base_font)
let line_step = rich_text_block_line_step(block, base_font, text_system)
let target_line = @core.clamp_int(
((point.y - frame.origin.y) / line_step).floor().to_int(),
0,
rich_text_block_visual_line_count(block) - 1,
)
let mut origin = frame.origin.x
let mut visual_offset = 0
let mut line = 0
let mut line_end = 0
if block.prefix != "" {
let prefix_length = text_length(block.prefix)
let hit = rich_text_run_visual_offset_at_x(
block.prefix,
font,
point.x,
origin,
text_system,
)
let width = rich_text_width(block.prefix, font, text_system)
if target_line == 0 && (hit < prefix_length || point.x <= origin + width) {
return hit
}
visual_offset = visual_offset + prefix_length
line_end = visual_offset
origin = origin + width
}
for run in block.runs {
let run_font = run.font.unwrap_or(font)
let segments = rich_text_split_lines(rich_text_run_visual_text(run))
for index, segment in segments {
let segment_length = text_length(segment)
let segment_width = rich_text_run_segment_width(
run, segment, run_font, text_system,
)
if line == target_line {
line_end = visual_offset + segment_length
if point.x <= origin + segment_width || segment_length == 0 {
return visual_offset +
rich_text_run_visual_offset_at_x(
segment,
run_font,
point.x,
origin,
text_system,
width=rich_text_run_hit_width(run, segment_width),
)
}
}
visual_offset = visual_offset + segment_length
origin = origin + segment_width
if index + 1 < segments.length() {
if line == target_line {
return line_end
}
visual_offset = visual_offset + 1
line = line + 1
origin = frame.origin.x
}
}
}
line_end
}
///|
fn rich_text_run_visual_offset_at_x(
text : String,
font : @core.FontSpec,
x : Double,
origin_x : Double,
text_system : @core.TextSystem,
width? : Double? = None,
) -> Int {
let chars = text.to_array()
let boundaries = @core.TextGraphemeBoundaries::new(text~)
if x <= origin_x {
return 0
}
match width {
Some(width) =>
if chars.length() <= 1 {
if x < origin_x + width / 2.0 {
return 0
} else {
return chars.length()
}
} else {
let relative = clamp_double((x - origin_x) / width, 0.0, 1.0)
return boundaries.nearest_boundary(
(relative * chars.length().to_double()).round().to_int(),
)
}
None => ()
}
for index in 0.. Int {
match block.content_range {
Some(content) => {
let prefix_length = text_length(block.prefix)
if visual_offset <= prefix_length {
content.start
} else {
match
rich_text_block_run_source_offset_from_visual(
block,
visual_offset - prefix_length,
) {
Some(offset) => offset
None =>
@core.clamp_int(
content.start + visual_offset - prefix_length,
content.start,
content.end,
)
}
}
}
None => visual_offset
}
}
///|
fn rich_text_block_run_source_offset_from_visual(
block : RichTextBlock,
visual_offset : Int,
) -> Int? {
let mut remaining = visual_offset
let mut fallback = match block.content_range {
Some(content) => Some(content.start)
None => None
}
for run in block.runs {
let visual_text = rich_text_run_visual_text(run)
let visual_length = text_length(visual_text)
if remaining <= visual_length {
match run.source_range {
Some(range) =>
return Some(
@core.clamp_int(
range.start +
rich_text_run_source_offset_from_visual_offset(
remaining,
visual_text,
run.text,
),
range.start,
range.end,
),
)
None => return fallback
}
}
remaining = remaining - visual_length
match run.source_range {
Some(range) => fallback = Some(range.end)
None => ()
}
}
match block.runs.last() {
Some(run) =>
match run.source_range {
Some(range) => Some(range.end)
None => fallback
}
None => None
}
}
///|
fn rich_text_block_source_offset_to_visual(
block : RichTextBlock,
source_offset : Int,
content : RichTextSourceRange,
) -> Int {
if source_offset < content.start {
0
} else if source_offset > content.end {
rich_text_block_visual_length(block)
} else {
let prefix_length = text_length(block.prefix)
match rich_text_block_run_visual_offset_from_source(block, source_offset) {
Some(offset) => prefix_length + offset
None =>
@core.clamp_int(
prefix_length + source_offset - content.start,
0,
rich_text_block_visual_length(block),
)
}
}
}
///|
fn rich_text_block_run_visual_offset_from_source(
block : RichTextBlock,
source_offset : Int,
) -> Int? {
let mut visual = 0
for run in block.runs {
let visual_text = rich_text_run_visual_text(run)
let visual_length = text_length(visual_text)
match run.source_range {
Some(range) =>
if source_offset <= range.end {
return Some(
visual +
rich_text_run_visual_offset_from_source_offset(
source_offset - range.start,
run.text,
visual_text,
),
)
}
None => ()
}
visual = visual + visual_length
}
Some(visual)
}
///|
fn rich_text_block_caret_rect(
block : RichTextBlock,
visual_offset : Int,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> @core.Rect {
let font = rich_text_block_font(block, base_font)
let line_step = rich_text_block_line_step(block, base_font, text_system)
let mut remaining = @core.clamp_int(
visual_offset,
0,
rich_text_block_visual_length(block),
)
let mut x = frame.origin.x
let mut y = frame.origin.y
if block.prefix != "" {
let prefix_length = text_length(block.prefix)
if remaining <= prefix_length {
let prefix = rich_text_prefix(block.prefix, remaining)
x = x + rich_text_width(prefix, font, text_system)
return rich_text_cursor_rect_at(
x,
@core.Rect::new(
x=frame.origin.x,
y~,
width=frame.size.width,
height=frame.size.height,
),
font,
)
}
remaining = remaining - prefix_length
x = x + rich_text_width(block.prefix, font, text_system)
}
for run in block.runs {
let run_font = run.font.unwrap_or(font)
let segments = rich_text_split_lines(rich_text_run_visual_text(run))
for index, segment in segments {
let segment_length = text_length(segment)
if remaining <= segment_length {
x = x +
rich_text_run_segment_prefix_width(
run, segment, remaining, run_font, text_system,
)
return rich_text_cursor_rect_at(
x,
@core.Rect::new(
x=frame.origin.x,
y~,
width=frame.size.width,
height=line_step,
),
run_font,
)
}
remaining = remaining - segment_length
x = x + rich_text_run_segment_width(run, segment, run_font, text_system)
if index + 1 < segments.length() {
if remaining == 0 {
return rich_text_cursor_rect_at(
x,
@core.Rect::new(
x=frame.origin.x,
y~,
width=frame.size.width,
height=line_step,
),
run_font,
)
}
remaining = remaining - 1
y = y + line_step
x = frame.origin.x
}
}
}
rich_text_cursor_rect_at(
x,
@core.Rect::new(
x=frame.origin.x,
y~,
width=frame.size.width,
height=line_step,
),
font,
)
}
///|
fn rich_text_run_segment_prefix_width(
run : RichTextRun,
text : String,
visual_offset : Int,
font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Double {
let length = text_length(text)
if length <= 0 || visual_offset <= 0 {
0.0
} else if visual_offset >= length {
rich_text_run_segment_width(run, text, font, text_system)
} else {
match run.image_source {
Some(_) =>
rich_text_run_segment_width(run, text, font, text_system) *
visual_offset.to_double() /
length.to_double()
None =>
rich_text_width(
rich_text_prefix(text, visual_offset),
font,
text_system,
)
}
}
}
///|
fn rich_text_prefix(text : String, length : Int) -> String {
let chars = text.to_array()
let end = @core.TextGraphemeBoundaries::new(text~).nearest_boundary(length)
String::from_array(chars[0:end])
}
///|
fn rich_text_cursor_rect_at(
x : Double,
frame : @core.Rect,
font : @core.FontSpec,
) -> @core.Rect {
@core.Rect::new(
x~,
y=frame.origin.y + 4.0,
width=1.5,
height=max_double(10.0, font.size * 1.55 - 8.0),
)
}
///|
fn rich_text_block_visual_length(block : RichTextBlock) -> Int {
let mut length = text_length(block.prefix)
for run in block.runs {
length = length + text_length(rich_text_run_visual_text(run))
}
length
}
///|
fn rich_text_document_has_source_ranges(document : RichTextDocument) -> Bool {
document.blocks.any(block => {
block.source_range is Some(_) ||
block.content_range is Some(_) ||
block.runs.any(run => run.source_range is Some(_)) ||
rich_text_block_table_has_source_ranges(block)
})
}
///|
fn rich_text_block_table_has_source_ranges(block : RichTextBlock) -> Bool {
match block.table {
Some(table) =>
table.rows.any(row => row.any(cell => cell.source_range is Some(_)))
None => false
}
}
///|
fn rich_text_table_column_count(table : RichTextTable) -> Int {
let mut count = 0
for row in table.rows {
count = @core.max_int(count, row.length())
}
count
}
///|
fn rich_text_block_cache_key(
index : Int,
block : RichTextBlock,
frame : @core.Rect,
) -> String {
// 注意:不要把 frame.origin 纳入 cache_key。
// 渲染器在栅格化 cached layer 时会做 translate(-origin.x, -origin.y) 抵消,
// pixmap 内容只依赖 block 自身 + frame.size,与 origin 无关。
// origin 仅在 draw_cached_layer_pixmap 贴图阶段用于定位。
// 滚动时让节点重建拿到新 origin 是通过 facade.mbt revision 加入 scroll_y 实现,
// 那条路径不依赖 layer cache 失效,layer 仍可命中并以新 frame 贴图。
// 把 origin 编码进 cache_key 反而会导致滚动时所有 block 缓存全 miss,触发
// 重栅格化与缓存驱逐抖动,造成滚动后明显卡顿。
"rich-text-block;range=\{rich_text_source_range_key(block.source_range)};index=\{index};w=\{frame.size.width};h=\{frame.size.height}"
}
///|
fn rich_text_block_content_revision(
block : RichTextBlock,
index : Int,
frame : @core.Rect,
base_font : @core.FontSpec,
foreground : @core.Color,
) -> Int {
let mut hash = rich_text_hash_mix_int(5381, index)
hash = rich_text_hash_mix_string(
hash,
rich_text_source_range_key(block.source_range),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_source_range_key(block.content_range),
)
hash = rich_text_hash_mix_string(hash, block.prefix)
hash = rich_text_hash_mix_string(
hash,
rich_text_font_key(block.font.unwrap_or(base_font)),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_color_key(block.color.unwrap_or(foreground)),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_brush_optional_key(block.background),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_border_optional_key(block.border_left),
)
hash = rich_text_hash_mix_string(hash, rich_text_insets_key(block.inset))
hash = rich_text_hash_mix_string(
hash,
rich_text_double_optional_key(block.line_height),
)
hash = rich_text_hash_mix_string(hash, "\{block.corner_radius}")
hash = rich_text_hash_mix_int(hash, if block.rule { 1 } else { 0 })
hash = rich_text_hash_mix_string(
hash,
"\{frame.size.width}x\{frame.size.height}",
)
for run in block.runs {
hash = rich_text_hash_mix_string(hash, run.text)
hash = rich_text_hash_mix_string(
hash,
rich_text_optional_string_key(run.visual_text),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_optional_string_key(run.image_source),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_size_optional_key(run.image_size),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_image_fit_key(run.image_fit),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_font_optional_key(run.font),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_color_optional_key(run.color),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_brush_optional_key(run.background),
)
hash = rich_text_hash_mix_string(hash, "\{run.background_radius}")
hash = rich_text_hash_mix_string(
hash,
rich_text_decoration_key(run.decoration),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_source_range_key(run.source_range),
)
}
match block.table {
Some(table) => {
hash = rich_text_hash_mix_string(hash, "\{table.row_height}")
hash = rich_text_hash_mix_string(
hash,
rich_text_insets_key(table.cell_padding),
)
hash = rich_text_hash_mix_string(hash, rich_text_color_key(table.border))
hash = rich_text_hash_mix_string(
hash,
rich_text_brush_optional_key(table.header_background),
)
for row in table.rows {
for cell in row {
hash = rich_text_hash_mix_string(hash, cell.text)
hash = rich_text_hash_mix_int(hash, if cell.header { 1 } else { 0 })
hash = rich_text_hash_mix_string(
hash,
rich_text_align_optional_key(cell.align),
)
hash = rich_text_hash_mix_string(
hash,
rich_text_source_range_key(cell.source_range),
)
}
}
}
None => ()
}
if hash < 0 {
-hash
} else {
hash
}
}
///|
fn rich_text_source_range_key(range : RichTextSourceRange?) -> String {
match range {
Some(range) => "len=\{@core.max_int(0, range.end - range.start)}"
None => "none"
}
}
///|
fn rich_text_optional_string_key(value : String?) -> String {
value.unwrap_or("none")
}
///|
fn rich_text_size_optional_key(value : @core.Size?) -> String {
match value {
Some(size) => "\{size.width}x\{size.height}"
None => "none"
}
}
///|
fn rich_text_double_optional_key(value : Double?) -> String {
match value {
Some(value) => "\{value}"
None => "none"
}
}
///|
fn rich_text_font_optional_key(value : @core.FontSpec?) -> String {
match value {
Some(font) => rich_text_font_key(font)
None => "none"
}
}
///|
fn rich_text_font_key(font : @core.FontSpec) -> String {
"\{font.css_family()}|\{font.size}|\{font.weight}|\{font.css_style()}"
}
///|
fn rich_text_color_optional_key(value : @core.Color?) -> String {
match value {
Some(color) => rich_text_color_key(color)
None => "none"
}
}
///|
fn rich_text_color_key(color : @core.Color) -> String {
"\{color.r},\{color.g},\{color.b},\{color.a}"
}
///|
fn rich_text_brush_optional_key(value : @core.Brush?) -> String {
match value {
Some(brush) => rich_text_brush_key(brush)
None => "none"
}
}
///|
fn rich_text_brush_key(brush : @core.Brush) -> String {
match brush {
@core.Brush::Solid(color) => "solid:\{rich_text_color_key(color)}"
@core.Brush::LinearGradient(spec) =>
"linear:\{spec.start.x},\{spec.start.y},\{spec.end.x},\{spec.end.y},\{rich_text_color_key(spec.start_color)},\{rich_text_color_key(spec.end_color)}"
@core.Brush::RadialGradient(spec) =>
"radial:\{spec.center.x},\{spec.center.y},\{spec.radius},\{rich_text_color_key(spec.center_color)},\{rich_text_color_key(spec.edge_color)}"
}
}
///|
fn rich_text_border_optional_key(value : @core.BorderStyle?) -> String {
match value {
Some(border) => "\{rich_text_brush_key(border.brush)}|\{border.width}"
None => "none"
}
}
///|
fn rich_text_insets_key(insets : @core.Insets) -> String {
"\{insets.top},\{insets.right},\{insets.bottom},\{insets.left}"
}
///|
fn rich_text_image_fit_key(fit : @core.ImageFit) -> String {
match fit {
@core.ImageFit::Contain => "contain"
@core.ImageFit::Cover => "cover"
@core.ImageFit::Stretch => "stretch"
@core.ImageFit::ScaleDown => "scale-down"
@core.ImageFit::FitWidth => "fit-width"
@core.ImageFit::FitHeight => "fit-height"
}
}
///|
fn rich_text_decoration_key(value : RichTextDecoration?) -> String {
match value {
Some(RichTextDecoration::Strikethrough) => "strikethrough"
None => "none"
}
}
///|
fn rich_text_align_optional_key(value : @core.TextAlign?) -> String {
match value {
Some(@core.TextAlign::TextStart) => "start"
Some(@core.TextAlign::TextCenter) => "center"
Some(@core.TextAlign::TextEnd) => "end"
None => "none"
}
}
///|
fn rich_text_string_hash(value : String) -> Int {
let mut hash = 5381
for ch in value.to_array() {
hash = hash * 33 + ch.to_int()
}
hash
}
///|
fn rich_text_hash_mix_int(hash : Int, value : Int) -> Int {
hash * 33 + value
}
///|
fn rich_text_hash_mix_string(hash : Int, value : String) -> Int {
hash * 33 + rich_text_string_hash(value)
}
///|
fn rich_text_block_table_source_offset_at_point(
block : RichTextBlock,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> Int? {
match block.table {
Some(table) => {
let row_count = table.rows.length()
let column_count = rich_text_table_column_count(table)
if row_count == 0 || column_count == 0 || table.row_height <= 0.0 {
return None
}
let row_index = @core.clamp_int(
((point.y - frame.origin.y) / table.row_height).floor().to_int(),
0,
row_count - 1,
)
let column_width = frame.size.width / column_count.to_double()
let column_index = @core.clamp_int(
((point.x - frame.origin.x) / column_width).floor().to_int(),
0,
column_count - 1,
)
let row = table.rows[row_index]
if column_index >= row.length() {
return None
}
let cell = row[column_index]
match cell.source_range {
Some(range) => {
// 与渲染端 (append_rich_text_block_table) 保持一致的几何:
// cell 文本 frame = cell_frame.inset(cell_padding),按 cell.align 决定 x 起点。
let cell_x = frame.origin.x + column_width * column_index.to_double()
let inner_x = cell_x + table.cell_padding.left
let inner_width = max_double(
0.0,
column_width - table.cell_padding.left - table.cell_padding.right,
)
// 空 cell 直接钳到 range.start,避免后续除零/无效测量。
if range.end <= range.start || cell.text == "" {
return Some(range.start)
}
let base_block_font = rich_text_block_font(block, base_font)
// 渲染端对 header cell 加重 weight=700,body 用 400。
let cell_font = if cell.header {
{ ..base_block_font, weight: 700 }
} else {
{ ..base_block_font, weight: 400 }
}
let text_width = rich_text_width(cell.text, cell_font, text_system)
if text_width <= 0.0 {
return Some(range.start)
}
let text_x = match cell.align.unwrap_or(@core.TextAlign::TextStart) {
TextStart => inner_x
TextCenter => inner_x + (inner_width - text_width) / 2.0
TextEnd => inner_x + inner_width - text_width
}
let text_x_end = text_x + text_width
// 点击落在文本左右两侧空白时,clamp 到 range 边界。
if point.x < text_x {
return Some(range.start)
}
if point.x > text_x_end {
return Some(range.end)
}
// 点击落在文本渲染范围内:按逐字符中点命中,再钳到图形素边界。
// 参考 rich_text_run_visual_offset_at_x 的 for index in 0.. None
}
}
None => None
}
}
///|
fn rich_text_block_table_caret_rect(
block : RichTextBlock,
table : RichTextTable,
source_caret : Int,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> @core.Rect {
let column_count = rich_text_table_column_count(table)
let row_count = table.rows.length()
let column_width = if column_count > 0 {
frame.size.width / column_count.to_double()
} else {
0.0
}
// 默认 caret 矩形:表格起点。
let base_font_resolved = rich_text_block_font(block, base_font)
let line_step = rich_text_block_line_step(block, base_font, text_system)
let caret_rect = rich_text_cursor_rect_at(
frame.origin.x,
@core.Rect::new(
x=frame.origin.x,
y=frame.origin.y,
width=frame.size.width,
height=line_step,
),
base_font_resolved,
)
if column_count == 0 || row_count == 0 {
return caret_rect
}
// 查找 source_caret 所在的 cell。
let mut found_row = -1
let mut found_col = -1
let mut found_cell : RichTextTableCell? = None
for row_index in 0..= row.length() {
continue
}
let cell = row[col_index]
match cell.source_range {
Some(range) =>
if source_caret >= range.start && source_caret <= range.end {
found_row = row_index
found_col = col_index
found_cell = Some(cell)
break
}
None => ()
}
}
if found_row >= 0 {
break
}
}
// 若 source_caret 超出所有 cell 范围:当 source_caret 在第一个 cell 之前
// (例如 position 0 位于表格 block 起点、但首个 cell 的 source_range 从
// "| " 之后才开始),定位到首个 cell;否则定位到末尾 cell。
if found_row < 0 {
let first_row = table.rows[0]
let first_col_index = @core.clamp_int(
first_row.length() - 1,
0,
column_count - 1,
)
let caret_before_first_cell = match
first_row[first_col_index].source_range {
Some(range) => source_caret < range.start
None => false
}
if caret_before_first_cell {
found_row = 0
found_col = first_col_index
found_cell = Some(first_row[first_col_index])
} else {
let last_row_index = row_count - 1
let last_row = table.rows[last_row_index]
let last_col_index = @core.clamp_int(
last_row.length() - 1,
0,
column_count - 1,
)
if last_col_index >= 0 && last_col_index < last_row.length() {
found_row = last_row_index
found_col = last_col_index
found_cell = Some(last_row[last_col_index])
}
}
}
if found_row < 0 {
return caret_rect
}
let cell = match found_cell {
Some(c) => c
None => return caret_rect
}
// 与渲染端一致的 cell 几何。
let cell_x = frame.origin.x + column_width * found_col.to_double()
let row_y = frame.origin.y + table.row_height * found_row.to_double()
let text_frame = @core.Rect::new(
x=cell_x + table.cell_padding.left,
y=row_y + table.cell_padding.top,
width=max_double(
0.0,
column_width - table.cell_padding.left - table.cell_padding.right,
),
height=table.row_height - table.cell_padding.top - table.cell_padding.bottom,
)
// cell 字体(header 加重,与渲染端一致)。
let cell_font = if cell.header {
{ ..base_font_resolved, weight: 700 }
} else {
{ ..base_font_resolved, weight: 400 }
}
// 空 cell 直接返回 cell 文本起点。
if cell.text == "" {
return rich_text_cursor_rect_at(text_frame.origin.x, text_frame, cell_font)
}
match cell.source_range {
Some(range) => {
// local_offset = source_caret 相对 cell.source_range.start 的偏移,钳到 [0, text.length]。
let local_offset = @core.clamp_int(
source_caret - range.start,
0,
cell.text.to_array().length(),
)
// prefix 宽度。
let chars = cell.text.to_array()
let prefix = String::from_array(
chars[0:@core.clamp_int(local_offset, 0, chars.length())],
)
let prefix_width = rich_text_width(prefix, cell_font, text_system)
// 按 align 计算 text_x(与点击映射对称)。
let text_width = rich_text_width(cell.text, cell_font, text_system)
let inner_width = text_frame.size.width
let text_x = match cell.align.unwrap_or(@core.TextAlign::TextStart) {
TextStart => text_frame.origin.x
TextCenter => text_frame.origin.x + (inner_width - text_width) / 2.0
TextEnd => text_frame.origin.x + inner_width - text_width
}
rich_text_cursor_rect_at(text_x + prefix_width, text_frame, cell_font)
}
None => rich_text_cursor_rect_at(text_frame.origin.x, text_frame, cell_font)
}
}
///|
fn rich_text_run_visual_offset_from_source_offset(
source_offset : Int,
source_text : String,
visual_text : String,
) -> Int {
let source_boundaries = @core.TextGraphemeBoundaries::new(text=source_text)
let visual_boundaries = @core.TextGraphemeBoundaries::new(text=visual_text)
let source_length = source_boundaries.length()
let visual_length = visual_boundaries.length()
let source_offset = source_boundaries.nearest_boundary(source_offset)
if source_length <= 0 || visual_length <= 0 {
0
} else if source_offset <= 0 {
0
} else if source_offset >= source_length {
visual_length
} else {
visual_boundaries.nearest_boundary(
(source_offset.to_double() *
visual_length.to_double() /
source_length.to_double())
.round()
.to_int(),
)
}
}
///|
fn rich_text_run_source_offset_from_visual_offset(
visual_offset : Int,
visual_text : String,
source_text : String,
) -> Int {
let visual_boundaries = @core.TextGraphemeBoundaries::new(text=visual_text)
let source_boundaries = @core.TextGraphemeBoundaries::new(text=source_text)
let visual_length = visual_boundaries.length()
let source_length = source_boundaries.length()
let visual_offset = visual_boundaries.nearest_boundary(visual_offset)
if visual_length <= 0 || source_length <= 0 {
0
} else if visual_offset <= 0 {
0
} else if visual_offset >= visual_length {
source_length
} else {
source_boundaries.nearest_boundary(
(visual_offset.to_double() *
source_length.to_double() /
visual_length.to_double())
.round()
.to_int(),
)
}
}
///|
fn[Msg] selection_messages(
on_selection_change : ((Int, @core.TextRange?) -> Msg)?,
caret : Int,
selection : @core.TextRange?,
) -> Array[Msg] {
match on_selection_change {
Some(handler) => [handler(caret, selection)]
None => []
}
}