winio 0.11.0

Single-threaded async GUI runtime based on compio.
Documentation
use std::{
    cell::RefCell,
    io,
    net::SocketAddr,
    ops::Deref,
    path::{Path, PathBuf},
    rc::Rc,
};

use axum::{http::Uri, response::IntoResponse};
use compio::{buf::buf_try, fs::File, io::AsyncReadAtExt, net::TcpListener, runtime::spawn};
use local_sync::oneshot;
use send_wrapper::SendWrapper;
use winio::prelude::*;

use crate::{Error, FailableWebView, Result};

pub struct MarkdownPage {
    window: Child<TabViewItem>,
    webview: Child<FailableWebView>,
    button: Child<Button>,
    label: Child<Label>,
    markdown_path: Rc<RefCell<PathBuf>>,
    addr: Option<SocketAddr>,
    shutdown_tx: Option<oneshot::Sender<()>>,
}

#[derive(Debug)]
pub enum MarkdownFetchStatus {
    Complete(String),
    Error(String),
}

#[derive(Debug)]
pub enum MarkdownPageEvent {
    ChooseFile,
    MessageBox(MessageBox),
}

#[derive(Debug)]
pub enum MarkdownPageMessage {
    Noop,
    SetAddr(SocketAddr),
    ChooseFile,
    OpenFile(PathBuf),
    Fetch(MarkdownFetchStatus),
}

impl Component for MarkdownPage {
    type Error = Error;
    type Event = MarkdownPageEvent;
    type Init<'a> = ();
    type Message = MarkdownPageMessage;

    async fn init(_init: Self::Init<'_>, sender: &ComponentSender<Self>) -> Result<Self> {
        let path = "README.md";
        init! {
            window: TabViewItem = (()) => {
                text: "Markdown",
            },
            webview: FailableWebView = (&window),
            button: Button = (&window) => {
                text: "Choose file...",
            },
            label: Label = (&window) => {
                text: path,
                halign: HAlign::Center,
            },
        }

        let (shutdown_tx, shutdown_rx) = oneshot::channel();
        let markdown_path = Rc::new(RefCell::new(PathBuf::from(path)));
        {
            let markdown_path = SendWrapper::new(markdown_path.clone());
            let sender = sender.clone();
            spawn(async move {
                let listener = TcpListener::bind("127.0.0.1:0").await?;
                let serve = cyper_axum::serve(
                    listener,
                    axum::routing::get(move |req: Uri| {
                        SendWrapper::new(async move {
                            let path = req.path().trim_start_matches('/').to_string();
                            let path = markdown_path
                                .borrow()
                                .parent()
                                .unwrap_or_else(|| Path::new("."))
                                .join(path);
                            match read_file(&path).await {
                                Ok(data) => (axum::http::StatusCode::OK, data).into_response(),
                                Err(_) => (
                                    axum::http::StatusCode::NOT_FOUND,
                                    b"File not found".to_vec(),
                                )
                                    .into_response(),
                            }
                        })
                    }),
                )
                .with_graceful_shutdown(async move {
                    shutdown_rx.await.ok();
                });
                let local_addr = serve.local_addr()?;
                sender.post(MarkdownPageMessage::SetAddr(local_addr));
                serve.await
            })
            .detach();
        }

        let path = path.to_string();
        spawn(fetch(path, sender.clone())).detach();

        Ok(Self {
            window,
            webview,
            button,
            label,
            markdown_path,
            addr: None,
            shutdown_tx: Some(shutdown_tx),
        })
    }

    async fn start(&mut self, sender: &ComponentSender<Self>) -> ! {
        start! {
            sender, default: MarkdownPageMessage::Noop,
            self.button => {
                ButtonEvent::Click => MarkdownPageMessage::ChooseFile,
            },
        }
    }

    async fn update_children(&mut self) -> Result<bool> {
        update_children!(self.window, self.webview, self.button, self.label)
    }

    async fn update(
        &mut self,
        message: Self::Message,
        sender: &ComponentSender<Self>,
    ) -> Result<bool> {
        match message {
            MarkdownPageMessage::Noop => Ok(false),
            MarkdownPageMessage::SetAddr(addr) => {
                self.addr = Some(addr);
                Ok(false)
            }
            MarkdownPageMessage::ChooseFile => {
                sender.output(MarkdownPageEvent::ChooseFile);
                Ok(false)
            }
            MarkdownPageMessage::OpenFile(p) => {
                self.label.set_text(p.to_str().unwrap_or_default())?;
                *self.markdown_path.borrow_mut() = p.clone();
                spawn(fetch(p, sender.clone())).detach();
                Ok(true)
            }
            MarkdownPageMessage::Fetch(status) => {
                match status {
                    MarkdownFetchStatus::Complete(text) => {
                        let mut output = String::new();
                        pulldown_cmark::html::push_html(
                            &mut output,
                            pulldown_cmark::Parser::new_ext(&text, pulldown_cmark::Options::all())
                                .map(|event| match event {
                                    pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image {
                                        link_type,
                                        dest_url,
                                        title,
                                        id,
                                    }) => {
                                        let dest_url = if dest_url.starts_with("http://")
                                            || dest_url.starts_with("https://")
                                        {
                                            dest_url.to_string()
                                        } else if let Some(addr) = self.addr {
                                            format!("http://{}/{}", addr, dest_url)
                                        } else {
                                            dest_url.to_string()
                                        };
                                        pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image {
                                            link_type,
                                            dest_url: dest_url.into(),
                                            title,
                                            id,
                                        })
                                    }
                                    _ => event,
                                }),
                        );
                        let html = format!(
                            r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Markdown Preview</title>
</head>
<body>
    <article>
        {output}
    </article>
</body>
</html>
"#
                        );
                        self.webview.navigate_to_string(html)?;
                    }
                    MarkdownFetchStatus::Error(err) => {
                        sender.output(MarkdownPageEvent::MessageBox(
                            MessageBox::new()
                                .title("Error")
                                .message("Failed to load markdown file.")
                                .instruction(&err)
                                .style(MessageBoxStyle::Error)
                                .buttons(MessageBoxButton::Ok),
                        ));
                    }
                }
                Ok(true)
            }
        }
    }

    fn render(&mut self, _sender: &ComponentSender<Self>) -> Result<()> {
        let csize = self.window.size()?;

        {
            let mut panel = layout! {
                StackPanel::new(Orient::Vertical),
                self.label, self.button,
                self.webview => { grow: true }
            };
            panel.set_size(csize)?;
        }
        Ok(())
    }
}

impl Deref for MarkdownPage {
    type Target = TabViewItem;

    fn deref(&self) -> &Self::Target {
        &self.window
    }
}

impl Drop for MarkdownPage {
    fn drop(&mut self) {
        if let Some(tx) = self.shutdown_tx.take() {
            let _ = tx.send(());
        }
    }
}

async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
    let file = File::open(path).await?;
    let (_, buffer) = buf_try!(@try file.read_to_end_at(vec![], 0).await);
    Ok(buffer)
}

async fn read_file_content(path: impl AsRef<Path>) -> io::Result<String> {
    let bytes = read_file(path).await?;
    String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

async fn fetch(path: impl AsRef<Path>, sender: ComponentSender<MarkdownPage>) {
    let status = match read_file_content(path).await {
        Ok(text) => MarkdownFetchStatus::Complete(text),
        Err(e) => MarkdownFetchStatus::Error(format!("{e:?}")),
    };
    sender.post(MarkdownPageMessage::Fetch(status));
}