use std::sync::Arc;
use blinc_core::{Color, State};
use crate::div::{div, Div, FontWeight};
use crate::widgets::rich_text_editor::cursor::ActiveFormat;
use super::format::{apply_mark_to_selection, Mark};
use super::render::RichTextTheme;
use super::state::{PickerState, RichTextState};
pub fn selection_toolbar(
state: &RichTextState,
version: &State<u32>,
theme: &RichTextTheme,
) -> Option<Div> {
let mut data = state.lock().ok()?;
let bounds = data.selection_bounds()?;
let picker = data.picker.clone();
let (x, y, w, _h) = bounds;
let toolbar_w: f32 = match picker {
PickerState::None => 280.0,
PickerState::Color => 280.0,
PickerState::Heading => 300.0,
PickerState::Link { .. } => 420.0,
};
let mark_row_h = 36.0_f32;
let picker_row_h = match picker {
PickerState::None => 0.0,
PickerState::Color => 36.0,
PickerState::Heading => 32.0,
PickerState::Link { .. } => 38.0,
};
let toolbar_h = mark_row_h + picker_row_h;
let center_x = x + w * 0.5;
let tx = (center_x - toolbar_w * 0.5).max(0.0);
let mut ty = y - toolbar_h - 6.0;
if ty < 0.0 {
ty = y + bounds.3 + 6.0;
}
data.toolbar_rect = Some((tx, ty, toolbar_w, toolbar_h));
drop(data);
let mark_row = mark_row(state, version, theme);
let mut toolbar = div()
.absolute()
.left(tx)
.top(ty)
.w(toolbar_w)
.padding_x_px(6.0)
.padding_y_px(4.0)
.flex_col()
.gap_px(4.0)
.foreground()
.bg(Color::rgba(0.10, 0.10, 0.13, 1.0))
.rounded(8.0)
.border(1.0, Color::rgba(0.30, 0.30, 0.36, 1.0))
.shadow(blinc_core::Shadow {
offset_x: 0.0,
offset_y: 4.0,
blur: 12.0,
spread: 0.0,
color: Color::rgba(0.0, 0.0, 0.0, 0.45),
})
.child(mark_row);
match picker {
PickerState::None => {}
PickerState::Color => {
toolbar = toolbar
.child(row_separator())
.child(color_picker_row(state, version));
}
PickerState::Heading => {
toolbar = toolbar
.child(row_separator())
.child(heading_picker_row(state, version));
}
PickerState::Link { draft } => {
toolbar = toolbar
.child(row_separator())
.child(link_prompt_row(state, version, theme, &draft));
}
}
Some(toolbar)
}
fn row_separator() -> Div {
div().h(1.0).w_full().bg(Color::rgba(0.34, 0.34, 0.40, 1.0))
}
fn mark_row(state: &RichTextState, version: &State<u32>, theme: &RichTextTheme) -> Div {
div()
.flex_row()
.items_center()
.gap_px(2.0)
.child(mark_button(
"B",
"Bold",
LabelStyle::BOLD,
state,
version,
theme,
|d| apply_mark_via(d, Mark::Bold),
))
.child(mark_button(
"I",
"Italic",
LabelStyle::ITALIC,
state,
version,
theme,
|d| apply_mark_via(d, Mark::Italic),
))
.child(mark_button(
"U",
"Underline",
LabelStyle::UNDERLINE,
state,
version,
theme,
|d| apply_mark_via(d, Mark::Underline),
))
.child(mark_button(
"S",
"Strikethrough",
LabelStyle::STRIKE,
state,
version,
theme,
|d| apply_mark_via(d, Mark::Strikethrough),
))
.child(mark_button(
"<>",
"Inline code",
LabelStyle::CODE,
state,
version,
theme,
|d| apply_mark_via(d, Mark::Code),
))
.child(divider())
.child(color_swatch_button(state, version))
.child(picker_button("H", "Heading", state, version, theme, |d| {
d.picker = if matches!(d.picker, PickerState::Heading) {
PickerState::None
} else {
PickerState::Heading
};
}))
.child(picker_button("@", "Link", state, version, theme, |d| {
d.picker = if matches!(d.picker, PickerState::Link { .. }) {
PickerState::None
} else {
PickerState::Link {
draft: existing_link(d).unwrap_or_default(),
}
};
}))
}
fn color_swatch_button(state: &RichTextState, version: &State<u32>) -> Div {
let state_for_click = Arc::clone(state);
let version_for_click = version.clone();
let current_color = state
.lock()
.ok()
.and_then(|d| d.active_format.color)
.unwrap_or(Color::WHITE);
div()
.w(28.0)
.h(24.0)
.items_center()
.justify_center()
.rounded(4.0)
.cursor_pointer()
.child(
div()
.w(14.0)
.h(14.0)
.rounded(7.0)
.bg(current_color)
.border(1.0, Color::rgba(1.0, 1.0, 1.0, 0.32)),
)
.on_mouse_down(move |_| {
if let Ok(mut data) = state_for_click.lock() {
data.suppress_next_outer_click = true;
data.picker = if matches!(data.picker, PickerState::Color) {
PickerState::None
} else {
PickerState::Color
};
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
}
})
}
fn heading_picker_row(state: &RichTextState, version: &State<u32>) -> Div {
let mut row = div().flex_row().items_center().gap_px(4.0);
let levels: &[(Option<u8>, &str, f32, FontWeight)] = &[
(None, "P", 12.0, FontWeight::Normal),
(Some(1), "H1", 13.0, FontWeight::Bold),
(Some(2), "H2", 12.5, FontWeight::Bold),
(Some(3), "H3", 12.0, FontWeight::Bold),
(Some(4), "H4", 11.5, FontWeight::Bold),
(Some(5), "H5", 11.0, FontWeight::Bold),
(Some(6), "H6", 10.5, FontWeight::Bold),
];
for (level, label, font_size, weight) in levels {
let level = *level;
let label = *label;
let font_size = *font_size;
let weight = *weight;
let state_for_click = Arc::clone(state);
let version_for_click = version.clone();
row = row.child(
div()
.w(34.0)
.h(24.0)
.items_center()
.justify_center()
.rounded(4.0)
.bg(Color::rgba(0.20, 0.20, 0.26, 1.0))
.cursor_pointer()
.child(
crate::text::text(label)
.size(font_size)
.weight(weight)
.color(Color::WHITE)
.no_cursor(),
)
.on_mouse_down(move |_| {
if let Ok(mut data) = state_for_click.lock() {
data.suppress_next_outer_click = true;
apply_heading_level(&mut data, level);
data.picker = PickerState::None;
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
}
}),
);
}
row
}
fn apply_heading_level(data: &mut super::state::RichTextData, level: Option<u8>) {
use super::block_ops::convert_selection_to_block;
use super::document::BlockKind;
let range = data.selection.unwrap_or(super::cursor::Selection {
anchor: data.cursor,
head: data.cursor,
});
let kind = match level {
Some(n) => BlockKind::Heading(n),
None => BlockKind::Paragraph,
};
let before = data.document.clone();
data.push_undo();
let middle_block_idx = convert_selection_to_block(&mut data.document, range, kind);
if data.document == before {
data.undo_stack.pop();
return;
}
if let Some(idx) = middle_block_idx {
let middle_chars = data
.document
.blocks
.get(idx)
.map(|b| b.char_len())
.unwrap_or(0);
let new_start = super::cursor::DocPosition::new(idx, 0, 0);
let new_end = super::cursor::DocPosition::new(idx, 0, middle_chars);
data.selection = Some(super::cursor::Selection {
anchor: new_start,
head: new_end,
});
data.set_cursor(new_end);
} else {
let cursor = data.cursor;
data.set_cursor(cursor);
}
data.reset_cursor_blink();
}
#[derive(Clone, Copy)]
struct LabelStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
monospace: bool,
}
impl LabelStyle {
const PLAIN: Self = Self {
bold: false,
italic: false,
underline: false,
strikethrough: false,
monospace: false,
};
const BOLD: Self = Self {
bold: true,
..Self::PLAIN
};
const ITALIC: Self = Self {
italic: true,
..Self::PLAIN
};
const UNDERLINE: Self = Self {
underline: true,
..Self::PLAIN
};
const STRIKE: Self = Self {
strikethrough: true,
..Self::PLAIN
};
const CODE: Self = Self {
monospace: true,
..Self::PLAIN
};
}
fn mark_button(
label: &str,
_tooltip: &str,
style: LabelStyle,
state: &RichTextState,
version: &State<u32>,
_theme: &RichTextTheme,
on_click: impl Fn(&mut super::state::RichTextData) -> bool + Send + Sync + 'static,
) -> Div {
let state_for_click = Arc::clone(state);
let version_for_click = version.clone();
let mut t = crate::text::text(label)
.size(13.0)
.color(Color::WHITE)
.no_cursor();
if style.bold {
t = t.weight(crate::div::FontWeight::Bold);
}
if style.italic {
t = t.italic();
}
if style.underline {
t = t.underline();
}
if style.strikethrough {
t = t.strikethrough();
}
if style.monospace {
t = t.monospace();
}
div()
.w(28.0)
.h(24.0)
.items_center()
.justify_center()
.rounded(4.0)
.cursor_pointer()
.child(t)
.on_mouse_down(move |_| {
if let Ok(mut data) = state_for_click.lock() {
data.suppress_next_outer_click = true;
if on_click(&mut data) {
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
}
}
})
}
fn picker_button(
label: &str,
_tooltip: &str,
state: &RichTextState,
version: &State<u32>,
_theme: &RichTextTheme,
on_click: impl Fn(&mut super::state::RichTextData) + Send + Sync + 'static,
) -> Div {
let state_for_click = Arc::clone(state);
let version_for_click = version.clone();
div()
.w(28.0)
.h(24.0)
.items_center()
.justify_center()
.rounded(4.0)
.cursor_pointer()
.child(
crate::text::text(label)
.size(13.0)
.color(Color::WHITE)
.no_cursor(),
)
.on_mouse_down(move |_| {
if let Ok(mut data) = state_for_click.lock() {
data.suppress_next_outer_click = true;
on_click(&mut data);
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
}
})
}
fn divider() -> Div {
div()
.w(1.0)
.h(20.0)
.bg(Color::rgba(0.34, 0.34, 0.40, 1.0))
.ml(1.0)
.mr(1.0)
}
fn color_palette() -> &'static [(Color, &'static str)] {
const PALETTE: &[(Color, &str)] = &[
(
Color {
r: 0.92,
g: 0.92,
b: 0.95,
a: 1.0,
},
"Default",
),
(
Color {
r: 0.94,
g: 0.39,
b: 0.39,
a: 1.0,
},
"Red",
),
(
Color {
r: 0.97,
g: 0.59,
b: 0.20,
a: 1.0,
},
"Orange",
),
(
Color {
r: 0.96,
g: 0.85,
b: 0.40,
a: 1.0,
},
"Yellow",
),
(
Color {
r: 0.50,
g: 0.85,
b: 0.50,
a: 1.0,
},
"Green",
),
(
Color {
r: 0.40,
g: 0.78,
b: 1.00,
a: 1.0,
},
"Blue",
),
(
Color {
r: 0.66,
g: 0.55,
b: 1.00,
a: 1.0,
},
"Purple",
),
(
Color {
r: 0.55,
g: 0.55,
b: 0.65,
a: 1.0,
},
"Gray",
),
];
PALETTE
}
fn color_picker_row(state: &RichTextState, version: &State<u32>) -> Div {
let mut row = div().flex_row().items_center().gap_px(4.0);
for (color, _label) in color_palette() {
let state_for_click = Arc::clone(state);
let version_for_click = version.clone();
let c = *color;
row = row.child(
div()
.w(18.0)
.h(18.0)
.rounded(9.0)
.bg(c)
.border(1.0, Color::rgba(1.0, 1.0, 1.0, 0.18))
.cursor_pointer()
.on_mouse_down(move |_| {
if let Ok(mut data) = state_for_click.lock() {
data.suppress_next_outer_click = true;
if let Some(sel) = data.selection {
if !sel.is_empty() {
data.push_undo();
apply_mark_to_selection(&mut data.document, sel, Mark::Color(c));
}
}
data.active_format.color = Some(c);
data.picker = PickerState::None;
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
}
}),
);
}
row
}
fn link_prompt_row(
state: &RichTextState,
version: &State<u32>,
_theme: &RichTextTheme,
draft: &str,
) -> Div {
let placeholder = if draft.is_empty() {
"https://…"
} else {
draft
};
let placeholder_color = if draft.is_empty() {
Color::rgba(0.55, 0.55, 0.65, 1.0)
} else {
Color::WHITE
};
let state_for_apply = Arc::clone(state);
let version_for_apply = version.clone();
let state_for_remove = Arc::clone(state);
let version_for_remove = version.clone();
let state_for_cancel = Arc::clone(state);
let version_for_cancel = version.clone();
let state_for_input = Arc::clone(state);
let input_click = move |_: &crate::event_handler::EventContext| {
if let Ok(mut data) = state_for_input.lock() {
data.suppress_next_outer_click = true;
}
};
div()
.flex_row()
.items_center()
.gap_px(6.0)
.child(
div()
.h(22.0)
.padding_x_px(8.0)
.min_w(160.0)
.flex_grow()
.items_center()
.rounded(4.0)
.bg(Color::rgba(0.10, 0.10, 0.13, 1.0))
.border(1.0, Color::rgba(0.34, 0.34, 0.40, 1.0))
.cursor_text()
.child(
crate::text::text(placeholder)
.size(12.0)
.color(placeholder_color)
.no_cursor()
.no_wrap(),
)
.on_mouse_down(input_click),
)
.child(prompt_button(
"Apply",
state_for_apply,
version_for_apply,
|d| {
if let PickerState::Link { draft } = d.picker.clone() {
if !draft.is_empty() {
if let Some(sel) = d.selection {
if !sel.is_empty() {
d.push_undo();
apply_mark_to_selection(
&mut d.document,
sel,
Mark::Link(Some(draft)),
);
}
}
}
d.picker = PickerState::None;
}
},
))
.child(prompt_button(
"Remove",
state_for_remove,
version_for_remove,
|d| {
if let Some(sel) = d.selection {
if !sel.is_empty() {
d.push_undo();
apply_mark_to_selection(&mut d.document, sel, Mark::Link(None));
}
}
d.picker = PickerState::None;
},
))
.child(prompt_button(
"Cancel",
state_for_cancel,
version_for_cancel,
|d| {
d.picker = PickerState::None;
},
))
}
fn prompt_button(
label: &str,
state: RichTextState,
version: State<u32>,
on_click: impl Fn(&mut super::state::RichTextData) + Send + Sync + 'static,
) -> Div {
let w = match label {
"Apply" => 50.0,
"Remove" => 60.0,
"Cancel" => 56.0,
_ => 60.0,
};
div()
.w(w)
.h(22.0)
.items_center()
.justify_center()
.rounded(4.0)
.bg(Color::rgba(0.20, 0.20, 0.26, 1.0))
.cursor_pointer()
.child(
crate::text::text(label)
.size(11.0)
.color(Color::WHITE)
.no_cursor()
.no_wrap(),
)
.on_mouse_down(move |_| {
if let Ok(mut data) = state.lock() {
data.suppress_next_outer_click = true;
on_click(&mut data);
drop(data);
version.set(version.get().wrapping_add(1));
}
})
}
fn apply_mark_via(data: &mut super::state::RichTextData, mark: Mark) -> bool {
if let Some(sel) = data.selection {
if !sel.is_empty() {
data.push_undo();
let changed = apply_mark_to_selection(&mut data.document, sel, mark);
data.active_format = ActiveFormat::from_position(&data.document, data.cursor);
return changed;
}
}
false
}
fn existing_link(data: &super::state::RichTextData) -> Option<String> {
let sel = data.selection?;
let (start, _end) = sel.ordered();
let block = data.document.blocks.get(start.block)?;
let line = block.lines.get(start.line)?;
let byte = super::document::char_to_byte(&line.text, start.col);
line.spans
.iter()
.find(|s| s.start <= byte && byte < s.end)
.and_then(|s| s.link_url.clone())
}