forked from jess/Acord
2245 lines
77 KiB
Rust
2245 lines
77 KiB
Rust
//! Text editors display a multi-line text input for text editing.
|
|
//!
|
|
//! # Example
|
|
//! ```no_run
|
|
//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
|
//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
|
//! #
|
|
//! use iced::widget::text_editor;
|
|
//!
|
|
//! struct State {
|
|
//! content: text_editor::Content,
|
|
//! }
|
|
//!
|
|
//! #[derive(Debug, Clone)]
|
|
//! enum Message {
|
|
//! Edit(text_editor::Action)
|
|
//! }
|
|
//!
|
|
//! fn view(state: &State) -> Element<'_, Message> {
|
|
//! text_editor(&state.content)
|
|
//! .placeholder("Type something here...")
|
|
//! .on_action(Message::Edit)
|
|
//! .into()
|
|
//! }
|
|
//!
|
|
//! fn update(state: &mut State, message: Message) {
|
|
//! match message {
|
|
//! Message::Edit(action) => {
|
|
//! state.content.perform(action);
|
|
//! }
|
|
//! }
|
|
//! }
|
|
//! ```
|
|
use iced_wgpu::core::alignment;
|
|
use iced_wgpu::core::clipboard::{self, Clipboard};
|
|
use iced_wgpu::core::input_method;
|
|
use iced_wgpu::core::keyboard;
|
|
use iced_wgpu::core::keyboard::key;
|
|
use iced_wgpu::core::layout::{self, Layout};
|
|
use iced_wgpu::core::mouse;
|
|
use iced_wgpu::core::renderer::{self, Renderer as _};
|
|
use iced_wgpu::core::text::editor::Editor as _;
|
|
use iced_wgpu::core::text::Renderer as _;
|
|
use iced_wgpu::core::text::highlighter::{self, Highlighter};
|
|
use iced_wgpu::core::text::paragraph::Paragraph as _;
|
|
use iced_wgpu::core::text::{self, LineHeight, Span, Text, Wrapping};
|
|
use iced_wgpu::core::theme;
|
|
use iced_wgpu::core::time::{Duration, Instant};
|
|
use iced_wgpu::core::widget::operation;
|
|
use iced_wgpu::core::widget::{self, Widget};
|
|
use iced_wgpu::core::window;
|
|
use iced_wgpu::core::{
|
|
Background, Border, Color, Element, Event, Font, InputMethod, Length,
|
|
Padding, Pixels, Point, Rectangle, Shell, Size, SmolStr, Theme, Vector,
|
|
};
|
|
|
|
use std::borrow::Cow;
|
|
use std::cell::RefCell;
|
|
use std::fmt;
|
|
use std::ops;
|
|
use std::ops::DerefMut;
|
|
use std::sync::Arc;
|
|
|
|
|
|
pub use text::editor::{
|
|
Action, Cursor, Edit, Line, LineEnding, Motion, Position, Selection,
|
|
};
|
|
|
|
/// An anchored child element rendered at a line boundary within the text widget.
|
|
/// The caller builds these using existing rendering code; the widget just draws them in order.
|
|
pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> {
|
|
pub after_line: usize,
|
|
pub height: f32,
|
|
pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>,
|
|
}
|
|
|
|
/// Per-logical-line metrics. Stored on State so layout publishes once
|
|
/// and every consumer (draw, cursor, hit-test) reads the same data.
|
|
#[derive(Clone, Default, Debug)]
|
|
pub struct LineMetric {
|
|
/// Widget-y of this line's first visual row (relative to text_bounds.y).
|
|
pub widget_y: f32,
|
|
/// Cosmic-text's viewport-relative y of this line's first visual row —
|
|
/// matches the y produced by `Selection::Caret(position).y` and what
|
|
/// `Action::Click { y }` consumes (already scroll-adjusted, items
|
|
/// invisible to it). Diverges from `widget_y` whenever an anchored
|
|
/// item sits between this line and `scroll.line`.
|
|
pub viewport_y: f32,
|
|
/// Number of visual rows this logical line occupies after wrap.
|
|
pub visual_rows: usize,
|
|
}
|
|
|
|
/// Translate a cosmic-reported y (`Selection::Caret`, `Selection::Range`)
|
|
/// into our widget-y so cursor + selection rectangles draw on top of the
|
|
/// text rows the compositor actually rendered.
|
|
fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) -> f32 {
|
|
if metrics.is_empty() { return cosmic_y; }
|
|
for i in (0..metrics.len() - 1).rev() {
|
|
if metrics[i].viewport_y <= cosmic_y {
|
|
return metrics[i].widget_y + (cosmic_y - metrics[i].viewport_y);
|
|
}
|
|
}
|
|
metrics[0].widget_y + (cosmic_y - metrics[0].viewport_y)
|
|
}
|
|
|
|
/// Translate a widget-y (mouse coords) back into the y cosmic-text expects
|
|
/// for click/drag actions — the inverse of `cosmic_y_to_widget_y`.
|
|
fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 {
|
|
if metrics.len() < 2 { return widget_y; }
|
|
let line_count = metrics.len() - 1;
|
|
for i in 0..line_count {
|
|
let line_top = metrics[i].widget_y;
|
|
let line_bot = line_top + metrics[i].visual_rows as f32 * line_h;
|
|
if widget_y < line_bot {
|
|
if widget_y < line_top {
|
|
return metrics[i].viewport_y;
|
|
}
|
|
return metrics[i].viewport_y + (widget_y - line_top);
|
|
}
|
|
let next_top = metrics[i + 1].widget_y;
|
|
if widget_y < next_top {
|
|
return metrics[i].viewport_y + metrics[i].visual_rows as f32 * line_h;
|
|
}
|
|
}
|
|
let tail = metrics.last().unwrap();
|
|
tail.viewport_y + (widget_y - tail.widget_y).max(0.0)
|
|
}
|
|
|
|
/// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor
|
|
/// (full saturation), `1.0` at the far end of the fade window.
|
|
const GUTTER_FADE_CYCLES: f32 = 2.5;
|
|
fn gutter_fade_t(distance: usize) -> f32 {
|
|
let max_d = GUTTER_FADE_CYCLES * crate::syntax::USER_IDENT_PALETTE_SIZE as f32;
|
|
(distance as f32 / max_d).min(1.0)
|
|
}
|
|
|
|
/// builds iced Spans from cosmic glyphs, grouping by (color, weight, style) and skipping marker ranges.
|
|
fn build_color_spans<'a>(
|
|
text: &'a str,
|
|
glyphs: &[cosmic_text::LayoutGlyph],
|
|
attrs_list: &cosmic_text::AttrsList,
|
|
base_font: Font,
|
|
font_size_px: f32,
|
|
marker_ranges: &[std::ops::Range<usize>],
|
|
) -> Vec<Span<'a>> {
|
|
use iced_wgpu::core::font::{Style as IcedStyle, Weight as IcedWeight};
|
|
|
|
fn cosmic_to_iced(c: cosmic_text::Color, font_size_px: f32) -> Color {
|
|
let raw = Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0);
|
|
crate::oklab::lighten_for_size(raw, font_size_px)
|
|
}
|
|
|
|
fn cosmic_to_iced_weight(w: cosmic_text::Weight) -> IcedWeight {
|
|
match w.0 {
|
|
0..=149 => IcedWeight::Thin,
|
|
150..=249 => IcedWeight::ExtraLight,
|
|
250..=349 => IcedWeight::Light,
|
|
350..=449 => IcedWeight::Normal,
|
|
450..=549 => IcedWeight::Medium,
|
|
550..=649 => IcedWeight::Semibold,
|
|
650..=749 => IcedWeight::Bold,
|
|
750..=849 => IcedWeight::ExtraBold,
|
|
_ => IcedWeight::Black,
|
|
}
|
|
}
|
|
|
|
fn cosmic_to_iced_style(s: cosmic_text::Style) -> IcedStyle {
|
|
match s {
|
|
cosmic_text::Style::Normal => IcedStyle::Normal,
|
|
cosmic_text::Style::Italic => IcedStyle::Italic,
|
|
cosmic_text::Style::Oblique => IcedStyle::Oblique,
|
|
}
|
|
}
|
|
|
|
/// span grouping key — color repr plus iced weight/style.
|
|
type StyleKey = (Option<u32>, IcedWeight, IcedStyle);
|
|
|
|
let style_at = |byte_idx: usize| -> (IcedWeight, IcedStyle) {
|
|
let attrs = attrs_list.get_span(byte_idx);
|
|
(cosmic_to_iced_weight(attrs.weight), cosmic_to_iced_style(attrs.style))
|
|
};
|
|
|
|
let push_span = |spans: &mut Vec<Span<'a>>,
|
|
range: std::ops::Range<usize>,
|
|
key: StyleKey| {
|
|
if range.start >= range.end || range.end > text.len() {
|
|
return;
|
|
}
|
|
let mut span = Span::new(&text[range]);
|
|
if let Some(raw) = key.0 {
|
|
let c = cosmic_text::Color(raw);
|
|
span = span.color(cosmic_to_iced(c, font_size_px));
|
|
}
|
|
if key.1 != IcedWeight::Normal || key.2 != IcedStyle::Normal {
|
|
span = span.font(Font {
|
|
weight: key.1,
|
|
style: key.2,
|
|
..base_font
|
|
});
|
|
}
|
|
spans.push(span);
|
|
};
|
|
|
|
// subtract every marker range from the full line range.
|
|
let visible_segments: Vec<std::ops::Range<usize>> = if marker_ranges.is_empty() {
|
|
vec![0..text.len()]
|
|
} else {
|
|
let mut segs = vec![0..text.len()];
|
|
for marker in marker_ranges {
|
|
let mut next = Vec::with_capacity(segs.len() + 1);
|
|
for seg in segs.into_iter() {
|
|
if marker.end <= seg.start || marker.start >= seg.end {
|
|
next.push(seg);
|
|
} else {
|
|
if seg.start < marker.start {
|
|
next.push(seg.start..marker.start);
|
|
}
|
|
if marker.end < seg.end {
|
|
next.push(marker.end..seg.end);
|
|
}
|
|
}
|
|
}
|
|
segs = next;
|
|
}
|
|
segs
|
|
};
|
|
|
|
if glyphs.is_empty() {
|
|
// empty line — fall back to line-level attrs.
|
|
let (w, s) = style_at(0);
|
|
let key: StyleKey = (None, w, s);
|
|
let mut spans = Vec::new();
|
|
for seg in &visible_segments {
|
|
push_span(&mut spans, seg.clone(), key);
|
|
}
|
|
if spans.is_empty() {
|
|
spans.push(Span::new(text));
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
let mut spans: Vec<Span<'a>> = Vec::new();
|
|
for seg in &visible_segments {
|
|
let mut seg_start = seg.start;
|
|
let mut cur_key: Option<StyleKey> = None;
|
|
for glyph in glyphs {
|
|
if glyph.start < seg.start || glyph.start >= seg.end {
|
|
continue;
|
|
}
|
|
let (w, s) = style_at(glyph.start);
|
|
let key: StyleKey = (glyph.color_opt.map(|c| c.0), w, s);
|
|
match cur_key {
|
|
None => {
|
|
seg_start = glyph.start;
|
|
cur_key = Some(key);
|
|
}
|
|
Some(prev) if prev != key => {
|
|
push_span(&mut spans, seg_start..glyph.start, prev);
|
|
seg_start = glyph.start;
|
|
cur_key = Some(key);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(key) = cur_key {
|
|
push_span(&mut spans, seg_start..seg.end, key);
|
|
}
|
|
}
|
|
|
|
if spans.is_empty() {
|
|
spans.push(Span::new(text));
|
|
}
|
|
|
|
spans
|
|
}
|
|
|
|
/// A multi-line text input.
|
|
///
|
|
/// # Example
|
|
/// ```no_run
|
|
/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
|
|
/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
|
|
/// #
|
|
/// use iced::widget::text_editor;
|
|
///
|
|
/// struct State {
|
|
/// content: text_editor::Content,
|
|
/// }
|
|
///
|
|
/// #[derive(Debug, Clone)]
|
|
/// enum Message {
|
|
/// Edit(text_editor::Action)
|
|
/// }
|
|
///
|
|
/// fn view(state: &State) -> Element<'_, Message> {
|
|
/// text_editor(&state.content)
|
|
/// .placeholder("Type something here...")
|
|
/// .on_action(Message::Edit)
|
|
/// .into()
|
|
/// }
|
|
///
|
|
/// fn update(state: &mut State, message: Message) {
|
|
/// match message {
|
|
/// Message::Edit(action) => {
|
|
/// state.content.perform(action);
|
|
/// }
|
|
/// }
|
|
/// }
|
|
/// ```
|
|
pub struct TextEditor<
|
|
'a,
|
|
Highlighter,
|
|
Message,
|
|
Theme = iced_wgpu::core::Theme,
|
|
> where
|
|
Highlighter: text::Highlighter,
|
|
Theme: Catalog,
|
|
{
|
|
id: Option<widget::Id>,
|
|
content: &'a Content,
|
|
placeholder: Option<text::Fragment<'a>>,
|
|
font: Option<Font>,
|
|
text_size: Option<Pixels>,
|
|
line_height: LineHeight,
|
|
width: Length,
|
|
height: Length,
|
|
min_height: f32,
|
|
max_height: f32,
|
|
padding: Padding,
|
|
wrapping: Wrapping,
|
|
class: Theme::Class<'a>,
|
|
key_binding: Option<Box<dyn Fn(KeyPress) -> Option<Binding<Message>> + 'a>>,
|
|
on_edit: Option<Box<dyn Fn(Action) -> Message + 'a>>,
|
|
highlighter_settings: Highlighter::Settings,
|
|
highlighter_format: fn(
|
|
&Highlighter::Highlight,
|
|
&Theme,
|
|
) -> highlighter::Format<Font>,
|
|
last_status: Option<Status>,
|
|
// Acord extensions
|
|
anchored_children: Vec<AnchoredItem<'a, Message, Theme>>,
|
|
gutter_offset: usize,
|
|
is_focused_block: bool,
|
|
show_gutter: bool,
|
|
cursor_line: Option<usize>,
|
|
line_indicator: crate::editor::LineIndicator,
|
|
gutter_rainbow: bool,
|
|
line_decors: Vec<crate::syntax::LineDecor>,
|
|
}
|
|
|
|
impl<'a, Message, Theme>
|
|
TextEditor<'a, highlighter::PlainText, Message, Theme>
|
|
where
|
|
Theme: Catalog,
|
|
{
|
|
/// Creates new [`TextEditor`] with the given [`Content`].
|
|
pub fn new(content: &'a Content) -> Self {
|
|
Self {
|
|
id: None,
|
|
content,
|
|
placeholder: None,
|
|
font: None,
|
|
text_size: None,
|
|
line_height: LineHeight::default(),
|
|
width: Length::Fill,
|
|
height: Length::Shrink,
|
|
min_height: 0.0,
|
|
max_height: f32::INFINITY,
|
|
padding: Padding::new(5.0),
|
|
wrapping: Wrapping::default(),
|
|
class: <Theme as Catalog>::default(),
|
|
key_binding: None,
|
|
on_edit: None,
|
|
highlighter_settings: (),
|
|
highlighter_format: |_highlight, _theme| {
|
|
highlighter::Format::default()
|
|
},
|
|
last_status: None,
|
|
anchored_children: Vec::new(),
|
|
gutter_offset: 0,
|
|
is_focused_block: false,
|
|
show_gutter: false,
|
|
cursor_line: None,
|
|
line_indicator: crate::editor::LineIndicator::On,
|
|
gutter_rainbow: false,
|
|
line_decors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Sets the [`Id`](widget::Id) of the [`TextEditor`].
|
|
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
|
self.id = Some(id.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl<'a, Highlighter, Message, Theme>
|
|
TextEditor<'a, Highlighter, Message, Theme>
|
|
where
|
|
Highlighter: text::Highlighter,
|
|
Theme: Catalog,
|
|
{
|
|
/// Sets the placeholder of the [`TextEditor`].
|
|
pub fn placeholder(
|
|
mut self,
|
|
placeholder: impl text::IntoFragment<'a>,
|
|
) -> Self {
|
|
self.placeholder = Some(placeholder.into_fragment());
|
|
self
|
|
}
|
|
|
|
/// Sets the width of the [`TextEditor`].
|
|
pub fn width(mut self, width: impl Into<Pixels>) -> Self {
|
|
self.width = Length::from(width.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the height of the [`TextEditor`].
|
|
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
|
self.height = height.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the minimum height of the [`TextEditor`].
|
|
pub fn min_height(mut self, min_height: impl Into<Pixels>) -> Self {
|
|
self.min_height = min_height.into().0;
|
|
self
|
|
}
|
|
|
|
/// Sets the maximum height of the [`TextEditor`].
|
|
pub fn max_height(mut self, max_height: impl Into<Pixels>) -> Self {
|
|
self.max_height = max_height.into().0;
|
|
self
|
|
}
|
|
|
|
/// Sets the message that should be produced when some action is performed in
|
|
/// the [`TextEditor`].
|
|
///
|
|
/// If this method is not called, the [`TextEditor`] will be disabled.
|
|
pub fn on_action(
|
|
mut self,
|
|
on_edit: impl Fn(Action) -> Message + 'a,
|
|
) -> Self {
|
|
self.on_edit = Some(Box::new(on_edit));
|
|
self
|
|
}
|
|
|
|
/// Sets the [`Font`] of the [`TextEditor`].
|
|
///
|
|
pub fn font(mut self, font: impl Into<Font>) -> Self {
|
|
self.font = Some(font.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the text size of the [`TextEditor`].
|
|
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
|
self.text_size = Some(size.into());
|
|
self
|
|
}
|
|
|
|
/// Sets the [`text::LineHeight`] of the [`TextEditor`].
|
|
pub fn line_height(
|
|
mut self,
|
|
line_height: impl Into<text::LineHeight>,
|
|
) -> Self {
|
|
self.line_height = line_height.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the [`Padding`] of the [`TextEditor`].
|
|
pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
|
|
self.padding = padding.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the [`Wrapping`] strategy of the [`TextEditor`].
|
|
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
|
|
self.wrapping = wrapping;
|
|
self
|
|
}
|
|
|
|
/// Highlights the [`TextEditor`] with the given [`Highlighter`] and
|
|
/// a strategy to turn its highlights into some text format.
|
|
pub fn highlight_with<H: text::Highlighter>(
|
|
self,
|
|
settings: H::Settings,
|
|
to_format: fn(
|
|
&H::Highlight,
|
|
&Theme,
|
|
) -> highlighter::Format<Font>,
|
|
) -> TextEditor<'a, H, Message, Theme> {
|
|
TextEditor {
|
|
id: self.id,
|
|
content: self.content,
|
|
placeholder: self.placeholder,
|
|
font: self.font,
|
|
text_size: self.text_size,
|
|
line_height: self.line_height,
|
|
width: self.width,
|
|
height: self.height,
|
|
min_height: self.min_height,
|
|
max_height: self.max_height,
|
|
padding: self.padding,
|
|
wrapping: self.wrapping,
|
|
class: self.class,
|
|
key_binding: self.key_binding,
|
|
on_edit: self.on_edit,
|
|
highlighter_settings: settings,
|
|
highlighter_format: to_format,
|
|
last_status: self.last_status,
|
|
anchored_children: self.anchored_children,
|
|
gutter_offset: self.gutter_offset,
|
|
is_focused_block: self.is_focused_block,
|
|
show_gutter: self.show_gutter,
|
|
cursor_line: self.cursor_line,
|
|
line_indicator: self.line_indicator,
|
|
gutter_rainbow: self.gutter_rainbow,
|
|
line_decors: self.line_decors,
|
|
}
|
|
}
|
|
|
|
/// Sets the closure to produce key bindings on key presses.
|
|
///
|
|
/// See [`Binding`] for the list of available bindings.
|
|
pub fn key_binding(
|
|
mut self,
|
|
key_binding: impl Fn(KeyPress) -> Option<Binding<Message>> + 'a,
|
|
) -> Self {
|
|
self.key_binding = Some(Box::new(key_binding));
|
|
self
|
|
}
|
|
|
|
/// Sets the style of the [`TextEditor`].
|
|
#[must_use]
|
|
pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
|
|
where
|
|
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
|
{
|
|
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
|
self
|
|
}
|
|
|
|
/// Sets the style class of the [`TextEditor`].
|
|
#[must_use]
|
|
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
|
self.class = class.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the anchored child elements to draw at line boundaries.
|
|
/// Items must be sorted by after_line.
|
|
pub fn anchored(mut self, items: Vec<AnchoredItem<'a, Message, Theme>>) -> Self {
|
|
self.anchored_children = items;
|
|
self
|
|
}
|
|
|
|
/// Sets the global line offset for gutter numbering.
|
|
pub fn gutter_offset(mut self, offset: usize) -> Self {
|
|
self.gutter_offset = offset;
|
|
self
|
|
}
|
|
|
|
/// Marks this widget as the focused editing block.
|
|
pub fn focused(mut self, focused: bool) -> Self {
|
|
self.is_focused_block = focused;
|
|
self
|
|
}
|
|
|
|
/// Reserve a left strip for line numbers + decoration stripes.
|
|
pub fn show_gutter(mut self, show: bool) -> Self {
|
|
self.show_gutter = show;
|
|
self
|
|
}
|
|
|
|
/// Cursor's current line within this block. `None` when not focused.
|
|
/// Drives both the cursorline tint and the gutter rainbow center.
|
|
pub fn cursor_line(mut self, line: Option<usize>) -> Self {
|
|
self.cursor_line = line;
|
|
self
|
|
}
|
|
|
|
pub fn line_indicator(mut self, ind: crate::editor::LineIndicator) -> Self {
|
|
self.line_indicator = ind;
|
|
self
|
|
}
|
|
|
|
pub fn gutter_rainbow(mut self, on: bool) -> Self {
|
|
self.gutter_rainbow = on;
|
|
self
|
|
}
|
|
|
|
pub fn line_decors(mut self, decors: Vec<crate::syntax::LineDecor>) -> Self {
|
|
self.line_decors = decors;
|
|
self
|
|
}
|
|
|
|
/// Width of the gutter strip given a line count. Caller passes the
|
|
/// count so this never touches `self.content` (which would deadlock
|
|
/// when called from inside layout/draw — those already hold the
|
|
/// content's RefCell).
|
|
fn gutter_width_for(&self, line_count: usize) -> f32 {
|
|
if !self.show_gutter { return 0.0; }
|
|
let total = self.gutter_offset + line_count;
|
|
let count = if total == 0 { 1 } else { total };
|
|
let digits = (count as f32).log10().floor() as usize + 1;
|
|
let font_size: f32 = self.text_size
|
|
.map(f32::from)
|
|
.unwrap_or(14.0);
|
|
let char_width = font_size * 0.6;
|
|
(digits.max(2) as f32 * char_width + 16.0).ceil()
|
|
}
|
|
|
|
fn draw_gutter_line(
|
|
&self,
|
|
renderer: &mut iced_wgpu::Renderer,
|
|
line_i: usize,
|
|
bounds: Rectangle,
|
|
y: f32,
|
|
line_h: f32,
|
|
gw: f32,
|
|
p: &crate::palette::Palette,
|
|
font_size: f32,
|
|
) {
|
|
use crate::syntax::LineDecor;
|
|
use crate::editor::LineIndicator;
|
|
|
|
let gutter_left = bounds.x + self.padding.left;
|
|
let gutter_right = gutter_left + gw;
|
|
|
|
let decor = self.line_decors.get(line_i).copied().unwrap_or(LineDecor::None);
|
|
match decor {
|
|
LineDecor::CodeBlock | LineDecor::FenceMarker => {
|
|
let bg = Color { a: 0.15, ..p.surface2 };
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: Rectangle::new(
|
|
Point::new(gutter_left, y),
|
|
Size::new(gw, line_h),
|
|
),
|
|
border: Border::default(),
|
|
..renderer::Quad::default()
|
|
},
|
|
Background::Color(bg),
|
|
);
|
|
}
|
|
LineDecor::Blockquote => {
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: Rectangle::new(
|
|
Point::new(gutter_right - 3.0, y),
|
|
Size::new(3.0, line_h),
|
|
),
|
|
border: Border::default(),
|
|
..renderer::Quad::default()
|
|
},
|
|
Background::Color(p.lavender),
|
|
);
|
|
}
|
|
LineDecor::HorizontalRule => {
|
|
let mid_y = y + line_h / 2.0;
|
|
let stroke_color = crate::oklab::lighten_for_size(p.overlay1, 1.0);
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: Rectangle::new(
|
|
Point::new(gutter_left + 4.0, mid_y - 0.5),
|
|
Size::new(gw - 8.0, 1.0),
|
|
),
|
|
border: Border::default(),
|
|
..renderer::Quad::default()
|
|
},
|
|
Background::Color(stroke_color),
|
|
);
|
|
}
|
|
LineDecor::None => {}
|
|
}
|
|
|
|
if self.line_indicator == LineIndicator::Off {
|
|
return;
|
|
}
|
|
|
|
let raw_color = if self.gutter_rainbow {
|
|
match self.cursor_line {
|
|
Some(cl) if line_i == cl => p.text,
|
|
Some(cl) if line_i > cl => {
|
|
let d = line_i - cl - 1;
|
|
let hue = crate::syntax::rainbow_color(d as u32);
|
|
crate::oklab::desaturate(hue, gutter_fade_t(d))
|
|
}
|
|
Some(cl) => {
|
|
let d = cl - line_i - 1;
|
|
let hue = crate::oklab::invert_hue(crate::syntax::rainbow_color(d as u32));
|
|
crate::oklab::desaturate(hue, gutter_fade_t(d))
|
|
}
|
|
None => p.surface2,
|
|
}
|
|
} else {
|
|
match self.cursor_line {
|
|
Some(cl) if line_i == cl => p.text,
|
|
_ => p.surface2,
|
|
}
|
|
};
|
|
|
|
let line_num = self.gutter_offset + line_i;
|
|
let label = match (self.line_indicator, self.cursor_line) {
|
|
(LineIndicator::Vim, Some(cl)) if line_i != cl => {
|
|
let d = if line_i > cl { line_i - cl } else { cl - line_i };
|
|
format!("{d}")
|
|
}
|
|
_ => format!("{}", line_num + 1),
|
|
};
|
|
|
|
renderer.fill_text(
|
|
Text {
|
|
content: label,
|
|
bounds: Size::new(gw, line_h),
|
|
size: Pixels(font_size),
|
|
line_height: self.line_height,
|
|
font: Font::MONOSPACE,
|
|
align_x: text::Alignment::Right,
|
|
align_y: alignment::Vertical::Top,
|
|
shaping: text::Shaping::Basic,
|
|
wrapping: Wrapping::None,
|
|
},
|
|
Point::new(gutter_right - 8.0, y),
|
|
crate::oklab::lighten_for_size(raw_color, font_size),
|
|
Rectangle::new(
|
|
Point::new(gutter_left, y),
|
|
Size::new(gw, line_h),
|
|
),
|
|
);
|
|
}
|
|
|
|
fn input_method<'b>(
|
|
&self,
|
|
state: &'b State<Highlighter>,
|
|
renderer: &iced_wgpu::Renderer,
|
|
layout: Layout<'_>,
|
|
) -> InputMethod<&'b str> {
|
|
let Some(Focus {
|
|
is_window_focused: true,
|
|
..
|
|
}) = &state.focus
|
|
else {
|
|
return InputMethod::Disabled;
|
|
};
|
|
|
|
let bounds = layout.bounds();
|
|
let internal = self.content.0.borrow_mut();
|
|
|
|
let gw = state.gutter_width.get();
|
|
let effective_padding = Padding {
|
|
left: self.padding.left + gw,
|
|
..self.padding
|
|
};
|
|
let text_bounds = bounds.shrink(effective_padding);
|
|
let translation = text_bounds.position() - Point::ORIGIN;
|
|
|
|
let cursor = match internal.editor.selection() {
|
|
Selection::Caret(position) => position,
|
|
Selection::Range(ranges) => {
|
|
ranges.first().cloned().unwrap_or_default().position()
|
|
}
|
|
};
|
|
|
|
let line_height = self.line_height.to_absolute(
|
|
self.text_size.unwrap_or_else(|| renderer.default_size()),
|
|
);
|
|
|
|
let adjusted = {
|
|
let metrics = state.line_metrics.borrow();
|
|
Point::new(cursor.x, cosmic_y_to_widget_y(&metrics, cursor.y, line_height.into()))
|
|
};
|
|
|
|
let position = adjusted + translation;
|
|
|
|
InputMethod::Enabled {
|
|
cursor: Rectangle::new(
|
|
position,
|
|
Size::new(1.0, f32::from(line_height)),
|
|
),
|
|
purpose: input_method::Purpose::Normal,
|
|
preedit: state.preedit.as_ref().map(input_method::Preedit::as_ref),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The content of a [`TextEditor`].
|
|
pub struct Content(RefCell<Internal>);
|
|
|
|
struct Internal {
|
|
editor: iced_graphics::text::Editor,
|
|
}
|
|
|
|
impl Content {
|
|
/// Creates an empty [`Content`].
|
|
pub fn new() -> Self {
|
|
Self::with_text("")
|
|
}
|
|
|
|
/// Creates a [`Content`] with the given text.
|
|
pub fn with_text(text: &str) -> Self {
|
|
Self(RefCell::new(Internal {
|
|
editor: <iced_graphics::text::Editor as text::editor::Editor>::with_text(text),
|
|
}))
|
|
}
|
|
|
|
/// Performs an [`Action`] on the [`Content`].
|
|
pub fn perform(&mut self, action: Action) {
|
|
let internal = self.0.get_mut();
|
|
|
|
internal.editor.perform(action);
|
|
}
|
|
|
|
/// Moves the current cursor to reflect the given one.
|
|
pub fn move_to(&mut self, cursor: Cursor) {
|
|
let internal = self.0.get_mut();
|
|
|
|
internal.editor.move_to(cursor);
|
|
}
|
|
|
|
/// Returns the current cursor position of the [`Content`].
|
|
pub fn cursor(&self) -> Cursor {
|
|
self.0.borrow().editor.cursor()
|
|
}
|
|
|
|
/// Returns the amount of lines of the [`Content`].
|
|
pub fn line_count(&self) -> usize {
|
|
self.0.borrow().editor.line_count()
|
|
}
|
|
|
|
/// Returns the text of the line at the given index, if it exists.
|
|
pub fn line(&self, index: usize) -> Option<Line<'_>> {
|
|
let internal = self.0.borrow();
|
|
let line = internal.editor.line(index)?;
|
|
|
|
Some(Line {
|
|
text: Cow::Owned(line.text.into_owned()),
|
|
ending: line.ending,
|
|
})
|
|
}
|
|
|
|
/// Returns an iterator of the text of the lines in the [`Content`].
|
|
pub fn lines(&self) -> impl Iterator<Item = Line<'_>> {
|
|
(0..)
|
|
.map(|i| self.line(i))
|
|
.take_while(Option::is_some)
|
|
.flatten()
|
|
}
|
|
|
|
/// Returns the text of the [`Content`].
|
|
pub fn text(&self) -> String {
|
|
let mut contents = String::new();
|
|
let mut lines = self.lines().peekable();
|
|
|
|
while let Some(line) = lines.next() {
|
|
contents.push_str(&line.text);
|
|
|
|
if lines.peek().is_some() {
|
|
contents.push_str(if line.ending == LineEnding::None {
|
|
LineEnding::default().as_str()
|
|
} else {
|
|
line.ending.as_str()
|
|
});
|
|
}
|
|
}
|
|
|
|
contents
|
|
}
|
|
|
|
/// Returns the selected text of the [`Content`].
|
|
pub fn selection(&self) -> Option<String> {
|
|
self.0.borrow().editor.copy()
|
|
}
|
|
|
|
/// Returns the kind of [`LineEnding`] used for separating lines in the [`Content`].
|
|
pub fn line_ending(&self) -> Option<LineEnding> {
|
|
Some(self.line(0)?.ending)
|
|
}
|
|
|
|
/// Returns whether or not the the [`Content`] is empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.borrow().editor.is_empty()
|
|
}
|
|
}
|
|
|
|
impl Clone for Content {
|
|
fn clone(&self) -> Self {
|
|
Self::with_text(&self.text())
|
|
}
|
|
}
|
|
|
|
impl Default for Content {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Content
|
|
{
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let internal = self.0.borrow();
|
|
|
|
f.debug_struct("Content")
|
|
.field("editor", &internal.editor)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// The state of a [`TextEditor`].
|
|
#[derive(Debug)]
|
|
pub struct State<Highlighter: text::Highlighter> {
|
|
focus: Option<Focus>,
|
|
preedit: Option<input_method::Preedit>,
|
|
last_click: Option<mouse::Click>,
|
|
drag_click: Option<mouse::click::Kind>,
|
|
partial_scroll: f32,
|
|
last_theme: RefCell<Option<String>>,
|
|
highlighter: RefCell<Highlighter>,
|
|
highlighter_settings: Highlighter::Settings,
|
|
highlighter_format_address: usize,
|
|
/// Paragraphs built during draw() — kept alive so the renderer's Weak refs
|
|
/// survive until the prepare() phase processes them.
|
|
retained_paragraphs: RefCell<Vec<iced_graphics::text::Paragraph>>,
|
|
/// Per-logical-line metrics published by `layout()`. Every consumer
|
|
/// (draw, cursor caret, click/drag hit-testing, IME) reads from this
|
|
/// same Vec — there is no parallel computation. Length = line_count
|
|
/// + 1, with the trailing sentinel marking widget/buffer y past the
|
|
/// last line.
|
|
line_metrics: RefCell<Vec<LineMetric>>,
|
|
/// Gutter strip width, also published by layout. Same single-source
|
|
/// rule: events translate click x by reading this, never recomputing.
|
|
gutter_width: std::cell::Cell<f32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Focus {
|
|
updated_at: Instant,
|
|
now: Instant,
|
|
is_window_focused: bool,
|
|
}
|
|
|
|
impl Focus {
|
|
const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500;
|
|
|
|
fn now() -> Self {
|
|
let now = Instant::now();
|
|
|
|
Self {
|
|
updated_at: now,
|
|
now,
|
|
is_window_focused: true,
|
|
}
|
|
}
|
|
|
|
fn is_cursor_visible(&self) -> bool {
|
|
self.is_window_focused
|
|
&& ((self.now - self.updated_at).as_millis()
|
|
/ Self::CURSOR_BLINK_INTERVAL_MILLIS)
|
|
.is_multiple_of(2)
|
|
}
|
|
}
|
|
|
|
impl<Highlighter: text::Highlighter> State<Highlighter> {
|
|
/// Returns whether the [`TextEditor`] is currently focused or not.
|
|
pub fn is_focused(&self) -> bool {
|
|
self.focus.is_some()
|
|
}
|
|
}
|
|
|
|
impl<Highlighter: text::Highlighter> operation::Focusable
|
|
for State<Highlighter>
|
|
{
|
|
fn is_focused(&self) -> bool {
|
|
self.focus.is_some()
|
|
}
|
|
|
|
fn focus(&mut self) {
|
|
self.focus = Some(Focus::now());
|
|
}
|
|
|
|
fn unfocus(&mut self) {
|
|
self.focus = None;
|
|
}
|
|
}
|
|
|
|
impl<Highlighter, Message, Theme> Widget<Message, Theme, iced_wgpu::Renderer>
|
|
for TextEditor<'_, Highlighter, Message, Theme>
|
|
where
|
|
Highlighter: text::Highlighter,
|
|
Theme: Catalog,
|
|
{
|
|
fn tag(&self) -> widget::tree::Tag {
|
|
widget::tree::Tag::of::<State<Highlighter>>()
|
|
}
|
|
|
|
fn state(&self) -> widget::tree::State {
|
|
widget::tree::State::new(State {
|
|
focus: None,
|
|
preedit: None,
|
|
last_click: None,
|
|
drag_click: None,
|
|
partial_scroll: 0.0,
|
|
last_theme: RefCell::default(),
|
|
highlighter: RefCell::new(Highlighter::new(
|
|
&self.highlighter_settings,
|
|
)),
|
|
highlighter_settings: self.highlighter_settings.clone(),
|
|
highlighter_format_address: self.highlighter_format as usize,
|
|
retained_paragraphs: RefCell::new(Vec::new()),
|
|
line_metrics: RefCell::new(Vec::new()),
|
|
gutter_width: std::cell::Cell::new(0.0),
|
|
})
|
|
}
|
|
|
|
fn children(&self) -> Vec<widget::Tree> {
|
|
self.anchored_children.iter().map(|item| widget::Tree::new(&item.element)).collect()
|
|
}
|
|
|
|
fn diff(&self, tree: &mut widget::Tree) {
|
|
tree.diff_children(&self.anchored_children.iter().map(|item| &item.element).collect::<Vec<_>>());
|
|
}
|
|
|
|
fn size(&self) -> Size<Length> {
|
|
Size {
|
|
width: self.width,
|
|
height: self.height,
|
|
}
|
|
}
|
|
|
|
fn layout(
|
|
&mut self,
|
|
tree: &mut widget::Tree,
|
|
renderer: &iced_wgpu::Renderer,
|
|
limits: &layout::Limits,
|
|
) -> layout::Node {
|
|
let mut internal = self.content.0.borrow_mut();
|
|
let state = tree.state.downcast_mut::<State<Highlighter>>();
|
|
|
|
if state.highlighter_format_address != self.highlighter_format as usize
|
|
{
|
|
state.highlighter.borrow_mut().change_line(0);
|
|
|
|
state.highlighter_format_address = self.highlighter_format as usize;
|
|
}
|
|
|
|
if state.highlighter_settings != self.highlighter_settings {
|
|
state
|
|
.highlighter
|
|
.borrow_mut()
|
|
.update(&self.highlighter_settings);
|
|
|
|
state.highlighter_settings = self.highlighter_settings.clone();
|
|
}
|
|
|
|
let limits = limits
|
|
.width(self.width)
|
|
.height(self.height)
|
|
.min_height(self.min_height)
|
|
.max_height(self.max_height);
|
|
|
|
let gw = self.gutter_width_for(internal.editor.line_count());
|
|
state.gutter_width.set(gw);
|
|
let effective_padding = Padding {
|
|
left: self.padding.left + gw,
|
|
..self.padding
|
|
};
|
|
|
|
internal.editor.update(
|
|
limits.shrink(effective_padding).max(),
|
|
self.font.unwrap_or_else(|| renderer.default_font()),
|
|
self.text_size.unwrap_or_else(|| renderer.default_size()),
|
|
self.line_height,
|
|
self.wrapping,
|
|
state.highlighter.borrow_mut().deref_mut(),
|
|
);
|
|
|
|
let line_h: f32 = self.line_height.to_absolute(
|
|
self.text_size.unwrap_or_else(|| renderer.default_size()),
|
|
).into();
|
|
|
|
// Single source-of-truth: walk lines + anchored children once and
|
|
// build per-line metrics. Each LineMetric records the widget-y +
|
|
// buffer-y of that line's first visual row, plus the wrap count.
|
|
// Draw, cursor positioning, click/drag — every consumer reads from
|
|
// this same Vec so they cannot drift.
|
|
let mut child_nodes = Vec::with_capacity(self.anchored_children.len());
|
|
let child_limits = layout::Limits::new(
|
|
Size::ZERO,
|
|
Size::new(limits.shrink(effective_padding).max().width, f32::INFINITY),
|
|
);
|
|
let buffer = internal.editor.buffer();
|
|
let line_count = buffer.lines.len();
|
|
// Seed widget_y from cosmic-text's internal scroll so the metrics
|
|
// we publish reflect ACTUAL on-screen positions, not no-scroll
|
|
// positions. Without this seeding, draw renders text at unscrolled
|
|
// y while the cursor (computed via cosmic's scroll-aware selection)
|
|
// appears to drift — the classic "two sources of truth" violation.
|
|
// Anchor cosmic's viewport-y at scroll.line top: cosmic's
|
|
// `Selection::Caret(position).y` for a cursor sitting on the very
|
|
// first visible visual row equals `-scroll.vertical`, regardless
|
|
// of how many logical lines came before. Pre-scroll lines are not
|
|
// shaped (`layout_opt() == None`) and contribute 0 visual rows in
|
|
// cosmic's own bookkeeping — mirror that so the two y-spaces
|
|
// agree.
|
|
let scroll = buffer.scroll();
|
|
let mut metrics: Vec<LineMetric> = Vec::with_capacity(line_count + 1);
|
|
let mut widget_y = -scroll.vertical;
|
|
let mut viewport_y = -scroll.vertical;
|
|
// Pre-scroll lines: cosmic treats them as 0 rows, but we still
|
|
// need a widget-y so any bottom-anchored items render relative to
|
|
// a stable bottom. For the cursor/selection mapping to work the
|
|
// viewport_y must stay parked at -scroll.vertical for them
|
|
// (cosmic has no addressable y above scroll.line).
|
|
for _ in 0..scroll.line.min(line_count) {
|
|
metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 });
|
|
}
|
|
let mut next_child = 0;
|
|
// Skip anchored children that sit above the scroll line.
|
|
while next_child < self.anchored_children.len()
|
|
&& self.anchored_children[next_child].after_line < scroll.line
|
|
{
|
|
next_child += 1;
|
|
}
|
|
for line in scroll.line..line_count {
|
|
let visual_rows = buffer.lines[line]
|
|
.layout_opt()
|
|
.map(|v| v.len())
|
|
.unwrap_or(1)
|
|
.max(1);
|
|
metrics.push(LineMetric { widget_y, viewport_y, visual_rows });
|
|
let line_visual_h = visual_rows as f32 * line_h;
|
|
widget_y += line_visual_h;
|
|
viewport_y += line_visual_h;
|
|
while next_child < self.anchored_children.len()
|
|
&& self.anchored_children[next_child].after_line == line
|
|
{
|
|
let child = &mut self.anchored_children[next_child];
|
|
let mut node = child.element.as_widget_mut().layout(
|
|
&mut tree.children[next_child],
|
|
renderer,
|
|
&child_limits,
|
|
);
|
|
node = node.move_to(Point::new(
|
|
self.padding.left + gw,
|
|
self.padding.top + widget_y,
|
|
));
|
|
child.height = node.bounds().height;
|
|
widget_y += child.height;
|
|
child_nodes.push(node);
|
|
next_child += 1;
|
|
}
|
|
}
|
|
// Remaining children after last line — they sit below all text.
|
|
while next_child < self.anchored_children.len() {
|
|
let child = &mut self.anchored_children[next_child];
|
|
let mut node = child.element.as_widget_mut().layout(
|
|
&mut tree.children[next_child],
|
|
renderer,
|
|
&child_limits,
|
|
);
|
|
node = node.move_to(Point::new(
|
|
self.padding.left + gw,
|
|
self.padding.top + widget_y,
|
|
));
|
|
child.height = node.bounds().height;
|
|
widget_y += child.height;
|
|
child_nodes.push(node);
|
|
next_child += 1;
|
|
}
|
|
// Push sentinel AFTER trailing children are placed, so the
|
|
// sentinel widget_y reflects the true bottom of the stream.
|
|
metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 });
|
|
let extra = widget_y - viewport_y;
|
|
*state.line_metrics.borrow_mut() = metrics;
|
|
|
|
match self.height {
|
|
Length::Fill | Length::FillPortion(_) | Length::Fixed(_) => {
|
|
// Fixed/Fill: caller specified the height. Honor it as-is —
|
|
// anchored items live within that height; trailing space
|
|
// would otherwise create phantom gaps below the block.
|
|
layout::Node::with_children(limits.max(), child_nodes)
|
|
}
|
|
Length::Shrink => {
|
|
let min_bounds = internal.editor.min_bounds();
|
|
|
|
layout::Node::with_children(
|
|
limits
|
|
.height(min_bounds.height + extra)
|
|
.max()
|
|
.expand(Size::new(0.0, self.padding.y())),
|
|
child_nodes,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update(
|
|
&mut self,
|
|
tree: &mut widget::Tree,
|
|
event: &Event,
|
|
layout: Layout<'_>,
|
|
cursor: mouse::Cursor,
|
|
renderer: &iced_wgpu::Renderer,
|
|
clipboard: &mut dyn Clipboard,
|
|
shell: &mut Shell<'_, Message>,
|
|
_viewport: &Rectangle,
|
|
) {
|
|
// Forward events to anchored children first
|
|
if !self.anchored_children.is_empty() {
|
|
let children_layouts: Vec<_> = layout.children().collect();
|
|
for (i, child) in self.anchored_children.iter_mut().enumerate() {
|
|
if i < children_layouts.len() && i < tree.children.len() {
|
|
child.element.as_widget_mut().update(
|
|
&mut tree.children[i],
|
|
event,
|
|
children_layouts[i],
|
|
cursor,
|
|
renderer,
|
|
clipboard,
|
|
shell,
|
|
_viewport,
|
|
);
|
|
if shell.is_event_captured() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(on_edit) = self.on_edit.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
let state = tree.state.downcast_mut::<State<Highlighter>>();
|
|
let is_redraw = matches!(
|
|
event,
|
|
Event::Window(window::Event::RedrawRequested(_now)),
|
|
);
|
|
|
|
match event {
|
|
Event::Window(window::Event::Unfocused) => {
|
|
if let Some(focus) = &mut state.focus {
|
|
focus.is_window_focused = false;
|
|
}
|
|
}
|
|
Event::Window(window::Event::Focused) => {
|
|
if let Some(focus) = &mut state.focus {
|
|
focus.is_window_focused = true;
|
|
focus.updated_at = Instant::now();
|
|
|
|
shell.request_redraw();
|
|
}
|
|
}
|
|
Event::Window(window::Event::RedrawRequested(now)) => {
|
|
if let Some(focus) = &mut state.focus
|
|
&& focus.is_window_focused
|
|
{
|
|
focus.now = *now;
|
|
|
|
let millis_until_redraw =
|
|
Focus::CURSOR_BLINK_INTERVAL_MILLIS
|
|
- (focus.now - focus.updated_at).as_millis()
|
|
% Focus::CURSOR_BLINK_INTERVAL_MILLIS;
|
|
|
|
shell.request_redraw_at(
|
|
focus.now
|
|
+ Duration::from_millis(millis_until_redraw as u64),
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if let Some(update) = Update::from_event(
|
|
event,
|
|
state,
|
|
layout.bounds(),
|
|
self.padding,
|
|
cursor,
|
|
self.key_binding.as_deref(),
|
|
) {
|
|
let line_h: f32 = self.line_height.to_absolute(
|
|
self.text_size.unwrap_or_else(|| renderer.default_size()),
|
|
).into();
|
|
|
|
match update {
|
|
Update::Click(click) => {
|
|
let gw = state.gutter_width.get();
|
|
let action = match click.kind() {
|
|
mouse::click::Kind::Single => {
|
|
let mut pos = click.position();
|
|
pos.x = (pos.x - gw).max(0.0);
|
|
let metrics = state.line_metrics.borrow();
|
|
pos.y = widget_y_to_cosmic_y(&metrics, pos.y, line_h);
|
|
Action::Click(pos)
|
|
}
|
|
mouse::click::Kind::Double => Action::SelectWord,
|
|
mouse::click::Kind::Triple => Action::SelectLine,
|
|
};
|
|
|
|
state.focus = Some(Focus::now());
|
|
state.last_click = Some(click);
|
|
state.drag_click = Some(click.kind());
|
|
|
|
shell.publish(on_edit(action));
|
|
shell.capture_event();
|
|
}
|
|
Update::Drag(position) => {
|
|
let gw = state.gutter_width.get();
|
|
let mut pos = position;
|
|
pos.x = (pos.x - gw).max(0.0);
|
|
let metrics = state.line_metrics.borrow();
|
|
pos.y = widget_y_to_cosmic_y(&metrics, pos.y, line_h);
|
|
shell.publish(on_edit(Action::Drag(pos)));
|
|
}
|
|
Update::Release => {
|
|
state.drag_click = None;
|
|
}
|
|
Update::Scroll(lines) => {
|
|
let bounds = self.content.0.borrow().editor.bounds();
|
|
|
|
if bounds.height >= i32::MAX as f32 {
|
|
return;
|
|
}
|
|
|
|
let lines = lines + state.partial_scroll;
|
|
state.partial_scroll = lines.fract();
|
|
|
|
shell.publish(on_edit(Action::Scroll {
|
|
lines: lines as i32,
|
|
}));
|
|
shell.capture_event();
|
|
}
|
|
Update::InputMethod(update) => match update {
|
|
Ime::Toggle(is_open) => {
|
|
state.preedit =
|
|
is_open.then(input_method::Preedit::new);
|
|
|
|
shell.request_redraw();
|
|
}
|
|
Ime::Preedit { content, selection } => {
|
|
state.preedit = Some(input_method::Preedit {
|
|
content,
|
|
selection,
|
|
text_size: self.text_size,
|
|
});
|
|
|
|
shell.request_redraw();
|
|
}
|
|
Ime::Commit(text) => {
|
|
shell.publish(on_edit(Action::Edit(Edit::Paste(
|
|
Arc::new(text),
|
|
))));
|
|
}
|
|
},
|
|
Update::Binding(binding) => {
|
|
fn apply_binding<
|
|
H: text::Highlighter,
|
|
Message,
|
|
>(
|
|
binding: Binding<Message>,
|
|
content: &Content,
|
|
state: &mut State<H>,
|
|
on_edit: &dyn Fn(Action) -> Message,
|
|
clipboard: &mut dyn Clipboard,
|
|
shell: &mut Shell<'_, Message>,
|
|
) {
|
|
let mut publish =
|
|
|action| shell.publish(on_edit(action));
|
|
|
|
match binding {
|
|
Binding::Unfocus => {
|
|
state.focus = None;
|
|
state.drag_click = None;
|
|
}
|
|
Binding::Copy => {
|
|
if let Some(selection) = content.selection() {
|
|
clipboard.write(
|
|
clipboard::Kind::Standard,
|
|
selection,
|
|
);
|
|
}
|
|
}
|
|
Binding::Cut => {
|
|
if let Some(selection) = content.selection() {
|
|
clipboard.write(
|
|
clipboard::Kind::Standard,
|
|
selection,
|
|
);
|
|
|
|
publish(Action::Edit(Edit::Delete));
|
|
}
|
|
}
|
|
Binding::Paste => {
|
|
if let Some(contents) =
|
|
clipboard.read(clipboard::Kind::Standard)
|
|
{
|
|
publish(Action::Edit(Edit::Paste(
|
|
Arc::new(contents),
|
|
)));
|
|
}
|
|
}
|
|
Binding::Move(motion) => {
|
|
publish(Action::Move(motion));
|
|
}
|
|
Binding::Select(motion) => {
|
|
publish(Action::Select(motion));
|
|
}
|
|
Binding::SelectWord => {
|
|
publish(Action::SelectWord);
|
|
}
|
|
Binding::SelectLine => {
|
|
publish(Action::SelectLine);
|
|
}
|
|
Binding::SelectAll => {
|
|
publish(Action::SelectAll);
|
|
}
|
|
Binding::Insert(c) => {
|
|
publish(Action::Edit(Edit::Insert(c)));
|
|
}
|
|
Binding::Enter => {
|
|
publish(Action::Edit(Edit::Enter));
|
|
}
|
|
Binding::Backspace => {
|
|
publish(Action::Edit(Edit::Backspace));
|
|
}
|
|
Binding::Delete => {
|
|
publish(Action::Edit(Edit::Delete));
|
|
}
|
|
Binding::Sequence(sequence) => {
|
|
for binding in sequence {
|
|
apply_binding(
|
|
binding, content, state, on_edit,
|
|
clipboard, shell,
|
|
);
|
|
}
|
|
}
|
|
Binding::Custom(message) => {
|
|
shell.publish(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !matches!(binding, Binding::Unfocus) {
|
|
shell.capture_event();
|
|
}
|
|
|
|
apply_binding(
|
|
binding,
|
|
self.content,
|
|
state,
|
|
on_edit,
|
|
clipboard,
|
|
shell,
|
|
);
|
|
|
|
if let Some(focus) = &mut state.focus {
|
|
focus.updated_at = Instant::now();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let status = {
|
|
let is_disabled = self.on_edit.is_none();
|
|
let is_hovered = cursor.is_over(layout.bounds());
|
|
|
|
if is_disabled {
|
|
Status::Disabled
|
|
} else if state.focus.is_some() {
|
|
Status::Focused { is_hovered }
|
|
} else if is_hovered {
|
|
Status::Hovered
|
|
} else {
|
|
Status::Active
|
|
}
|
|
};
|
|
|
|
if is_redraw {
|
|
self.last_status = Some(status);
|
|
|
|
shell.request_input_method(
|
|
&self.input_method(state, renderer, layout),
|
|
);
|
|
} else if self
|
|
.last_status
|
|
.is_some_and(|last_status| status != last_status)
|
|
{
|
|
shell.request_redraw();
|
|
}
|
|
}
|
|
|
|
fn draw(
|
|
&self,
|
|
tree: &widget::Tree,
|
|
renderer: &mut iced_wgpu::Renderer,
|
|
theme: &Theme,
|
|
_defaults: &renderer::Style,
|
|
layout: Layout<'_>,
|
|
_cursor: mouse::Cursor,
|
|
_viewport: &Rectangle,
|
|
) {
|
|
let bounds = layout.bounds();
|
|
|
|
let mut internal = self.content.0.borrow_mut();
|
|
let state = tree.state.downcast_ref::<State<Highlighter>>();
|
|
|
|
let font = self.font.unwrap_or_else(|| renderer.default_font());
|
|
|
|
let theme_name = theme.name();
|
|
|
|
if state
|
|
.last_theme
|
|
.borrow()
|
|
.as_ref()
|
|
.is_none_or(|last_theme| last_theme != theme_name)
|
|
{
|
|
state.highlighter.borrow_mut().change_line(0);
|
|
let _ =
|
|
state.last_theme.borrow_mut().replace(theme_name.to_owned());
|
|
}
|
|
|
|
internal.editor.highlight(
|
|
font,
|
|
state.highlighter.borrow_mut().deref_mut(),
|
|
|highlight| (self.highlighter_format)(highlight, theme),
|
|
);
|
|
|
|
let style = theme
|
|
.style(&self.class, self.last_status.unwrap_or(Status::Active));
|
|
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds,
|
|
border: style.border,
|
|
..renderer::Quad::default()
|
|
},
|
|
style.background,
|
|
);
|
|
|
|
let gw = state.gutter_width.get();
|
|
let effective_padding = Padding {
|
|
left: self.padding.left + gw,
|
|
..self.padding
|
|
};
|
|
let text_bounds = bounds.shrink(effective_padding);
|
|
|
|
let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
|
|
let line_h: f32 = self.line_height.to_absolute(text_size).into();
|
|
|
|
// Gutter background — only the strip below top_pad so the title-bar
|
|
// / traffic-light area doesn't get painted.
|
|
if self.show_gutter && self.padding.top < bounds.height {
|
|
let p = crate::palette::current();
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: Rectangle::new(
|
|
Point::new(bounds.x, bounds.y + self.padding.top),
|
|
Size::new(gw + self.padding.left, bounds.height - self.padding.top),
|
|
),
|
|
border: Border::default(),
|
|
..renderer::Quad::default()
|
|
},
|
|
Background::Color(p.crust),
|
|
);
|
|
}
|
|
|
|
if internal.editor.is_empty() {
|
|
if let Some(placeholder) = self.placeholder.clone() {
|
|
renderer.fill_text(
|
|
Text {
|
|
content: placeholder.into_owned(),
|
|
bounds: text_bounds.size(),
|
|
size: text_size,
|
|
line_height: self.line_height,
|
|
font,
|
|
align_x: text::Alignment::Default,
|
|
align_y: alignment::Vertical::Top,
|
|
shaping: text::Shaping::Advanced,
|
|
wrapping: self.wrapping,
|
|
},
|
|
text_bounds.position(),
|
|
style.placeholder,
|
|
text_bounds,
|
|
);
|
|
}
|
|
} else {
|
|
// Sequential stream: text lines (layer 0) interleaved with
|
|
// anchored children (layer 1) in one continuous pass. Cursorline
|
|
// tint and gutter line numbers are drawn on the SAME y as the
|
|
// line's paragraph — single source of truth.
|
|
let buffer = internal.editor.buffer();
|
|
let line_count = buffer.lines.len();
|
|
let mut child_idx = 0;
|
|
let children_layouts: Vec<_> = layout.children().collect();
|
|
|
|
// cursor line draws raw cosmic glyphs, other lines strip marker bytes for WYS.
|
|
let active_cursor_line = if self.is_focused_block {
|
|
self.cursor_line
|
|
} else {
|
|
None
|
|
};
|
|
let highlighter_borrow = state.highlighter.borrow();
|
|
let highlighter_any: &dyn std::any::Any = &*highlighter_borrow;
|
|
let syntax_highlighter = highlighter_any.downcast_ref::<crate::syntax::SyntaxHighlighter>();
|
|
{
|
|
let mut paras = state.retained_paragraphs.borrow_mut();
|
|
paras.clear();
|
|
let metrics = state.line_metrics.borrow();
|
|
for i in 0..line_count {
|
|
let line_text = buffer.lines[i].text();
|
|
let attrs_list = buffer.lines[i].attrs_list();
|
|
let glyphs: Vec<cosmic_text::LayoutGlyph> =
|
|
buffer.lines[i].layout_opt()
|
|
.map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect())
|
|
.unwrap_or_default();
|
|
let marker_ranges = if active_cursor_line == Some(i) {
|
|
Vec::new()
|
|
} else {
|
|
syntax_highlighter
|
|
.map(|h| h.line_marker_ranges(i, line_text))
|
|
.unwrap_or_default()
|
|
};
|
|
let spans = build_color_spans(
|
|
line_text,
|
|
&glyphs,
|
|
attrs_list,
|
|
font,
|
|
f32::from(text_size),
|
|
&marker_ranges,
|
|
);
|
|
let visual_rows = metrics.get(i).map(|m| m.visual_rows).unwrap_or(1).max(1);
|
|
paras.push(iced_graphics::text::Paragraph::with_spans(Text {
|
|
content: spans.as_slice(),
|
|
bounds: Size::new(text_bounds.width, visual_rows as f32 * line_h),
|
|
size: text_size,
|
|
line_height: self.line_height,
|
|
font,
|
|
align_x: text::Alignment::Default,
|
|
align_y: alignment::Vertical::Top,
|
|
shaping: text::Shaping::Advanced,
|
|
wrapping: self.wrapping,
|
|
}));
|
|
}
|
|
}
|
|
|
|
let p = crate::palette::current();
|
|
let font_size_px: f32 = f32::from(text_size);
|
|
let paras = state.retained_paragraphs.borrow();
|
|
let metrics = state.line_metrics.borrow();
|
|
for line_i in 0..line_count {
|
|
// Pull line position from the Vec layout published.
|
|
let m = match metrics.get(line_i) {
|
|
Some(m) => m,
|
|
None => continue,
|
|
};
|
|
// Pre-scroll lines carry visual_rows == 0 (cosmic hasn't
|
|
// shaped them, layout_opt returns None) — skip them so
|
|
// we don't draw unshaped paragraphs piled at the same y.
|
|
if m.visual_rows == 0 {
|
|
continue;
|
|
}
|
|
let y = text_bounds.y + m.widget_y;
|
|
let row_h = m.visual_rows as f32 * line_h;
|
|
|
|
// Cursorline tint — full editor width (incl. gutter),
|
|
// covers all visual rows of the wrapped logical line.
|
|
if self.is_focused_block
|
|
&& self.cursor_line == Some(line_i)
|
|
&& self.line_indicator != crate::editor::LineIndicator::Off
|
|
{
|
|
let band = Color { a: 0.06, ..p.text };
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: Rectangle::new(
|
|
Point::new(bounds.x, y),
|
|
Size::new(bounds.width, row_h),
|
|
),
|
|
border: Border::default(),
|
|
..renderer::Quad::default()
|
|
},
|
|
Background::Color(band),
|
|
);
|
|
}
|
|
|
|
// Gutter — line decor stripe + line number, in the strip
|
|
// between bounds.x and text_bounds.x.
|
|
if self.show_gutter {
|
|
self.draw_gutter_line(renderer, line_i, bounds, y, line_h, gw, &p, font_size_px);
|
|
}
|
|
|
|
renderer.fill_paragraph(
|
|
¶s[line_i],
|
|
Point::new(text_bounds.x, y),
|
|
style.value,
|
|
text_bounds,
|
|
);
|
|
|
|
// After this line, draw any anchored children
|
|
while child_idx < self.anchored_children.len()
|
|
&& self.anchored_children[child_idx].after_line == line_i
|
|
{
|
|
if child_idx < children_layouts.len() {
|
|
self.anchored_children[child_idx].element.as_widget().draw(
|
|
&tree.children[child_idx],
|
|
renderer,
|
|
theme,
|
|
_defaults,
|
|
children_layouts[child_idx],
|
|
_cursor,
|
|
_viewport,
|
|
);
|
|
}
|
|
child_idx += 1;
|
|
}
|
|
}
|
|
|
|
// Draw remaining children after last text line
|
|
while child_idx < self.anchored_children.len() {
|
|
if child_idx < children_layouts.len() {
|
|
self.anchored_children[child_idx].element.as_widget().draw(
|
|
&tree.children[child_idx],
|
|
renderer,
|
|
theme,
|
|
_defaults,
|
|
children_layouts[child_idx],
|
|
_cursor,
|
|
_viewport,
|
|
);
|
|
}
|
|
child_idx += 1;
|
|
}
|
|
}
|
|
|
|
let translation = text_bounds.position() - Point::ORIGIN;
|
|
|
|
if let Some(focus) = state.focus.as_ref() {
|
|
let metrics_for_cursor = state.line_metrics.borrow();
|
|
let adjust_y = |pos: Point| -> Point {
|
|
Point::new(pos.x, cosmic_y_to_widget_y(&metrics_for_cursor, pos.y, line_h))
|
|
};
|
|
|
|
match internal.editor.selection() {
|
|
Selection::Caret(position) if focus.is_cursor_visible() => {
|
|
let position = adjust_y(position);
|
|
let cursor =
|
|
Rectangle::new(
|
|
position + translation,
|
|
Size::new(1.0, line_h),
|
|
);
|
|
|
|
if let Some(clipped_cursor) =
|
|
text_bounds.intersection(&cursor)
|
|
{
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: clipped_cursor,
|
|
..renderer::Quad::default()
|
|
},
|
|
style.value,
|
|
);
|
|
}
|
|
}
|
|
Selection::Range(ranges) => {
|
|
for range in ranges.into_iter().map(|r| {
|
|
let adjusted = Rectangle::new(
|
|
adjust_y(r.position()),
|
|
r.size(),
|
|
);
|
|
adjusted + translation
|
|
}).filter_map(|r| text_bounds.intersection(&r)) {
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds: range,
|
|
..renderer::Quad::default()
|
|
},
|
|
style.selection,
|
|
);
|
|
}
|
|
}
|
|
Selection::Caret(_) => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mouse_interaction(
|
|
&self,
|
|
_tree: &widget::Tree,
|
|
layout: Layout<'_>,
|
|
cursor: mouse::Cursor,
|
|
_viewport: &Rectangle,
|
|
_renderer: &iced_wgpu::Renderer,
|
|
) -> mouse::Interaction {
|
|
let is_disabled = self.on_edit.is_none();
|
|
|
|
if cursor.is_over(layout.bounds()) {
|
|
if is_disabled {
|
|
mouse::Interaction::NotAllowed
|
|
} else {
|
|
mouse::Interaction::Text
|
|
}
|
|
} else {
|
|
mouse::Interaction::default()
|
|
}
|
|
}
|
|
|
|
fn operate(
|
|
&mut self,
|
|
tree: &mut widget::Tree,
|
|
layout: Layout<'_>,
|
|
_renderer: &iced_wgpu::Renderer,
|
|
operation: &mut dyn widget::Operation,
|
|
) {
|
|
let state = tree.state.downcast_mut::<State<Highlighter>>();
|
|
|
|
operation.focusable(self.id.as_ref(), layout.bounds(), state);
|
|
}
|
|
}
|
|
|
|
impl<'a, Highlighter, Message, Theme>
|
|
From<TextEditor<'a, Highlighter, Message, Theme>>
|
|
for Element<'a, Message, Theme, iced_wgpu::Renderer>
|
|
where
|
|
Highlighter: text::Highlighter,
|
|
Message: 'a,
|
|
Theme: Catalog + 'a,
|
|
{
|
|
fn from(
|
|
text_editor: TextEditor<'a, Highlighter, Message, Theme>,
|
|
) -> Self {
|
|
Self::new(text_editor)
|
|
}
|
|
}
|
|
|
|
/// A binding to an action in the [`TextEditor`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Binding<Message> {
|
|
/// Unfocus the [`TextEditor`].
|
|
Unfocus,
|
|
/// Copy the selection of the [`TextEditor`].
|
|
Copy,
|
|
/// Cut the selection of the [`TextEditor`].
|
|
Cut,
|
|
/// Paste the clipboard contents in the [`TextEditor`].
|
|
Paste,
|
|
/// Apply a [`Motion`].
|
|
Move(Motion),
|
|
/// Select text with a given [`Motion`].
|
|
Select(Motion),
|
|
/// Select the word at the current cursor.
|
|
SelectWord,
|
|
/// Select the line at the current cursor.
|
|
SelectLine,
|
|
/// Select the entire buffer.
|
|
SelectAll,
|
|
/// Insert the given character.
|
|
Insert(char),
|
|
/// Break the current line.
|
|
Enter,
|
|
/// Delete the previous character.
|
|
Backspace,
|
|
/// Delete the next character.
|
|
Delete,
|
|
/// A sequence of bindings to execute.
|
|
Sequence(Vec<Self>),
|
|
/// Produce the given message.
|
|
Custom(Message),
|
|
}
|
|
|
|
/// A key press.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct KeyPress {
|
|
/// The original key pressed without modifiers applied to it.
|
|
///
|
|
/// You should use this key for combinations (e.g. Ctrl+C).
|
|
pub key: keyboard::Key,
|
|
/// The key pressed with modifiers applied to it.
|
|
///
|
|
/// You should use this key for any single key bindings (e.g. motions).
|
|
pub modified_key: keyboard::Key,
|
|
/// The physical key pressed.
|
|
///
|
|
/// You should use this key for layout-independent bindings.
|
|
pub physical_key: keyboard::key::Physical,
|
|
/// The state of the keyboard modifiers.
|
|
pub modifiers: keyboard::Modifiers,
|
|
/// The text produced by the key press.
|
|
pub text: Option<SmolStr>,
|
|
/// The current [`Status`] of the [`TextEditor`].
|
|
pub status: Status,
|
|
}
|
|
|
|
impl<Message> Binding<Message> {
|
|
/// Returns the default [`Binding`] for the given key press.
|
|
pub fn from_key_press(event: KeyPress) -> Option<Self> {
|
|
let KeyPress {
|
|
key,
|
|
modified_key,
|
|
physical_key,
|
|
modifiers,
|
|
text,
|
|
status,
|
|
} = event;
|
|
|
|
if !matches!(status, Status::Focused { .. }) {
|
|
return None;
|
|
}
|
|
|
|
let combination = match key.to_latin(physical_key) {
|
|
Some('c') if modifiers.command() => Some(Self::Copy),
|
|
Some('x') if modifiers.command() => Some(Self::Cut),
|
|
Some('v') if modifiers.command() && !modifiers.alt() => {
|
|
Some(Self::Paste)
|
|
}
|
|
Some('a') if modifiers.command() => Some(Self::SelectAll),
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(binding) = combination {
|
|
return Some(binding);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let modified_key =
|
|
convert_macos_shortcut(&key, modifiers).unwrap_or(modified_key);
|
|
|
|
match modified_key.as_ref() {
|
|
keyboard::Key::Named(key::Named::Enter) => Some(Self::Enter),
|
|
keyboard::Key::Named(key::Named::Backspace) => {
|
|
Some(Self::Backspace)
|
|
}
|
|
keyboard::Key::Named(key::Named::Delete)
|
|
if text.is_none() || text.as_deref() == Some("\u{7f}") =>
|
|
{
|
|
Some(Self::Delete)
|
|
}
|
|
keyboard::Key::Named(key::Named::Escape) => Some(Self::Unfocus),
|
|
_ => {
|
|
if let Some(text) = text {
|
|
let c = text.chars().find(|c| !c.is_control())?;
|
|
|
|
Some(Self::Insert(c))
|
|
} else if let keyboard::Key::Named(named_key) = key.as_ref() {
|
|
let motion = motion(named_key)?;
|
|
|
|
let motion = if modifiers.macos_command() {
|
|
match motion {
|
|
Motion::Left => Motion::Home,
|
|
Motion::Right => Motion::End,
|
|
_ => motion,
|
|
}
|
|
} else {
|
|
motion
|
|
};
|
|
|
|
let motion = if modifiers.jump() {
|
|
motion.widen()
|
|
} else {
|
|
motion
|
|
};
|
|
|
|
Some(if modifiers.shift() {
|
|
Self::Select(motion)
|
|
} else {
|
|
Self::Move(motion)
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Update<Message> {
|
|
Click(mouse::Click),
|
|
Drag(Point),
|
|
Release,
|
|
Scroll(f32),
|
|
InputMethod(Ime),
|
|
Binding(Binding<Message>),
|
|
}
|
|
|
|
enum Ime {
|
|
Toggle(bool),
|
|
Preedit {
|
|
content: String,
|
|
selection: Option<ops::Range<usize>>,
|
|
},
|
|
Commit(String),
|
|
}
|
|
|
|
impl<Message> Update<Message> {
|
|
fn from_event<H: Highlighter>(
|
|
event: &Event,
|
|
state: &State<H>,
|
|
bounds: Rectangle,
|
|
padding: Padding,
|
|
cursor: mouse::Cursor,
|
|
key_binding: Option<&dyn Fn(KeyPress) -> Option<Binding<Message>>>,
|
|
) -> Option<Self> {
|
|
let binding = |binding| Some(Update::Binding(binding));
|
|
|
|
match event {
|
|
Event::Mouse(event) => match event {
|
|
mouse::Event::ButtonPressed(mouse::Button::Left) => {
|
|
if let Some(cursor_position) = cursor.position_in(bounds) {
|
|
let cursor_position = cursor_position
|
|
- Vector::new(padding.left, padding.top);
|
|
|
|
let click = mouse::Click::new(
|
|
cursor_position,
|
|
mouse::Button::Left,
|
|
state.last_click,
|
|
);
|
|
|
|
Some(Update::Click(click))
|
|
} else if state.focus.is_some() {
|
|
binding(Binding::Unfocus)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
mouse::Event::ButtonReleased(mouse::Button::Left) => {
|
|
Some(Update::Release)
|
|
}
|
|
mouse::Event::CursorMoved { .. } => match state.drag_click {
|
|
Some(mouse::click::Kind::Single) => {
|
|
let cursor_position = cursor.position_in(bounds)?
|
|
- Vector::new(padding.left, padding.top);
|
|
|
|
Some(Update::Drag(cursor_position))
|
|
}
|
|
_ => None,
|
|
},
|
|
mouse::Event::WheelScrolled { delta }
|
|
if cursor.is_over(bounds) =>
|
|
{
|
|
Some(Update::Scroll(match delta {
|
|
mouse::ScrollDelta::Lines { y, .. } => {
|
|
if y.abs() > 0.0 {
|
|
y.signum() * -(y.abs() * 4.0).max(1.0)
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
mouse::ScrollDelta::Pixels { y, .. } => -y / 4.0,
|
|
}))
|
|
}
|
|
_ => None,
|
|
},
|
|
Event::InputMethod(event) => match event {
|
|
input_method::Event::Opened | input_method::Event::Closed => {
|
|
Some(Update::InputMethod(Ime::Toggle(matches!(
|
|
event,
|
|
input_method::Event::Opened
|
|
))))
|
|
}
|
|
input_method::Event::Preedit(content, selection)
|
|
if state.focus.is_some() =>
|
|
{
|
|
Some(Update::InputMethod(Ime::Preedit {
|
|
content: content.clone(),
|
|
selection: selection.clone(),
|
|
}))
|
|
}
|
|
input_method::Event::Commit(content)
|
|
if state.focus.is_some() =>
|
|
{
|
|
Some(Update::InputMethod(Ime::Commit(content.clone())))
|
|
}
|
|
_ => None,
|
|
},
|
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
|
key,
|
|
modified_key,
|
|
physical_key,
|
|
modifiers,
|
|
text,
|
|
..
|
|
}) => {
|
|
let status = if state.focus.is_some() {
|
|
Status::Focused {
|
|
is_hovered: cursor.is_over(bounds),
|
|
}
|
|
} else {
|
|
Status::Active
|
|
};
|
|
|
|
let key_press = KeyPress {
|
|
key: key.clone(),
|
|
modified_key: modified_key.clone(),
|
|
physical_key: *physical_key,
|
|
modifiers: *modifiers,
|
|
text: text.clone(),
|
|
status,
|
|
};
|
|
|
|
if let Some(key_binding) = key_binding {
|
|
key_binding(key_press)
|
|
} else {
|
|
Binding::from_key_press(key_press)
|
|
}
|
|
.map(Self::Binding)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn motion(key: key::Named) -> Option<Motion> {
|
|
match key {
|
|
key::Named::ArrowLeft => Some(Motion::Left),
|
|
key::Named::ArrowRight => Some(Motion::Right),
|
|
key::Named::ArrowUp => Some(Motion::Up),
|
|
key::Named::ArrowDown => Some(Motion::Down),
|
|
key::Named::Home => Some(Motion::Home),
|
|
key::Named::End => Some(Motion::End),
|
|
key::Named::PageUp => Some(Motion::PageUp),
|
|
key::Named::PageDown => Some(Motion::PageDown),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The possible status of a [`TextEditor`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Status {
|
|
/// The [`TextEditor`] can be interacted with.
|
|
Active,
|
|
/// The [`TextEditor`] is being hovered.
|
|
Hovered,
|
|
/// The [`TextEditor`] is focused.
|
|
Focused {
|
|
/// Whether the [`TextEditor`] is hovered, while focused.
|
|
is_hovered: bool,
|
|
},
|
|
/// The [`TextEditor`] cannot be interacted with.
|
|
Disabled,
|
|
}
|
|
|
|
/// The appearance of a text input.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Style {
|
|
/// The [`Background`] of the text input.
|
|
pub background: Background,
|
|
/// The [`Border`] of the text input.
|
|
pub border: Border,
|
|
/// The [`Color`] of the placeholder of the text input.
|
|
pub placeholder: Color,
|
|
/// The [`Color`] of the value of the text input.
|
|
pub value: Color,
|
|
/// The [`Color`] of the selection of the text input.
|
|
pub selection: Color,
|
|
}
|
|
|
|
/// The theme catalog of a [`TextEditor`].
|
|
pub trait Catalog: theme::Base {
|
|
/// The item class of the [`Catalog`].
|
|
type Class<'a>;
|
|
|
|
/// The default class produced by the [`Catalog`].
|
|
fn default<'a>() -> Self::Class<'a>;
|
|
|
|
/// The [`Style`] of a class with the given status.
|
|
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
|
|
}
|
|
|
|
/// A styling function for a [`TextEditor`].
|
|
pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
|
|
|
|
impl Catalog for Theme {
|
|
type Class<'a> = StyleFn<'a, Self>;
|
|
|
|
fn default<'a>() -> Self::Class<'a> {
|
|
Box::new(default)
|
|
}
|
|
|
|
fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
|
|
class(self, status)
|
|
}
|
|
}
|
|
|
|
/// The default style of a [`TextEditor`].
|
|
pub fn default(theme: &Theme, status: Status) -> Style {
|
|
let palette = theme.extended_palette();
|
|
|
|
let active = Style {
|
|
background: Background::Color(palette.background.base.color),
|
|
border: Border {
|
|
radius: 2.0.into(),
|
|
width: 1.0,
|
|
color: palette.background.strong.color,
|
|
},
|
|
placeholder: palette.secondary.base.color,
|
|
value: palette.background.base.text,
|
|
selection: palette.primary.weak.color,
|
|
};
|
|
|
|
match status {
|
|
Status::Active => active,
|
|
Status::Hovered => Style {
|
|
border: Border {
|
|
color: palette.background.base.text,
|
|
..active.border
|
|
},
|
|
..active
|
|
},
|
|
Status::Focused { .. } => Style {
|
|
border: Border {
|
|
color: palette.primary.strong.color,
|
|
..active.border
|
|
},
|
|
..active
|
|
},
|
|
Status::Disabled => Style {
|
|
background: Background::Color(palette.background.weak.color),
|
|
value: active.placeholder,
|
|
placeholder: palette.background.strongest.color,
|
|
..active
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub fn convert_macos_shortcut(
|
|
key: &keyboard::Key,
|
|
modifiers: keyboard::Modifiers,
|
|
) -> Option<keyboard::Key> {
|
|
if modifiers != keyboard::Modifiers::CTRL {
|
|
return None;
|
|
}
|
|
|
|
let key = match key.as_ref() {
|
|
keyboard::Key::Character("b") => key::Named::ArrowLeft,
|
|
keyboard::Key::Character("f") => key::Named::ArrowRight,
|
|
keyboard::Key::Character("a") => key::Named::Home,
|
|
keyboard::Key::Character("e") => key::Named::End,
|
|
keyboard::Key::Character("h") => key::Named::Backspace,
|
|
keyboard::Key::Character("d") => key::Named::Delete,
|
|
_ => return None,
|
|
};
|
|
|
|
Some(keyboard::Key::Named(key))
|
|
}
|