sveltui_code/
code_editor.rs1use crate::binding::CodeBinding;
2use crate::buffer::{with_code_buffer, with_code_buffer_mut};
3use crate::highlight::highlight_text;
4use crossterm::event::KeyEvent;
5use crossterm::style::Color;
6use std::cell::RefCell;
7use std::collections::HashMap;
8use std::rc::Rc;
9use sveltui::element::scroll::{get_scroll, get_viewport, set_scroll};
10use sveltui::layout::Dimension;
11use sveltui::prelude::*;
12
13thread_local! {
14 static LAST_SYNCED: RefCell<HashMap<FocusId, String>> = RefCell::new(HashMap::new());
15}
16
17#[derive(Props)]
18pub struct CodeEditorProps {
19 #[prop(optional)]
20 pub id: Option<FocusId>,
21 #[prop(optional)]
22 pub value: Option<CodeBinding>,
23 #[prop(optional)]
24 pub set_value: Option<WriteSignal<String>>,
25 #[prop(default = String::from("// Type code here..."))]
26 pub placeholder: String,
27 #[prop(optional)]
28 pub language: Option<String>,
29 #[prop(optional)]
30 pub theme: Option<String>,
31 #[prop(default = true)]
32 pub show_line_numbers: bool,
33 #[prop(default = Dimension::Auto)]
34 pub width: Dimension,
35 #[prop(default = Dimension::Auto)]
36 pub height: Dimension,
37 #[prop(optional)]
38 pub title: Option<String>,
39 #[prop(default = 4)]
40 pub tab_size: usize,
41 #[prop(default = true)]
42 pub autofocus: bool,
43 #[prop(default = CursorStyle::Line)]
44 pub cursor_style: CursorStyle,
45}
46
47#[component]
48pub fn CodeEditor(props: CodeEditorProps) -> impl IntoNode {
49 let id = props.id.unwrap_or_default();
50 let focused = is_focused(id);
51
52 let border_style = if focused {
53 BorderStyle::Double
54 } else {
55 BorderStyle::Single
56 };
57
58 let border_color = if focused {
59 Color::White
60 } else {
61 Color::DarkGrey
62 };
63
64 let set_value = props
65 .set_value
66 .or_else(|| props.value.as_ref().and_then(|b| b.write_signal()));
67
68 let total_lines = with_code_buffer_mut(id, |buf| {
69 buf.tab_size = props.tab_size;
70 let last = LAST_SYNCED.with(|m| m.borrow().get(&id).cloned());
71
72 if let Some(ref binding) = props.value {
73 let current = binding.get_text();
74 match last {
75 None => {
76 if !current.is_empty() {
77 buf.set_content(¤t);
78 }
79 LAST_SYNCED.with(|m| m.borrow_mut().insert(id, current));
80 }
81 Some(ref prev) if prev != ¤t => {
82 buf.set_content(¤t);
83 LAST_SYNCED.with(|m| m.borrow_mut().insert(id, current));
84 }
85 _ => {}
86 }
87 }
88 buf.line_count()
89 });
90
91 let digits = format!("{}", total_lines.max(1)).len().max(2);
92 let gutter_width = if props.show_line_numbers {
93 digits + 3
94 } else {
95 0
96 };
97
98 let (_rendered_vw, rendered_vh) = get_viewport(id).unwrap_or_else(|| {
99 let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
100 let default_h = match props.height {
101 Dimension::Length(h) => (h.round() as u16).saturating_sub(2),
102 _ => term_h.saturating_sub(2),
103 };
104 let default_w = match props.width {
105 Dimension::Length(w) => (w.round() as u16).saturating_sub(4 + gutter_width as u16),
106 _ => term_w.saturating_sub(4 + gutter_width as u16),
107 };
108 (default_w.max(1), default_h.max(1))
109 });
110
111 let visible_height = (rendered_vh as usize).max(1);
112
113 let (is_empty, full_content) = with_code_buffer(id, |buf| (buf.is_empty(), buf.content()));
114
115 let all_line_spans = highlight_text(
116 &full_content,
117 props.language.as_deref(),
118 props.theme.as_deref(),
119 );
120
121 let show_line_numbers = props.show_line_numbers;
122
123 let rendered_line_nodes: Vec<VNode> = if is_empty {
124 let mut row = ViewProps::builder()
125 .flex_direction(FlexDirection::Row)
126 .align_items(AlignItems::Center)
127 .justify_content(JustifyContent::FlexStart);
128 if show_line_numbers {
129 row = row.child(TextSpan(
130 TextSpanProps::builder()
131 .foreground_color(Color::DarkGrey)
132 .content(format!("{:>width$} │ ", 1, width = digits))
133 .build(),
134 ));
135 }
136 row = row.child(TextSpan(
137 TextSpanProps::builder()
138 .foreground_color(Color::DarkGrey)
139 .content(props.placeholder.clone())
140 .build(),
141 ));
142 vec![View(row.build()).into_node()]
143 } else {
144 all_line_spans
145 .into_iter()
146 .enumerate()
147 .map(|(offset, spans)| {
148 let line_num = offset + 1;
149 let mut row = ViewProps::builder()
150 .flex_direction(FlexDirection::Row)
151 .align_items(AlignItems::Center)
152 .justify_content(JustifyContent::FlexStart);
153 if show_line_numbers {
154 row = row.child(TextSpan(
155 TextSpanProps::builder()
156 .foreground_color(Color::DarkGrey)
157 .content(format!("{:>width$} │ ", line_num, width = digits))
158 .build(),
159 ));
160 }
161 if spans.is_empty() {
162 row = row.child(TextSpan(
163 TextSpanProps::builder()
164 .foreground_color(Color::DarkGrey)
165 .content(" ")
166 .build(),
167 ));
168 } else {
169 for span in spans {
170 row = row.child(TextSpan(
171 TextSpanProps::builder()
172 .foreground_color(span.fg)
173 .bold(span.bold)
174 .italic(span.italic)
175 .underline(span.underline)
176 .content(span.text)
177 .build(),
178 ));
179 }
180 }
181 View(row.build()).into_node()
182 })
183 .collect()
184 };
185
186 let on_click_at: Rc<dyn Fn(u16, u16)> = Rc::new(move |rel_x: u16, rel_y: u16| {
187 let (scroll_x, scroll_y) = get_scroll(id);
188 let click_col =
189 (rel_x as i32 + scroll_x as i32 - (2 + gutter_width as i32)).max(0) as usize;
190 let click_row = (rel_y as i32 + scroll_y as i32 - 1).max(0) as usize;
191 with_code_buffer_mut(id, |buf| {
192 buf.set_cursor_pos(click_row, click_col);
193 });
194 });
195
196 let on_key: KeyHandler = Rc::new(move |key: KeyEvent, _ctx: &mut EventContext| {
197 if !with_code_buffer(id, |buf| buf.accepts_key(&key)) {
198 return EventResult::Ignored;
199 }
200
201 let (vw, vh) = get_viewport(id).unwrap_or_else(|| {
202 let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
203 (
204 term_w.saturating_sub(4 + gutter_width as u16).max(1),
205 term_h.saturating_sub(2).max(1),
206 )
207 });
208 let cur_visible_w = ((vw as usize).saturating_sub(gutter_width)).max(1);
209 let cur_visible_h = (vh as usize).max(1);
210
211 with_code_buffer_mut(id, |buf| {
212 let modified = buf.handle_key(&key);
213 let (mut sx, mut sy) = get_scroll(id);
214
215 if (buf.cursor_row as u16) < sy {
216 sy = buf.cursor_row as u16;
217 } else if buf.cursor_row >= (sy as usize) + cur_visible_h {
218 sy = (buf.cursor_row.saturating_sub(cur_visible_h) + 1) as u16;
219 }
220
221 if (buf.cursor_col as u16) < sx {
222 sx = buf.cursor_col as u16;
223 } else if buf.cursor_col >= (sx as usize) + cur_visible_w {
224 sx = (buf.cursor_col.saturating_sub(cur_visible_w) + 1) as u16;
225 }
226
227 set_scroll(id, (sx, sy));
228
229 if modified {
230 let text = buf.content();
231 LAST_SYNCED.with(|m| m.borrow_mut().insert(id, text.clone()));
232 if let Some(sv) = set_value {
233 sv.set(text);
234 }
235 }
236 });
237 EventResult::Handled
238 });
239
240 let (scroll_x, scroll_y) = get_scroll(id);
241 let (cur_row, cur_col) = with_code_buffer(id, |buf| (buf.cursor_row, buf.cursor_col));
242 let rel_row = (cur_row as i32) - (scroll_y as i32);
243 let rel_col = (gutter_width as i32 + cur_col as i32) - (scroll_x as i32);
244
245 let cursor_offset =
246 if focused && rel_row >= 0 && rel_col >= 0 && (rel_row as usize) < visible_height {
247 Some((rel_col as u16, rel_row as u16))
248 } else {
249 None
250 };
251
252 let cursor_style = if focused {
253 Some(props.cursor_style)
254 } else {
255 None
256 };
257
258 let title_str = props
259 .title
260 .clone()
261 .unwrap_or_else(|| props.placeholder.clone());
262
263 let focus_scope = if props.autofocus {
264 Some(FocusScope {
265 contain_tab: false,
266 initial_focus: InitialFocus::Container,
267 })
268 } else {
269 None
270 };
271
272 view! {
273 <Pressable
274 focus_id=id
275 tab_stop=true
276 opt_focus_scope=focus_scope
277 semantics=Semantics::new(Role::Textbox, title_str)
278 on_click_at=on_click_at
279 on_key=on_key
280 >
281 <View
282 border=border_style
283 border_color=border_color
284 padding=Padding::symmetric(1, 0)
285 width=props.width
286 height=props.height
287 flex_direction=FlexDirection::Column
288 align_items=AlignItems::FlexStart
289 justify_content=JustifyContent::FlexStart
290 overflow_x=Overflow::Scroll
291 overflow_y=Overflow::Scroll
292 scroll_id=id
293 opt_title=props.title
294 opt_cursor_offset=cursor_offset
295 opt_cursor_style=cursor_style
296 >
297 {#each rendered_line_nodes as line_node}
298 {line_node}
299 {/each}
300 </View>
301 </Pressable>
302 }
303}