///|
fn[Msg] rich_text_editor_event(
ctx : @core.ViewEventContext,
value~ : String,
format~ : RichTextFormatter,
font~ : @core.FontSpec,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?, RichTextInputEdit?) -> 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,
visible_scroll_y~ : Double,
on_scroll~ : ((Double) -> Msg)?,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewEventResult[Msg] {
match ctx.event {
Pointer(event) =>
rich_text_pointer_event(
ctx,
value~,
format~,
font~,
on_selection_change~,
on_prefix_click~,
on_command_click~,
event~,
visible_scroll_y~,
on_scroll~,
visible_viewport_height~,
visible_overscan~,
)
Keyboard(event) =>
rich_text_keyboard_event(
ctx,
value~,
format~,
font~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
accepts_tab_input~,
event~,
)
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~ : 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,
visible_scroll_y~ : Double,
on_scroll~ : ((Double) -> Msg)?,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
) -> @core.ViewEventResult[Msg] {
ignore(visible_overscan)
// Secondary/middle presses reach the view tree now that host backends route
// right-button downs for per-target context menus. A right press only places
// the caret (see the Down branch), so the menu the host builds next describes
// what was pressed; every other button and phase is ignored, which keeps a
// right-drag from selecting and leaves the wheel path (button 0) untouched.
if event.button != 0 && !(event.button == 1 && event.phase == Down) {
return @core.ViewEventResult::ignored()
}
let inside = ctx.frame.contains(event.position)
if inside && (event.wheel_delta.x != 0.0 || event.wheel_delta.y != 0.0) {
return rich_text_scroll_event(
value,
format,
font,
ctx.text_system,
event~,
visible_scroll_y~,
on_scroll~,
visible_viewport_height~,
content_width=rich_text_editor_content_rect(ctx.frame).size.width,
)
}
match event.phase {
Down =>
if inside {
let text = value
let document = format(text)
let base_content = rich_text_editor_content_rect(ctx.frame)
let content = rich_text_scrolled_content_rect(
base_content,
scroll_y=visible_scroll_y,
visible_viewport_height~,
)
// A right press moves the caret the way mature editors do, so the
// context menu the host builds next describes the text that was
// pressed. A press inside the current selection keeps it: that is what
// makes "Bold" in the menu apply to the selected run. The result
// reports `activated=false` on purpose — the enclosing
// `on_secondary_tap` observer must still run, so the app records its
// menu target from the same press — and leaves no selection anchor
// behind, so a right-drag never grows a selection.
if event.button == 1 {
let caret = rich_text_document_source_offset_at_point(
document,
event.position,
content,
font,
ctx.text_system,
).unwrap_or(text_length(text))
if rich_text_selection_covers(ctx.text_control.selection, caret) {
return view_event_result(focused=true)
}
return view_event_result(
changed=true,
focused=true,
text_control=Some({
..ctx.text_control,
caret,
selection: None,
text_selection_anchor: None,
goal_column: None,
text_selection_origin: None,
composition: None,
composition_cursor: None,
}),
dirty=ViewPaintDirty,
messages=selection_messages(on_selection_change, caret, None),
)
}
// 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=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),
goal_column: None,
composition: None,
composition_cursor: None,
}
view_event_result(
changed=true,
focused=true,
captured=true,
text_control=Some(next_control),
dirty=ViewPaintDirty,
messages=prefix_messages +
selection_messages(on_selection_change, caret, None),
)
}
}
} else {
@core.ViewEventResult::ignored()
}
Move =>
match ctx.text_control.text_selection_anchor {
Some(anchor) => {
let text = value
let document = format(text)
let base_content = rich_text_editor_content_rect(ctx.frame)
let content = rich_text_scrolled_content_rect(
base_content,
scroll_y=visible_scroll_y,
visible_viewport_height~,
)
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=ViewPaintDirty,
messages=if changed {
selection_messages(on_selection_change, caret, next_selection)
} else {
[]
},
)
}
None => @core.ViewEventResult::ignored()
}
Up | Cancel | 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=ViewPaintDirty,
)
}
}
}
///|
fn[Msg] rich_text_scroll_event(
value : String,
format : RichTextFormatter,
font : @core.FontSpec,
text_system : @core.TextSystem,
event~ : @core.PointerEvent,
visible_scroll_y~ : Double,
on_scroll~ : ((Double) -> Msg)?,
visible_viewport_height~ : Double,
content_width? : Double = 0.0,
) -> @core.ViewEventResult[Msg] {
match on_scroll {
Some(handler) => {
let viewport_height = if visible_viewport_height > 0.0 {
visible_viewport_height
} else {
0.0
}
let content_height = rich_text_document_height(
format(value),
font,
text_system~,
block_width=content_width,
) +
48.0
let max_scroll = max_double(0.0, content_height - viewport_height)
let next_y = min_double(
max_double(0.0, visible_scroll_y - event.wheel_delta.y),
max_scroll,
)
view_event_result(
changed=next_y != visible_scroll_y,
activated=next_y != visible_scroll_y,
captured=true,
dirty=ViewPaintDirty,
messages=if next_y != visible_scroll_y { [handler(next_y)] } else { [] },
)
}
None => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] rich_text_keyboard_event(
ctx : @core.ViewEventContext,
value~ : String,
format~ : RichTextFormatter,
font~ : @core.FontSpec,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?, RichTextInputEdit?) -> 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=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=InsertText("\n"),
record_history=true,
)
}
if @core.is_select_all_shortcut(event) {
let next_control = select_rich_text_control(ctx.text_control, value)
return view_event_result(
changed=true,
captured=true,
text_control=Some(next_control),
dirty=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, undo=true)
return view_event_result(
changed=result.changed,
captured=true,
text_control=Some(result.control),
dirty=ViewPaintDirty,
messages=text_input_messages(on_input, result.text) +
text_input_edit_messages(
on_input_edit,
result.text,
result.control,
record=result.edit,
) +
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, undo=false)
return view_event_result(
changed=result.changed,
captured=true,
text_control=Some(result.control),
dirty=ViewPaintDirty,
messages=text_input_messages(on_input, result.text) +
text_input_edit_messages(
on_input_edit,
result.text,
result.control,
record=result.edit,
) +
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=DeleteRange(selection),
record_history=true,
)
None =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=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=DeleteRange(selection),
record_history=true,
)
None =>
return rich_text_input_event(
ctx,
value~,
on_input~,
on_input_edit~,
on_selection_change~,
input_transform~,
event=DeleteSurrounding(0, 1),
record_history=true,
)
}
}
if event.key == "ArrowUp" {
let text = value
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_selection=event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowDown" {
let text = value
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_selection=event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowLeft" {
return rich_text_caret_move_result(
ctx,
value,
MoveLeft,
event.modifiers.shift,
on_selection_change,
)
}
if event.key == "ArrowRight" {
return rich_text_caret_move_result(
ctx,
value,
MoveRight,
event.modifiers.shift,
on_selection_change,
)
}
if event.key == "Home" {
return rich_text_line_edge_move_result(
ctx,
value,
home=true,
on_selection_change,
)
}
if event.key == "End" {
return rich_text_line_edge_move_result(
ctx,
value,
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=InsertText(text),
record_history=true,
)
} else {
@core.ViewEventResult::ignored()
}
None => @core.ViewEventResult::ignored()
}
}
///|
fn[Msg] rich_text_input_event(
ctx : @core.ViewEventContext,
value~ : String,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?, RichTextInputEdit?) -> 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
let result = match event {
InsertText(inserted) =>
if inserted == "" {
(
{ text, control: ctx.text_control, changed: false, edit: None, } :
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~)
}
}
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~)
}
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~)
}
view_event_result(
changed=result.changed,
captured=result.changed,
text_control=Some(result.control),
dirty=ViewPaintDirty,
messages=if result.changed {
text_input_messages(on_input, result.text) +
text_input_edit_messages(
on_input_edit,
result.text,
result.control,
record=result.edit,
) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
///|
fn[Msg] rich_text_editor_command(
ctx : @core.ViewEventContext,
value~ : String,
format~ : RichTextFormatter,
on_input~ : ((String) -> Msg)?,
on_input_edit~ : ((String, Int, @core.TextRange?, RichTextInputEdit?) -> 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
let document = format(text)
match intent {
Copy =>
@core.ViewTextCommandResult::new(
result=@core.TextCommandResult::handled(
clipboard_text=Some(
selected_rich_text(text, document, ctx.text_control.selection),
),
),
)
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=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("")),
)
}
}
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=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(),
)
}
Undo => {
let result = restore_text_history(ctx.text_control, text, undo=true)
@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,
record=result.edit,
) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
Redo => {
let result = restore_text_history(ctx.text_control, text, undo=false)
@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,
record=result.edit,
) +
selection_messages(
on_selection_change,
result.control.caret,
result.control.selection,
)
} else {
[]
},
)
}
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?,
visible_scroll_y~ : Double,
visible_viewport_height~ : Double,
visible_overscan~ : Double,
text_system~ : @core.TextSystem,
) -> @core.TextInputState {
ignore(visible_overscan)
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 base_content = rich_text_editor_content_rect(frame)
let content = rich_text_scrolled_content_rect(
base_content,
scroll_y=visible_scroll_y,
visible_viewport_height~,
)
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,
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_selection : 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_selection~,
)
let next_control = {
..ctx.text_control,
caret: caret_move.caret,
selection: caret_move.selection,
composition: None,
composition_cursor: None,
// 水平移动重置目标列(M6②):只有垂直移动才播种/保持 goal
goal_column: 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=ViewPaintDirty,
messages=if changed {
selection_messages(
on_selection_change,
next_control.caret,
next_control.selection,
)
} else {
[]
},
)
}