1use std::cell::RefCell;
11use std::io;
12use std::rc::{Rc, Weak};
13
14use rich::{Segment, Style};
15use rich_intuituive::interact::{Button, Key, KeyCode, Mouse, MouseKind};
16use rich_intuituive::widget::{Canvas, DrawCx, EventCx, Used, Widget, WidgetEvent, widget};
17use rich_intuituive::{Ctx, Node, Signal, signal, watch};
18
19use crate::host::Notify;
20use crate::pixels::{Pixels, half_blocks};
21use crate::program::ProgramEngine;
22use crate::wake::{Fed, connect};
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum WebInput {
27 Key(Key),
28 Mouse(Mouse),
30 Paste(String),
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum WebFrame {
36 Cells {
38 lines: Vec<Vec<Segment>>,
39 cursor: Option<(u16, u16)>,
40 },
41 Pixels(Pixels),
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct PageState {
48 pub url: String,
49 pub title: String,
50 pub loading: bool,
51 pub can_go_back: bool,
52 pub can_go_forward: bool,
53 pub error: Option<String>,
55}
56
57pub trait WebEngine {
65 fn open(&mut self, url: &str) -> io::Result<()>;
67 fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()>;
69 fn input(&mut self, input: WebInput) -> io::Result<()>;
70 fn back(&mut self) -> io::Result<()>;
71 fn forward(&mut self) -> io::Result<()>;
72 fn reload(&mut self) -> io::Result<()>;
73 fn poll(&mut self) -> Option<WebFrame>;
75 fn state(&self) -> PageState;
77 fn set_notify(&mut self, notify: Notify);
79}
80
81pub struct WebHandle {
84 address: Signal<String>,
85 title: Signal<String>,
86 loading: Signal<bool>,
87 can_go_back: Signal<bool>,
88 can_go_forward: Signal<bool>,
89 error: Signal<Option<String>>,
90 view: Signal<Weak<RefCell<ViewState>>>,
91}
92
93impl Clone for WebHandle {
94 fn clone(&self) -> Self {
95 *self
96 }
97}
98
99impl Copy for WebHandle {}
100
101impl WebHandle {
102 pub fn address(&self) -> Signal<String> {
104 self.address
105 }
106
107 pub fn title(&self) -> Signal<String> {
108 self.title
109 }
110
111 pub fn loading(&self) -> Signal<bool> {
112 self.loading
113 }
114
115 pub fn can_go_back(&self) -> Signal<bool> {
116 self.can_go_back
117 }
118
119 pub fn can_go_forward(&self) -> Signal<bool> {
120 self.can_go_forward
121 }
122
123 pub fn error(&self) -> Signal<Option<String>> {
125 self.error
126 }
127
128 fn with_view(&self, f: impl FnOnce(&mut ViewState)) {
129 if let Some(view) = self.view.with_untracked(Weak::upgrade) {
130 let mut view = view.borrow_mut();
131 f(&mut view);
132 view.sync();
133 }
134 }
135
136 pub fn open(&self, url: impl Into<String>) {
138 self.address.set(url.into());
139 }
140
141 pub fn back(&self) {
142 self.with_view(|view| view.act(|engine| engine.back()));
143 }
144
145 pub fn forward(&self) {
146 self.with_view(|view| view.act(|engine| engine.forward()));
147 }
148
149 pub fn reload(&self) {
150 self.with_view(|view| view.act(|engine| engine.reload()));
151 }
152}
153
154struct ViewState {
155 engine: Box<dyn WebEngine>,
156 handle: Option<WebHandle>,
157 wake: Signal<u64>,
159 frame: Option<WebFrame>,
160 size: Option<(u16, u16)>,
162 first: Option<String>,
164 current: String,
166 failure: Option<String>,
168 notify: Option<Notify>,
170}
171
172impl ViewState {
173 fn act(&mut self, f: impl FnOnce(&mut dyn WebEngine) -> io::Result<()>) {
177 if self.size.is_none() {
178 return;
179 }
180 self.failure = f(self.engine.as_mut()).err().map(|e| e.to_string());
181 if self.failure.is_some()
185 && let Some(notify) = &self.notify
186 {
187 notify();
188 }
189 }
190
191 fn sync(&mut self) {
193 let Some(handle) = self.handle else {
194 return;
195 };
196 let state = self.engine.state();
197 if !state.url.is_empty() {
198 self.current = state.url.clone();
199 handle.address.set(state.url);
200 }
201 handle.title.set(state.title);
202 handle.loading.set(state.loading);
203 handle.can_go_back.set(state.can_go_back);
204 handle.can_go_forward.set(state.can_go_forward);
205 handle
206 .error
207 .set(state.error.or_else(|| self.failure.clone()));
208 }
209
210 fn fit(&mut self, columns: u16, rows: u16) {
213 if columns == 0 || rows == 0 || self.size == Some((columns, rows)) {
214 return;
215 }
216 self.size = Some((columns, rows));
217 self.act(|engine| engine.resize(columns, rows));
218 if let Some(url) = self.first.take() {
219 self.current = url.clone();
220 self.act(|engine| engine.open(&url));
221 }
222 }
223}
224
225impl Fed for ViewState {
226 fn install(&mut self, notify: Notify) {
227 self.notify = Some(notify.clone());
228 self.engine.set_notify(notify);
229 }
230
231 fn pump(this: &Rc<RefCell<Self>>, _cx: &mut Ctx) {
232 let mut view = this.borrow_mut();
233 if let Some(frame) = view.engine.poll() {
234 view.frame = Some(frame);
235 view.wake.update(|n| *n = n.wrapping_add(1));
236 }
237 view.sync();
238 }
239}
240
241pub struct WebView {
252 state: Rc<RefCell<ViewState>>,
253 handle: WebHandle,
254 bar: bool,
255 release: Vec<Key>,
256}
257
258pub fn web_view(url: impl Into<String>) -> WebView {
265 web_view_with(ProgramEngine::detect(), url)
266}
267
268pub fn web_view_with(engine: impl WebEngine + 'static, url: impl Into<String>) -> WebView {
287 let url = url.into();
288 let state = Rc::new(RefCell::new(ViewState {
289 engine: Box::new(engine),
290 handle: None,
291 wake: signal(0),
292 frame: None,
293 size: None,
294 first: Some(url.clone()),
295 current: url.clone(),
296 failure: None,
297 notify: None,
298 }));
299 let handle = WebHandle {
300 address: signal(url),
301 title: signal(String::new()),
302 loading: signal(false),
303 can_go_back: signal(false),
304 can_go_forward: signal(false),
305 error: signal(None),
306 view: signal(Rc::downgrade(&state)),
307 };
308 state.borrow_mut().handle = Some(handle);
309 connect(&state);
310 let weak = Rc::downgrade(&state);
313 watch(
314 move || handle.address.get(),
315 move |url, _| {
316 let Some(view) = weak.upgrade() else {
317 return;
318 };
319 let mut view = view.borrow_mut();
320 if url == view.current {
321 return;
322 }
323 view.current = url.clone();
324 if view.size.is_none() {
325 view.first = Some(url);
326 return;
327 }
328 view.act(|engine| engine.open(&url));
329 },
330 );
331 WebView {
332 state,
333 handle,
334 bar: true,
335 release: Vec::new(),
336 }
337}
338
339impl WebView {
340 pub fn handle(&self) -> WebHandle {
342 self.handle
343 }
344
345 #[must_use]
348 pub fn address_bar(mut self, on: bool) -> WebView {
349 self.bar = on;
350 self
351 }
352
353 #[must_use]
356 pub fn release_keys(mut self, keys: &str) -> WebView {
357 self.release
358 .extend(keys.split_whitespace().filter_map(Key::parse));
359 self
360 }
361
362 pub fn node(self) -> Node {
364 widget(ViewWidget {
365 state: self.state,
366 handle: self.handle,
367 bar: self.bar,
368 release: self.release,
369 editing: None,
370 caret: None,
371 })
372 }
373}
374
375impl From<WebView> for Node {
376 fn from(view: WebView) -> Node {
377 view.node()
378 }
379}
380
381const BACK: u16 = 1;
383const FORWARD: u16 = 3;
384const RELOAD: u16 = 5;
385const ADDRESS: u16 = 7;
386
387struct ViewWidget {
388 state: Rc<RefCell<ViewState>>,
389 handle: WebHandle,
390 bar: bool,
391 release: Vec<Key>,
392 editing: Option<String>,
394 caret: Option<(u16, u16)>,
395}
396
397impl ViewWidget {
398 fn top(&self) -> u16 {
399 self.bar as u16
400 }
401
402 fn draw_bar(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
403 let width = canvas.width();
404 let bar = cx.style("web.bar", "on grey15");
405 let off = bar.combine(&Style::parse("dim").unwrap_or_default());
406 canvas.fill(0, 0, width, 1, Some(&bar));
407 let back = self.handle.can_go_back.get();
408 let forward = self.handle.can_go_forward.get();
409 canvas.print(BACK, 0, "←", Some(if back { &bar } else { &off }));
410 canvas.print(FORWARD, 0, "→", Some(if forward { &bar } else { &off }));
411 canvas.print(RELOAD, 0, "↻", Some(&bar));
412 let loading = self.handle.loading.get();
413 let room = width.saturating_sub(ADDRESS + 2 * loading as u16);
414 match &self.editing {
415 Some(text) => {
416 let field = cx.style("web.address.edit", "on grey30");
417 canvas.fill(ADDRESS, 0, room, 1, Some(&field));
418 let shown = tail(text, room.saturating_sub(1));
420 canvas.print(ADDRESS, 0, &shown, Some(&field));
421 let at = ADDRESS + rich::cells::cell_len(&shown) as u16;
422 self.caret = Some((at.min(width.saturating_sub(1)), 0));
423 }
424 None => {
425 let address = self.handle.address.get();
426 let line = crate::web::fit(&address, room);
427 canvas.print(ADDRESS, 0, &line, Some(&bar));
428 }
429 }
430 if loading {
431 canvas.print(width.saturating_sub(2), 0, "…", Some(&bar));
432 }
433 }
434
435 fn input(&self, input: WebInput) {
436 let mut view = self.state.borrow_mut();
437 view.act(|engine| engine.input(input));
438 }
439
440 fn edit(&mut self, cx: &mut EventCx, key: Key) -> Used {
441 let Some(text) = &mut self.editing else {
442 return Used::No;
443 };
444 match key.code {
445 KeyCode::Enter => {
446 let url = std::mem::take(text).trim().to_string();
447 self.editing = None;
448 if !url.is_empty() {
449 self.handle.open(with_scheme(&url));
450 }
451 }
452 KeyCode::Escape => self.editing = None,
453 KeyCode::Backspace => {
454 text.pop();
455 }
456 KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => text.push(c),
457 _ => {}
458 }
459 cx.redraw();
460 Used::Yes
461 }
462}
463
464impl Widget for ViewWidget {
465 fn name(&self) -> &'static str {
466 "web view"
467 }
468
469 fn describe(&self) -> Option<String> {
470 Some(self.state.borrow().current.clone())
471 }
472
473 fn draw(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
474 let wake = self.state.borrow().wake;
475 wake.get();
476 self.caret = None;
477 let (width, height) = (canvas.width(), canvas.height());
478 let top = self.top();
479 if self.bar && height > 0 {
480 self.draw_bar(cx, canvas);
481 }
482 let rows = height.saturating_sub(top);
483 let mut view = self.state.borrow_mut();
484 view.fit(width, rows);
485 if rows == 0 {
486 return;
487 }
488 let error = self.handle.error.get().or_else(|| view.failure.clone());
489 match &view.frame {
490 Some(WebFrame::Cells { lines, cursor }) => {
491 canvas.lines_at(0, top, width, rows, lines);
492 if self.editing.is_none() {
493 self.caret = cursor.map(|(x, y)| (x, y + top));
494 }
495 }
496 Some(WebFrame::Pixels(pixels)) => {
497 canvas.lines_at(0, top, width, rows, &half_blocks(pixels, width, rows));
498 }
499 None => {
500 canvas.clear(0, top, width, rows);
501 let note = match &error {
502 Some(error) => format!("[red]{}", error.replace('[', "\\[")),
503 None => "[dim]loading…".to_string(),
504 };
505 canvas.markup(cx.console(), 1, top, width.saturating_sub(1), ¬e, None);
506 }
507 }
508 if let (Some(error), Some(_)) = (error, &view.frame) {
509 let line = format!("[reverse red] {} ", error.replace('[', "\\["));
510 canvas.markup(cx.console(), 0, height - 1, width, &line, None);
511 }
512 }
513
514 fn event(&mut self, cx: &mut EventCx, event: &WidgetEvent) -> Used {
515 let top = self.top();
516 match event {
517 WidgetEvent::Key(key) if self.release.contains(key) && self.editing.is_none() => {
518 Used::No
519 }
520 WidgetEvent::Key(key) => {
521 if self.editing.is_some() {
522 return self.edit(cx, *key);
523 }
524 let alt = key.modifiers.alt && !key.modifiers.ctrl;
525 match key.code {
526 KeyCode::Char('l') if key.modifiers.ctrl && self.bar => {
527 self.editing = Some(self.handle.address.get_untracked());
528 }
529 KeyCode::Left if alt => self.handle.back(),
530 KeyCode::Right if alt => self.handle.forward(),
531 KeyCode::F(5) => self.handle.reload(),
532 _ => self.input(WebInput::Key(*key)),
533 }
534 cx.redraw();
535 Used::Yes
536 }
537 WidgetEvent::Paste(text) => {
538 match &mut self.editing {
539 Some(editing) => editing.push_str(text.trim()),
540 None => self.input(WebInput::Paste(text.clone())),
541 }
542 cx.redraw();
543 Used::Yes
544 }
545 WidgetEvent::Mouse(mouse) if self.bar && mouse.row == 0 => {
546 if mouse.kind != MouseKind::Down(Button::Left) {
547 return Used::Yes;
548 }
549 match mouse.column {
550 c if c == BACK => self.handle.back(),
551 c if c == FORWARD => self.handle.forward(),
552 c if c == RELOAD => self.handle.reload(),
553 c if c >= ADDRESS && self.editing.is_none() => {
554 self.editing = Some(self.handle.address.get_untracked());
555 }
556 _ => {}
557 }
558 cx.redraw();
559 Used::Yes
560 }
561 WidgetEvent::Mouse(mouse) => {
562 let mut page = *mouse;
563 page.row -= top;
564 self.input(WebInput::Mouse(page));
565 Used::Yes
566 }
567 WidgetEvent::Focus(false) if self.editing.is_some() => {
568 self.editing = None;
569 cx.redraw();
570 Used::Yes
571 }
572 _ => Used::No,
573 }
574 }
575
576 fn focusable(&self) -> bool {
577 true
578 }
579
580 fn caret(&self) -> Option<(u16, u16)> {
581 self.caret
582 }
583}
584
585pub(crate) fn fit(text: &str, width: u16) -> String {
587 let width = width as usize;
588 if rich::cells::cell_len(text) <= width {
589 return text.to_string();
590 }
591 if width == 0 {
592 return String::new();
593 }
594 let mut out = String::new();
595 let mut used = 0;
596 for c in text.chars() {
597 let w = rich::cells::cell_len(c.encode_utf8(&mut [0u8; 4]));
598 if used + w + 1 > width {
599 break;
600 }
601 out.push(c);
602 used += w;
603 }
604 out.push('…');
605 out
606}
607
608fn tail(text: &str, width: u16) -> String {
610 let mut out: Vec<char> = Vec::new();
611 let mut used = 0;
612 for c in text.chars().rev() {
613 let w = rich::cells::cell_len(c.encode_utf8(&mut [0u8; 4]));
614 if used + w > width as usize {
615 break;
616 }
617 out.push(c);
618 used += w;
619 }
620 out.into_iter().rev().collect()
621}
622
623fn with_scheme(url: &str) -> String {
625 if url.contains("://") || url.starts_with("about:") || url.starts_with("file:") {
626 url.to_string()
627 } else {
628 format!("https://{url}")
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn addresses_fit_and_get_a_scheme() {
638 assert_eq!(fit("https://example.com", 10), "https://e…");
639 assert_eq!(fit("short", 10), "short");
640 assert_eq!(tail("abcdef", 3), "def");
641 assert_eq!(with_scheme("example.com"), "https://example.com");
642 assert_eq!(with_scheme("http://x"), "http://x");
643 assert_eq!(with_scheme("about:blank"), "about:blank");
644 }
645}