diff --git a/docs/coding/.pages b/docs/coding/.pages index 6d8b16571..6d0a332e9 100644 --- a/docs/coding/.pages +++ b/docs/coding/.pages @@ -5,7 +5,9 @@ nav: - Python: - 语法: python - matplotlib: matplotlib - - Rust: rust + - Rust: + - 语法: rust + - GUI: rust-gui - 机器学习: machine-learning - 数据结构: dsa - 系统配置: configuration diff --git a/docs/coding/index.md b/docs/coding/index.md index f1fabea42..959b605da 100644 --- a/docs/coding/index.md +++ b/docs/coding/index.md @@ -13,6 +13,7 @@ hide: * [Python](python/index.md) * [matplotlib](matplotlib/index.md) * [Rust](rust/index.md) + * [GUI](rust-gui/index.md) * [机器学习](machine-learning/index.md) * [数据结构](dsa/index.md) * [系统配置](configuration/index.md) diff --git a/docs/coding/rust-gui/.pages b/docs/coding/rust-gui/.pages new file mode 100644 index 000000000..f00cf9a0c --- /dev/null +++ b/docs/coding/rust-gui/.pages @@ -0,0 +1,13 @@ +nav: + - index.md + - Text editor step by step: + - 1. Hello, World!: 1-hello-world.md + - 2. Multi-line input: 2-multi-line-input.md + - 3. Theme and cursor indicator: 3-theme-and-cursor-indicator.md + - 4. Async file loading: 4-async-file-loading.md + - 5. File picker: 5-file-picker.md + - 6. File path indicator: 6-file-path-indicator.md + - 7. New and Save: 7-new-and-save.md + - 8. Button Prettify: 8-button-prettify.md + - 9. Syntax Highlighting: 9-syntax-highlighting.md + - 10. Misc: 10-misc.md diff --git a/docs/coding/rust-gui/1-hello-world.md b/docs/coding/rust-gui/1-hello-world.md new file mode 100644 index 000000000..7b945e910 --- /dev/null +++ b/docs/coding/rust-gui/1-hello-world.md @@ -0,0 +1,95 @@ +# Hello World App + +Keywords: `iced::Sandbox`, `iced::Settings`, `iced::widget::text` + +## 创建项目 + +首先,创建一个新的 Rust 项目,并添加对`iced`库的依赖。 + +```bash +cargo new text-editor +cd text-editor +cargo add iced +``` + +此时的`main.rs`文件内容如下: + +```rust +fn main() { + println!("Hello, world!"); +} +``` + +## 创建GUI应用 + +`iced`的GUI类有`Application`和`Sandbox`两种。其中`Sandbox`是一个简化版的`Application`,提供了一些默认的行为。`Sandbox`特性包含如下方法: + +```rust hl_lines="2 3 4 5 6" +pub trait Sandbox { + type Message: std::fmt::Debug + Send; + fn new() -> Self; + fn title(&self) -> String; + fn update(&mut self, message: Self::Message); + fn view(&self) -> Element<'_, Self::Message>; + fn theme(&self) -> Theme { + Theme::default() + } + fn style(&self) -> theme::Application { + theme::Application::default() + } + fn scale_factor(&self) -> f64 { + 1.0 + } + fn run(settings: Settings<()>) -> Result<(), Error> + where + Self: 'static + Sized, + { + ::run(settings) + } +} +``` + +为了使用`Sandbox`,我们需要实现其中未实现的方法,即`Message`、`new`、`title`、`update`和`view`。 + +* `Message`是一个枚举类型,用于定义应用会产生的消息类型,需要实现`std::fmt::Debug`和`Send`特性。 + + ```rust + #[derive(Debug)] // Inherit the Debug trait + enum Message {} // No message required + ``` + +* `new`方法用于创建一个新的`Sandbox`实例,初始化实例状态,一般情况下直接返回`Self`即可。 + + ```rust + fn new() -> Self { + Self + } + ``` + +* `title`方法用于返回GUI窗口的标题。 + + ```rust + fn title(&self) -> String { + String::from("A text editor") + } + ``` + +* `update`方法和`view`方法共同组成应用的消息循环:`update`方法用于处理消息,更新应用状态,`view`方法用于在状态更新后更新应用界面。此处我们在`view`方法中放置一个文本控件。 + + ```rust + fn update(&mut self, message: Self::Message) { + match message { + // No message to handle + } + } + + fn view(&self) -> Element<'_, Self::Message> { + text("Hello, world!").into() + } + ``` + +通过`Sandbox::run`方法启动应用,该方法返回`Result<(), Error>`类型,可以直接作为`main`函数的返回值。 + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/1.rs') }} diff --git a/docs/coding/rust-gui/10-misc.md b/docs/coding/rust-gui/10-misc.md new file mode 100644 index 000000000..1bc70c584 --- /dev/null +++ b/docs/coding/rust-gui/10-misc.md @@ -0,0 +1,97 @@ +# Miscellaneous + +本节添加一些额外功能。 + +首先,当文件没有被修改时,可以设置禁用保存按钮。这样可以避免用户误操作。在`on_press_maybe`中传入`None`即可。同时,可以根据文件的修改情况设置按钮的样式。 + +```rust hl_lines="10 15 16 17 18 19 20 21" +fn toolbar_button<'a>(description: &str, callback: Option) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + let lower = description.to_lowercase(); + let icon = text(match lower.as_str() { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + let is_disabled = callback.is_none(); + tooltip( + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press_maybe(callback).style( + if is_disabled { + theme::Button::Secondary + } else { + theme::Button::Primary + } + ), + description, tooltip::Position::FollowCursor + ).style(theme::Container::Box).into() +} +``` + +同时修改`toolbar_button`的调用。 + +```rust hl_lines="5" +// ... In `view` function +let controls = row![ + toolbar_button("New", Some(Message::NewButtonPressed)), + toolbar_button("Open", Some(Message::OpenButtonPressed)), + toolbar_button("Save", if self.modified { Some(Message::SaveButtonPressed) } else { None }), + horizontal_space(Length::Fill), + pick_list(highlighter::Theme::ALL, Some(self.theme), Message::ThemeChanged) +].spacing(10); +``` + +我们可以添加不同的快捷键,以方便用户操作。 + +```rust +// In `impl Application for Editor` +fn subscription(&self) -> Subscription { + keyboard::on_key_press(|keycode, modifier| { + match (keycode, modifier) { + (keyboard::KeyCode::S, keyboard::Modifiers::COMMAND) => { + Some(Message::SaveButtonPressed) + }, + (keyboard::KeyCode::O, keyboard::Modifiers::COMMAND) => { + Some(Message::OpenButtonPressed) + }, + (keyboard::KeyCode::N, keyboard::Modifiers::COMMAND) => { + Some(Message::NewButtonPressed) + }, + _ => None + } + }) +} +``` + +这样,用户可以使用`Command + S`来保存文件,`Command + O`来打开文件,`Command + N`来新建文件。 + +文件的标题栏通常显示文件路径,可以和左下角的状态栏保持同步。 + +```rust +// ... In `impl Application for Editor` +fn title(&self) -> String { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + format!("{}{}", path_text, suffix) +} + +// ... In `view` function +let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } +} else { + text(self.title()) +}; +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/10.rs') }} diff --git a/docs/coding/rust-gui/2-multi-line-input.md b/docs/coding/rust-gui/2-multi-line-input.md new file mode 100644 index 000000000..a333945d9 --- /dev/null +++ b/docs/coding/rust-gui/2-multi-line-input.md @@ -0,0 +1,102 @@ +# Add Multi-line Input + +Keywords: `iced::widget::text_editor`, `iced::widget::container`. + +本节我们将`text`控件替换为`text_editor`控件,以实现多行文本的输入。 + +!!! warning "iced库版本" + + `text_editor`控件尚未在正式版本中发布,需要使用`git`仓库中的代码。 + + ```toml + [dependencies] + iced = { git = "https://github.com/iced-rs/iced.git", rev = "refs/tags/text-editor" } + ``` + +首先,将引入的`iced::widget::text`替换为`iced::widget::text_editor`。`text_editor`是有状态的,需要在`new`中初始化空间状态,并且在`view`中根据状态更新控件内容。 + +```rust +struct Editor { + content: text_editor::Content +} + +// ... + +impl Sandbox for Editor { + type Message = Message; + + fn new() -> Self { + Editor { + content: text_editor::Content::new() // Initialize the content + } + } + + // ... + + fn view(&self) -> Element<'_, Message> { + text_editor(&self.content).into() // Link the content state + } +} +``` + +默认情况下,`into`方法会使控件充满整个窗口,我们可以使用`container`包围`text_editor`控件辅助布局。 + +```rust +use iced::widget::container; + +// ... + +impl Sandbox for Editor { + // ... + fn view(&self) -> Element<'_, Message> { + let editor = text_editor(&self.content); + container(editor).padding(10).into() // Add padding and wrap the editor + } +} +``` + +此时,多行文本输入框的布局已经完成。但由于输入框没有绑定事件处理,因此目前无法输入文本。`text_editor`控件支持的事件有 + +```rust hl_lines="6" +pub enum Action { + Move(Motion), + Select(Motion), + SelectWord, + SelectLine, + Edit(Edit), + Click(Point), + Drag(Point), + Scroll { lines: i32 }, +} +``` + +我们需要将`Edit`事件在`update`和`view`中进行处理,同时更新`Message`枚举类型。 + +```rust +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action) +} + +// ... + +impl Sandbox for Editor { + // ... + fn update(&mut self, message: Message) { + match message { + Message::EditorEdit(action) => { + self.content = self.content.edit(action); + } + } + } + + fn view(&self) -> Element<'_, Message> { + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); // Bind the edit event + container(editor).padding(10).into() // Add padding and wrap the editor + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/2.rs') }} diff --git a/docs/coding/rust-gui/3-theme-and-cursor-indicator.md b/docs/coding/rust-gui/3-theme-and-cursor-indicator.md new file mode 100644 index 000000000..703043fd0 --- /dev/null +++ b/docs/coding/rust-gui/3-theme-and-cursor-indicator.md @@ -0,0 +1,74 @@ +# Theme and Cursor Indicator + +Keywords: `iced::widget::column`, `iced::widget::row`, `iced::widget::horizontal_space`, `iced::Theme`, `iced::Length` + +本节将窗口设置为暗色调,并且通过`text`控件显示光标的位置。 + +## 设置窗口主题 + +窗口主题通过`theme`函数设置,定义主题风格的类为`iced::Theme`,`Light`和`Dark`分别表示亮色和暗色调。 + +```rust +use iced::Theme + +// ... + +impl Sandbox for Editor { + // ... + + fn theme(&self) -> iced::Theme { + iced::Theme::Dark // Set the window theme to dark + } +} +``` + +## 读取文件内容 + +在程序启动时,我们可以设置一个默认的文件内容,此处可以使用`include_str!`宏在编译时读取文件内容并用于初始化文本框的状态。 + +```rust +impl Sandbox for Editor { + // ... + + fn new() -> Self { + Self { + content: text_editor::Content::with(include_str!("main.rs")) + } + } +} +``` + +## 显示光标位置 + +文本框的光标位置可以通过`self.content`状态的`cursor_position`方法获取,返回一个包含光标位置(从0开始)的元组,可以根据光标位置创建一个`text`控件用于显示。 + +* 通过`column!`宏创建一个列布局,上边包含输入文本框,下边包含光标位置显示。 +* 通过`row!`宏创建一个行布局,结合`Length::Fill`把光标位置压缩到右侧对齐。 + +```rust +use iced::widget::{column, row, horizontal_space, text}; +use iced::Length; + +// ... + +impl Sandbox for Editor { + // ... + + fn view(&self) -> Element<'_, Message> { + let editor = text_editor(&self.content); + // Query cursor position + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![horizontal_space(Length::Fill), cursor_indicator]; + + container(column![editor, status_bar].spacing(10)).padding(10).into() + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/3.rs') }} diff --git a/docs/coding/rust-gui/4-async-file-loading.md b/docs/coding/rust-gui/4-async-file-loading.md new file mode 100644 index 000000000..711e7fedf --- /dev/null +++ b/docs/coding/rust-gui/4-async-file-loading.md @@ -0,0 +1,134 @@ +# Asynchronous File Loading + +Keywords: `iced::Application`, `iced::Command`, `iced::executor`, `std::io`, `std::path::Path`, `std::sync::Arc`, `tokio::fs::read_to_string` + +为了实现异步文件加载,我们需要使用`tokio`库 + +```toml +[dependencies] +tokio = { version = "1.32", features = ["fs"] } +``` + +`Sandbox`不能直接执行异步函数,需要用`Application`。相比于`Sandbox`,`Application`需要额外实现如下类型 + +```rust +impl Application for Editor { + // ... + type Theme; // Color theme + type Executor; // Engine for running async tasks + type Flags; // Initial state +} +``` + +此处,`Theme`和`Flags`类型不需要额外实现,可以分别用`iced::Theme`和`()`代替。默认情况下,`Executor`类型需要用`iced::executor::Default`代替。 + +在修改为`Application`后,`new`和`update`方法的返回值标签也需要修改,`new`方法返回`(Self, Command)`,`update`方法返回`Command`。其中`Command`是一个异步任务,在执行完毕后会发送一个对应类型的`Message`。 + +```rust +impl Application for Editor { + // ... + + fn new(_flags: ()) -> (Self, Command) { + ( + Self { + // ... + }, + Command::none(), + ) + } + + fn update(&mut self, message: Message) -> Command { + match message { + // ... + } + + Command::none() + } +} +``` + +此后,我们需要编写用于读取文件的函数。 + +```rust +use std::io; +use std::path::Path; +use std::sync::Arc; + +async fn load_file(path: T) -> Result, io::ErrorKind> + where T: AsRef +{ + tokio::fs::read_to_string(path) + .await + .map(Arc::new) + .map_err(|e| e.kind()) +} +``` + +在`new`方法中,我们可以使用`Command`来调用`load_file`函数。 + +```rust + +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result, io::ErrorKind>) +} + +impl Application for Editor { + // ... + + fn new(_flags: ()) -> (Self, Command) { + let path = "path/to/file.txt"; + let file = load_file(path); + + ( + Self { + // ... + }, + Command::perform( + load_file(format!("{}/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileLoaded, + ), + ) + } +} +``` + +读取文件时可能会发生错误,需要对异常消息进行处理。在`load_file`函数中已经通过`Result`返回了对应的错误类型,只需要在`update`方法中处理`Message::FileOpened`即可。 + +```rust +struct Editor { + // ... + error: Option, // Use Option to store error +} + +// ... + +impl Application for Editor { + // ... + + fn new(_flags: ()) -> (Self, Command) { + ( + Self { + // ... + error: None, // Initialize error as None + }, + // ... + ) + } + + fn update(&mut self, message: Message) -> Command { + // ... + match message { + // ... + Message::FileOpened(Error(e)) => { + self.error = Some(e); // Store error + } + } + // ... + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/3.rs') }} diff --git a/docs/coding/rust-gui/5-file-picker.md b/docs/coding/rust-gui/5-file-picker.md new file mode 100644 index 000000000..5a41421db --- /dev/null +++ b/docs/coding/rust-gui/5-file-picker.md @@ -0,0 +1,74 @@ +# File Picker + +Keywords: `iced::widget::button`, `rfd::AsyncFileDialog` + +本节在窗口上添加一个按钮,点击后弹出文件选择对话框,选择文件后将文件内容显示在窗口上。首先需要添加`rfd`库的依赖,用于文件选择对话框。 + +```bash +cargo add rfd +``` + +首先需要编写用于显示文件选择窗口的函数。注意`rfd::AsyncFileDialog`会返回一个异常,因此需要自定义异常类型。 + +```rust +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} + +async fn pick_file() -> Result, Error> { + let handler = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; // If error, return DialogClosed error + load_file(handler.path()).await +} +``` + +调整窗口布局,在窗口上添加一个按钮。 + +```rust +impl Application for Editor { + // ... + + fn view(&self) -> Element<'_, Message> { + let controls = row![button("Open")]; + // ... + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } +} +``` + +之后需要添加按钮的点击事件处理,调用`pick_file`函数。 + +```rust +enum Message { + // ... + OpenButtonPressed +} + +impl Application for Editor { + // ... + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + // ... + Message::OpenButtonPressed => { + Command::perform(pick_file(), Message::FileOpened) + } + } + } + + fn view(&self) -> Element<'_, Message> { + let controls = row![button("Open").on_press(Message::OpenButtonPressed)]; + // ... + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/5.rs') }} diff --git a/docs/coding/rust-gui/6-file-path-indicator.md b/docs/coding/rust-gui/6-file-path-indicator.md new file mode 100644 index 000000000..502783704 --- /dev/null +++ b/docs/coding/rust-gui/6-file-path-indicator.md @@ -0,0 +1,118 @@ +# File Path Indicator + +本节在状态栏中添加一个显示文件路径的文本控件。首先需要存储文件路径状态,并在初始化阶段设置为`None`。 + +```rust +struct Editor { + path: Option, + // ... +} + +impl Application for Editor { + // ... + fn new() -> (Editor, Command) { + ( + Editor { + path: None, + // ... + }, + // ... + ) + } + // ... +} +``` + +!!! warning "Path与PathBuf" + `std::path::Path`和`std::path::PathBuf`的关系类似于`&str`和`String`的关系。`Path`是一个不可变引用,`PathBuf`是一个可变的对象,因此存储路径状态需要使用`PathBuf`,引用路径可以使用`Path`。 + + +之前的`load_file`函数只返回了文件内容,此处需要将文件的路径一同返回。注意函数不能返回引用,因此需要将路径转换为`PathBuf`类型。`and_then`方法处理`Result`的`Ok`值,在成功读取文件后将`Path`转换为`PathBuf`并返回。调用`load_file`的`pick_file`函数也需要一并修改,同时返回路径和文件内容。 + +```rust hl_lines="11" +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + // ... +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} +``` + +在修改读取文件的函数后,需要修改函数回调事件的类型和对应的处理函数 + + +=== "修改后" + + ```rust hl_lines="3 11 12" + enum Message { + // ... + FileOpened(Result<(PathBuf, Arc), Error>), + } + + impl Application for Editor { + // ... + fn update(&mut self, message: Message) -> Command { + match message { + // ... + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.content = text_editor::Content::with(&result); + Command::none() + }, + } + } + } + ``` + +=== "修改前" + + ```rust + enum Message { + // ... + FileOpened(Result, Error>), + } + + impl Application for Editor { + // ... + fn update(&mut self, message: Message) -> Command { + match message { + // ... + Message::FileOpened(Ok(result)) => { + self.content = text_editor::Content::with(&result); + Command::none() + }, + } + } + } + ``` + +最后在状态栏中添加一个文本控件显示文件路径即可。 + +```rust +impl Application for Editor { + // ... + fn view(&mut self) -> Element { + // ... + let path_indicator = match &self.path { + None => text(""), + Some(path) => text(path.to_string_lossy()) + }; + let status_bar = row![ + path_indicator, // Add path indicator here + horizontal_space(Length::Fill), + cursor_indicator + ]; + // ... + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/6.rs') }} diff --git a/docs/coding/rust-gui/7-new-and-save.md b/docs/coding/rust-gui/7-new-and-save.md new file mode 100644 index 000000000..747682734 --- /dev/null +++ b/docs/coding/rust-gui/7-new-and-save.md @@ -0,0 +1,185 @@ +# New and Save + +本节添加新建和保存文件的功能。 + +首先,在`EditorEdit`消息处理中检查是否对文件进行了修改,并且记录文件的修改状态。 + +```rust hl_lines="3 7 8 9 10 16 22" +struct Editor { + // ... + modified: bool +} +// ... In `update` function + Message::EditorEdit(action) => { + match &action { + text_editor::Action::Edit(_) => self.modified = true, + _ => {} + } + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.modified = false; + self.content = text_editor::Content::with(&result); + Command::none() + }, + // ... + Message::OpenButtonPressed => { + self.modified = false; + Command::perform(pick_file(), Message::FileOpened) + }, +``` + +其次,调整文件路径控件的不同显示状态: + +1. 当文件路径为空时,为新文件,显示“New File”。 +2. 当打开一个文件时,显示文件路径。 +3. 当文件被修改后,文件路径后加上“*”。 +4. 当打开文件出错时,显示错误信息。 + +```rust +let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } +} else { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + text(format!("{}{}", path_text, suffix)) +}; +``` + +在创建文件时,需要清空文件路径和内容,以及清空错误信息。加入一个新的消息类型`NewButtonPressed`,由一个按钮触发,在`update`函数中执行这个逻辑。 + +=== "消息类型声明" + + ```rust hl_lines="4" + enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + NewButtonPressed, + OpenButtonPressed + } + ``` + +=== "消息触发" + + ```rust hl_lines="3" + // ... In `view` function + let controls = row![ + button("New").on_press(Message::NewButtonPressed), + button("Open").on_press(Message::OpenButtonPressed) + ]; + ``` + +=== "执行逻辑" + + ```rust + // ... In matching logic in `update` function + Message::NewButtonPressed => { + self.content = text_editor::Content::new(); + self.error = None; + self.path = None; + self.modified = false; + Command::none() + }, + // ... + ``` + +接下来处理保存文件的逻辑,当存在文件路径时,保存文件,否则打开文件选择对话框。 + +```rust +async fn save_file(path: Option, content: String) -> Result { + let path = if let Some(path) = path { + path + } else { + rfd::AsyncFileDialog::new() + .set_title("Save the file to...") + .save_file() + .await + .ok_or(Error::DialogClosed)? + .path() + .to_path_buf() + }; + tokio::fs::write(&path, content) + .await + .map_err(|err| err.kind()) + .map_err(Error::IO) + .map(|_| path) +} +``` + +在保存文件时,需要 + +1. 检查文件的修改状态,如果文件没有修改,不执行保存操作。 +2. 检查文件路径是否为空,如果为空,打开文件选择对话框,否则直接保存文件。 +3. 加入一个新的消息类型`SaveButtonPressed`,由一个按钮触发,在`update`函数中执行这个逻辑。 + +=== "消息类型声明" + + ```rust hl_lines="4 7" + enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + FileSaved(Result), + NewButtonPressed, + OpenButtonPressed, + SaveButtonPressed + } + ``` + +=== "消息触发" + + ```rust hl_lines="5" + // ... In `view` function + let controls = row![ + button("New").on_press(Message::NewButtonPressed), + button("Open").on_press(Message::OpenButtonPressed), + button("Save").on_press(Message::SaveButtonPressed) + ]; + ``` + +=== "执行逻辑" + + ```rust + // ... In matching logic in `update` function + Message::FileSaved(Ok(path)) => { + self.path = Some(path); + self.modified = false; + Command::none() + }, + Message::FileOpened(Err(error)) | Message::FileSaved(Err(error)) => { + self.error = Some(error); + Command::none() + }, + // ... + Message::SaveButtonPressed => { + let content = self.content.text(); + match self.modified { + false => Command::none(), + true => Command::perform( + save_file(self.path.clone(), content), + Message::FileSaved + ) + } + } + ``` + +最后,调整三个按钮之间的间距 + +```rust hl_lines="5" +let controls = row![ + button("New").on_press(Message::NewButtonPressed), + button("Open").on_press(Message::OpenButtonPressed), + button("Save").on_press(Message::SaveButtonPressed) +].spacing(10); +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/7.rs') }} diff --git a/docs/coding/rust-gui/8-button-prettify.md b/docs/coding/rust-gui/8-button-prettify.md new file mode 100644 index 000000000..462e03488 --- /dev/null +++ b/docs/coding/rust-gui/8-button-prettify.md @@ -0,0 +1,82 @@ +# Button Prettify + +在本节中,我们把按钮的文本替换为图标,并且添加文本悬浮提示。 + +首先需要创建包含图标的字体,可以在[Fontello](https://fontello.com/)上选择图标,然后下载字体文件。将`ttf`版本的字体存放在项目下的`fonts/editor-icon.ttf`中。 + +然后在代码中加载字体文件,在`iced::Settings`中添加字体: + +```rust +fn main() -> iced::Result { + Editor::run(Settings { + fonts: vec![include_bytes!("../fonts/editor-icon.ttf").as_slice().into()], + ..Default::default() // Expand the default settings + }) +} +``` + +加载字体后,可以将按钮的输入文本替换为图标,使用`text`控件创建图标。在网页中可以读取到对应新建、打开、保存的Unicode编码分别为`\u{E800}`、`\u{F115}`、`\u{E801}`。 + +```rust +fn toolbar_button<'a>(description: &str, callback: Message) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + + let icon = text(match description { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press(callback).into() +} +``` + +使用`button_icon`函数替换按钮原本的输入 + +```rust +// ... In `view` function +let controls = row![ + toolbar_button("new", Message::NewButtonPressed), + toolbar_button("open", Message::OpenButtonPressed), + toolbar_button("save", Message::SaveButtonPressed) +].spacing(10); +``` + +最后,实现悬浮提示,使用`Tooltip`控件包裹按钮即可。为了美观,可以通过`style`方法设置提示框的样式。 + +```rust +// ... In `view` function +let controls = row![ + toolbar_button("New", Message::NewButtonPressed), + toolbar_button("Open", Message::OpenButtonPressed), + toolbar_button("Save", Message::SaveButtonPressed) +].spacing(10); + +// ... In the outer scope +fn toolbar_button<'a>(description: &str, callback: Message) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + let lower = description.to_lowercase(); + let icon = text(match lower.as_str() { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + + tooltip( + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press(callback), + description, tooltip::Position::FollowCursor + ).style(theme::Container::Box).into() // Set the style of the tooltip +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/8.rs') }} diff --git a/docs/coding/rust-gui/9-syntax-highlighting.md b/docs/coding/rust-gui/9-syntax-highlighting.md new file mode 100644 index 000000000..a9907554d --- /dev/null +++ b/docs/coding/rust-gui/9-syntax-highlighting.md @@ -0,0 +1,99 @@ +# Syntax Highlighting + +Keywords: `iced::widget::pick_list` + +本节添加代码编辑器的语法高亮功能。 + +首先,修改编辑器的默认字体为等宽字体,以便更好地显示代码。 + +```rust hl_lines="3" +// ... In `view` function +let editor = text_editor(&self.content) + .on_edit(Message::EditorEdit) + .font(Font::MONOSPACE); +``` + +接下来,添加高亮处理的逻辑。高亮功能由`iced`的`highlighter`模块提供,需要在`Cargo.toml`中添加依赖。 + +```toml +[dependencies] +iced = { git = "https://github.com/iced-rs/iced.git", rev = "refs/tags/text-editor", features = [ "highlighter" ] } +``` + +在文本框的`highlight`方法中设置高亮。`highlight`方法需要指定配色方案`theme`和语法`extension`。`extension`可以根据文件路径的后缀名自动识别,如果没有后缀名,则默认为`rs`。 + +```rust hl_lines="3 4 5 6 7 8 9 10 11 12 13 14" +let editor = text_editor(&self.content) + .on_edit(Message::EditorEdit) + .highlight::( // use iced::highlighter::Highlighter + highlighter::Settings { + theme: highlighter::Theme::SolarizedDark, // Set the theme + extension: self.path + .as_ref() + .and_then(|path| path.extension()?.to_str()) + .unwrap_or("rs") // If extension is not found, use `rs` + .to_string() + }, |highlighter, _theme| { + highlighter.to_format() + } + ) + .font(Font::MONOSPACE); +``` + +接下来添加高亮风格的选择功能。首先在`Editor`中添加`theme`状态作为当前的主题,并且设置初始化状态。 + +```rust +struct Editor { + // ... + theme: highlighter::Theme +} +// ... In `new` function +Self { + // ... + theme: highlighter::Theme::SolarizedDark +}, +// ... In `view` function +highlighter::Settings { + theme: self.theme, + // ... +} +``` + +添加一个`pick_list`控件用于选择高亮风格,`pick_list`需要设置选项、当前选中项和触发事件。每次更新时都会以选中项为参数触发事件。 + +```rust hl_lines="6" +let controls = row![ + toolbar_button("New", Message::NewButtonPressed), + toolbar_button("Open", Message::OpenButtonPressed), + toolbar_button("Save", Message::SaveButtonPressed), + horizontal_space(Length::Fill), + pick_list(highlighter::Theme::ALL, Some(self.theme), Message::ThemeChanged) +].spacing(10); +``` + +同时更新`ThemeChanged`事件的处理逻辑 + +```rust +// ... In `update` function +Message::ThemeChanged(theme) => { + self.theme = theme; + Command::none() +} +``` + +内置的部分主题并不是暗色主题,需要添加一个根据主题的亮暗切换窗口配色的功能: + +```rust +// ... In `impl Application for Editor` +fn theme(&self) -> Theme { + if self.theme.is_dark() { + Theme::Dark + } else { + Theme::Light + } +} +``` + +以下为完整的`main.rs`文件内容: + +{{ read_code_from_file('docs/coding/rust-gui/source/9.rs') }} diff --git a/docs/coding/rust-gui/index.md b/docs/coding/rust-gui/index.md new file mode 100644 index 000000000..8e470698f --- /dev/null +++ b/docs/coding/rust-gui/index.md @@ -0,0 +1,14 @@ +# Rust GUI library + +The following pages are notes from [*Building a simple text editor with iced, a cross-platform GUI library for Rust*](https://www.youtube.com/watch?v=gcBJ7cPSALo) using iced lib. + +1. [Hello World](1-hello-world.md) +2. [Multi-line Input](2-multi-line-input.md) +3. [Theme and Cursor Indicator](3-theme-and-cursor-indicator.md) +4. [Async File Loading](4-async-file-loading.md) +5. [File Picker](5-file-picker.md) +6. [File Path Indicator](6-file-path-indicator.md) +7. [New and Save](7-new-and-save.md) +8. [Button prettify](8-button-prettify.md) +9. [Syntax Highlighting](9-syntax-highlighting.md) +10. [Misc](10-misc.md) diff --git a/docs/coding/rust-gui/source/1.rs b/docs/coding/rust-gui/source/1.rs new file mode 100644 index 000000000..a1cd37f6a --- /dev/null +++ b/docs/coding/rust-gui/source/1.rs @@ -0,0 +1,32 @@ +use iced::{Element, Sandbox, Settings}; +use iced::widget::text; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor; + +#[derive(Debug)] +enum Message {} + +impl Sandbox for Editor { + type Message = Message; // Define the type of messages + fn new() -> Self { + Self + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) { + // Handle messages here + match message {} + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + text("Hello, world!").into() + } +} diff --git a/docs/coding/rust-gui/source/10.rs b/docs/coding/rust-gui/source/10.rs new file mode 100644 index 000000000..346d6b999 --- /dev/null +++ b/docs/coding/rust-gui/source/10.rs @@ -0,0 +1,260 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced::{executor, keyboard, theme, Application, Command, Element, Font, Length, Settings, Subscription, Theme}; +use iced::highlighter::{self, Highlighter}; +use iced::widget::{button, column, container, horizontal_space, pick_list, row, text, text_editor, tooltip}; + +fn main() -> iced::Result { + Editor::run(Settings { + fonts: vec![include_bytes!("../fonts/editor-icon.ttf").as_slice().into()], + ..Default::default() + }) +} + +struct Editor { + path: Option, + content: text_editor::Content, + modified: bool, + error: Option, + theme: highlighter::Theme +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + FileSaved(Result), + ThemeChanged(highlighter::Theme), + NewButtonPressed, + OpenButtonPressed, + SaveButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None, + modified: false, + path: None, + theme: highlighter::Theme::SolarizedDark + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + format!("{}{}", path_text, suffix) + } + + fn subscription(&self) -> Subscription { + keyboard::on_key_press(|keycode, modifier| { + match (keycode, modifier) { + (keyboard::KeyCode::S, keyboard::Modifiers::COMMAND) => { + Some(Message::SaveButtonPressed) + }, + (keyboard::KeyCode::O, keyboard::Modifiers::COMMAND) => { + Some(Message::OpenButtonPressed) + }, + (keyboard::KeyCode::N, keyboard::Modifiers::COMMAND) => { + Some(Message::NewButtonPressed) + }, + _ => None + } + }) + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + match &action { + text_editor::Action::Edit(_) => self.modified = true, + _ => {} + } + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.modified = false; + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileSaved(Ok(path)) => { + self.path = Some(path); + self.modified = false; + Command::none() + }, + Message::FileOpened(Err(error)) | Message::FileSaved(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::ThemeChanged(theme) => { + self.theme = theme; + Command::none() + }, + Message::NewButtonPressed => { + self.content = text_editor::Content::new(); + self.error = None; + self.path = None; + self.modified = false; + Command::none() + }, + Message::OpenButtonPressed => { + self.modified = false; + Command::perform(pick_file(), Message::FileOpened) + }, + Message::SaveButtonPressed => { + let content = self.content.text(); + match self.modified { + false => Command::none(), + true => Command::perform( + save_file(self.path.clone(), content), + Message::FileSaved + ) + } + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content) + .on_edit(Message::EditorEdit) + .highlight::( + highlighter::Settings { + theme: self.theme, + extension: self.path + .as_ref() + .and_then(|path| path.extension()?.to_str()) + .unwrap_or("rs") + .to_string() + }, |highlighter, _theme| { + highlighter.to_format() + } + ) + .font(Font::MONOSPACE); + let controls = row![ + toolbar_button("New", Some(Message::NewButtonPressed)), + toolbar_button("Open", Some(Message::OpenButtonPressed)), + toolbar_button("Save", if self.modified { Some(Message::SaveButtonPressed) } else { None }), + horizontal_space(Length::Fill), + pick_list(highlighter::Theme::ALL, Some(self.theme), Message::ThemeChanged) + ].spacing(10); + + // Query cursor position + let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } + } else { + text(self.title()) + }; + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![ + path_indicator, + horizontal_space(Length::Fill), + cursor_indicator + ]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + if self.theme.is_dark() { + Theme::Dark + } else { + Theme::Light + } + } +} + +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} + +async fn save_file(path: Option, content: String) -> Result { + let path = if let Some(path) = path { + path + } else { + rfd::AsyncFileDialog::new() + .set_title("Save the file to...") + .save_file() + .await + .ok_or(Error::DialogClosed)? + .path() + .to_path_buf() + }; + tokio::fs::write(&path, content) + .await + .map_err(|err| err.kind()) + .map_err(Error::IO) + .map(|_| path) +} + +fn toolbar_button<'a>(description: &str, callback: Option) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + let lower = description.to_lowercase(); + let icon = text(match lower.as_str() { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + let is_disabled = callback.is_none(); + tooltip( + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press_maybe(callback).style( + if is_disabled { + theme::Button::Secondary + } else { + theme::Button::Primary + } + ), + description, tooltip::Position::FollowCursor + ).style(theme::Container::Box).into() +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust-gui/source/2.rs b/docs/coding/rust-gui/source/2.rs new file mode 100644 index 000000000..c0e8dca88 --- /dev/null +++ b/docs/coding/rust-gui/source/2.rs @@ -0,0 +1,44 @@ +use iced::{Element, Sandbox, Settings}; +use iced::widget::{text_editor, container}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + content: text_editor::Content +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action) +} + +impl Sandbox for Editor { + type Message = Message; // Define the type of messages + + fn new() -> Self { + Self { + content: text_editor::Content::new() + } + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) { + // Handle messages here + match message { + Message::EditorEdit(action) => { + self.content.edit(action); + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + container(editor).padding(10).into() + } +} diff --git a/docs/coding/rust-gui/source/3.rs b/docs/coding/rust-gui/source/3.rs new file mode 100644 index 000000000..f6a78c7e3 --- /dev/null +++ b/docs/coding/rust-gui/source/3.rs @@ -0,0 +1,57 @@ +use iced::{Theme, Element, Sandbox, Settings, Length}; +use iced::widget::{column, container, horizontal_space, row, text, text_editor}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + content: text_editor::Content +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action) +} + +impl Sandbox for Editor { + type Message = Message; // Define the type of messages + + fn new() -> Self { + Self { + content: text_editor::Content::with(include_str!("main.rs")) + } + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) { + // Handle messages here + match message { + Message::EditorEdit(action) => { + self.content.edit(action); + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + + // Query cursor position + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![horizontal_space(Length::Fill), cursor_indicator]; + + container(column![editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} diff --git a/docs/coding/rust-gui/source/4.rs b/docs/coding/rust-gui/source/4.rs new file mode 100644 index 000000000..ce6cbbc79 --- /dev/null +++ b/docs/coding/rust-gui/source/4.rs @@ -0,0 +1,88 @@ +use std::io; +use std::path::Path; +use std::sync::Arc; + +use iced::{executor, Application, Command, Element, Length, Settings, Theme}; +use iced::widget::{column, container, horizontal_space, row, text, text_editor}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + content: text_editor::Content, + error: Option +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result, io::ErrorKind>) +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + self.content.edit(action); + }, + Message::FileOpened(Ok(result)) => { + self.content = text_editor::Content::with(&result); + }, + Message::FileOpened(Err(error)) => { + self.error = Some(error); + } + }; + + Command::none() + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + + // Query cursor position + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![horizontal_space(Length::Fill), cursor_indicator]; + + container(column![editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} + +async fn load_file(path: impl AsRef) -> Result, io::ErrorKind> { + tokio::fs::read_to_string(path) + .await + .map(Arc::new) + .map_err(|err| err.kind()) +} diff --git a/docs/coding/rust-gui/source/5.rs b/docs/coding/rust-gui/source/5.rs new file mode 100644 index 000000000..4c71e0690 --- /dev/null +++ b/docs/coding/rust-gui/source/5.rs @@ -0,0 +1,110 @@ +use std::io; +use std::path::Path; +use std::sync::Arc; + +use iced::{executor, Application, Command, Element, Length, Settings, Theme}; +use iced::widget::{button, column, container, horizontal_space, row, text, text_editor}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + content: text_editor::Content, + error: Option +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result, Error>), + OpenButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok(result)) => { + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileOpened(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::OpenButtonPressed => { + Command::perform(pick_file(), Message::FileOpened) + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + let controls = row![button("Open").on_press(Message::OpenButtonPressed)]; + + // Query cursor position + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![horizontal_space(Length::Fill), cursor_indicator]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} + +async fn pick_file() -> Result, Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result, Error> { + tokio::fs::read_to_string(path) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO) +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust-gui/source/6.rs b/docs/coding/rust-gui/source/6.rs new file mode 100644 index 000000000..14d2acbca --- /dev/null +++ b/docs/coding/rust-gui/source/6.rs @@ -0,0 +1,122 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced::{executor, Application, Command, Element, Length, Settings, Theme}; +use iced::widget::{button, column, container, horizontal_space, row, text, text_editor}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + path: Option, + content: text_editor::Content, + error: Option +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + OpenButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None, + path: None + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileOpened(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::OpenButtonPressed => { + Command::perform(pick_file(), Message::FileOpened) + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + let controls = row![button("Open").on_press(Message::OpenButtonPressed)]; + + // Query cursor position + let path_indicator = match &self.path { + None => text(""), + Some(path) => text(path.to_string_lossy()) + }; + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![ + path_indicator, + horizontal_space(Length::Fill), + cursor_indicator + ]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} + +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust-gui/source/7.rs b/docs/coding/rust-gui/source/7.rs new file mode 100644 index 000000000..5f6a4dc71 --- /dev/null +++ b/docs/coding/rust-gui/source/7.rs @@ -0,0 +1,187 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced::{executor, Application, Command, Element, Length, Settings, Theme}; +use iced::widget::{button, column, container, horizontal_space, row, text, text_editor}; + +fn main() -> iced::Result { + Editor::run(Settings::default()) +} + +struct Editor { + path: Option, + content: text_editor::Content, + modified: bool, + error: Option +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + FileSaved(Result), + NewButtonPressed, + OpenButtonPressed, + SaveButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None, + modified: false, + path: None + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + match &action { + text_editor::Action::Edit(_) => self.modified = true, + _ => {} + } + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.modified = false; + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileSaved(Ok(path)) => { + self.path = Some(path); + self.modified = false; + Command::none() + }, + Message::FileOpened(Err(error)) | Message::FileSaved(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::NewButtonPressed => { + self.content = text_editor::Content::new(); + self.error = None; + self.path = None; + self.modified = false; + Command::none() + }, + Message::OpenButtonPressed => { + self.modified = false; + Command::perform(pick_file(), Message::FileOpened) + }, + Message::SaveButtonPressed => { + let content = self.content.text(); + match self.modified { + false => Command::none(), + true => Command::perform( + save_file(self.path.clone(), content), + Message::FileSaved + ) + } + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + let controls = row![ + button("New").on_press(Message::NewButtonPressed), + button("Open").on_press(Message::OpenButtonPressed), + button("Save").on_press(Message::SaveButtonPressed) + ].spacing(10); + + // Query cursor position + let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } + } else { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + text(format!("{}{}", path_text, suffix)) + }; + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![ + path_indicator, + horizontal_space(Length::Fill), + cursor_indicator + ]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} + +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} + +async fn save_file(path: Option, content: String) -> Result { + let path = if let Some(path) = path { + path + } else { + rfd::AsyncFileDialog::new() + .set_title("Save the file to...") + .save_file() + .await + .ok_or(Error::DialogClosed)? + .path() + .to_path_buf() + }; + tokio::fs::write(&path, content) + .await + .map_err(|err| err.kind()) + .map_err(Error::IO) + .map(|_| path) +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust-gui/source/8.rs b/docs/coding/rust-gui/source/8.rs new file mode 100644 index 000000000..84c49aae8 --- /dev/null +++ b/docs/coding/rust-gui/source/8.rs @@ -0,0 +1,209 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced::{executor, theme, Application, Command, Element, Font, Length, Settings, Theme}; +use iced::widget::{button, column, container, horizontal_space, row, text, text_editor, tooltip}; + +fn main() -> iced::Result { + Editor::run(Settings { + fonts: vec![include_bytes!("../fonts/editor-icon.ttf").as_slice().into()], + ..Default::default() + }) +} + +struct Editor { + path: Option, + content: text_editor::Content, + modified: bool, + error: Option +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + FileSaved(Result), + NewButtonPressed, + OpenButtonPressed, + SaveButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None, + modified: false, + path: None + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + match &action { + text_editor::Action::Edit(_) => self.modified = true, + _ => {} + } + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.modified = false; + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileSaved(Ok(path)) => { + self.path = Some(path); + self.modified = false; + Command::none() + }, + Message::FileOpened(Err(error)) | Message::FileSaved(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::NewButtonPressed => { + self.content = text_editor::Content::new(); + self.error = None; + self.path = None; + self.modified = false; + Command::none() + }, + Message::OpenButtonPressed => { + self.modified = false; + Command::perform(pick_file(), Message::FileOpened) + }, + Message::SaveButtonPressed => { + let content = self.content.text(); + match self.modified { + false => Command::none(), + true => Command::perform( + save_file(self.path.clone(), content), + Message::FileSaved + ) + } + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content).on_edit(Message::EditorEdit); + let controls = row![ + toolbar_button("New", Message::NewButtonPressed), + toolbar_button("Open", Message::OpenButtonPressed), + toolbar_button("Save", Message::SaveButtonPressed) + ].spacing(10); + + // Query cursor position + let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } + } else { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + text(format!("{}{}", path_text, suffix)) + }; + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![ + path_indicator, + horizontal_space(Length::Fill), + cursor_indicator + ]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + Theme::Dark + } +} + +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} + +async fn save_file(path: Option, content: String) -> Result { + let path = if let Some(path) = path { + path + } else { + rfd::AsyncFileDialog::new() + .set_title("Save the file to...") + .save_file() + .await + .ok_or(Error::DialogClosed)? + .path() + .to_path_buf() + }; + tokio::fs::write(&path, content) + .await + .map_err(|err| err.kind()) + .map_err(Error::IO) + .map(|_| path) +} + +fn toolbar_button<'a>(description: &str, callback: Message) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + let lower = description.to_lowercase(); + let icon = text(match lower.as_str() { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + + tooltip( + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press(callback), + description, tooltip::Position::FollowCursor + ).style(theme::Container::Box).into() +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust-gui/source/9.rs b/docs/coding/rust-gui/source/9.rs new file mode 100644 index 000000000..3f6ed1deb --- /dev/null +++ b/docs/coding/rust-gui/source/9.rs @@ -0,0 +1,237 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use iced::{executor, theme, Application, Command, Element, Font, Length, Settings, Theme}; +use iced::highlighter::{self, Highlighter}; +use iced::widget::{button, column, container, horizontal_space, pick_list, row, text, text_editor, tooltip}; + +fn main() -> iced::Result { + Editor::run(Settings { + fonts: vec![include_bytes!("../fonts/editor-icon.ttf").as_slice().into()], + ..Default::default() + }) +} + +struct Editor { + path: Option, + content: text_editor::Content, + modified: bool, + error: Option, + theme: highlighter::Theme +} + +#[derive(Debug, Clone)] +enum Message { + EditorEdit(text_editor::Action), + FileOpened(Result<(PathBuf, Arc), Error>), + FileSaved(Result), + ThemeChanged(highlighter::Theme), + NewButtonPressed, + OpenButtonPressed, + SaveButtonPressed +} + +impl Application for Editor { + type Message = Message; // Define the type of messages + type Theme = Theme; + type Executor = executor::Default; // Engine for running async tasks + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + Self { + content: text_editor::Content::new(), + error: None, + modified: false, + path: None, + theme: highlighter::Theme::SolarizedDark + }, + Command::perform( + load_file(format!("{}/src/main.rs", env!("CARGO_MANIFEST_DIR"))), + Message::FileOpened + ) + ) + } + + fn title(&self) -> String { + String::from("A text editor") + } + + fn update(&mut self, message: Message) -> Command { + // Handle messages here + match message { + Message::EditorEdit(action) => { + match &action { + text_editor::Action::Edit(_) => self.modified = true, + _ => {} + } + self.content.edit(action); + Command::none() + }, + Message::FileOpened(Ok((path, result))) => { + self.path = Some(path); + self.modified = false; + self.content = text_editor::Content::with(&result); + Command::none() + }, + Message::FileSaved(Ok(path)) => { + self.path = Some(path); + self.modified = false; + Command::none() + }, + Message::FileOpened(Err(error)) | Message::FileSaved(Err(error)) => { + self.error = Some(error); + Command::none() + }, + Message::ThemeChanged(theme) => { + self.theme = theme; + Command::none() + }, + Message::NewButtonPressed => { + self.content = text_editor::Content::new(); + self.error = None; + self.path = None; + self.modified = false; + Command::none() + }, + Message::OpenButtonPressed => { + self.modified = false; + Command::perform(pick_file(), Message::FileOpened) + }, + Message::SaveButtonPressed => { + let content = self.content.text(); + match self.modified { + false => Command::none(), + true => Command::perform( + save_file(self.path.clone(), content), + Message::FileSaved + ) + } + } + } + } + + fn view(&self) -> Element<'_, Message> { + // Create the user interface here + let editor = text_editor(&self.content) + .on_edit(Message::EditorEdit) + .highlight::( + highlighter::Settings { + theme: self.theme, + extension: self.path + .as_ref() + .and_then(|path| path.extension()?.to_str()) + .unwrap_or("rs") + .to_string() + }, |highlighter, _theme| { + highlighter.to_format() + } + ) + .font(Font::MONOSPACE); + let controls = row![ + toolbar_button("New", Message::NewButtonPressed), + toolbar_button("Open", Message::OpenButtonPressed), + toolbar_button("Save", Message::SaveButtonPressed), + horizontal_space(Length::Fill), + pick_list(highlighter::Theme::ALL, Some(self.theme), Message::ThemeChanged) + ].spacing(10); + + // Query cursor position + let path_indicator = if let Some(error) = &self.error { + match error { + Error::DialogClosed => text("Dialog closed"), + Error::IO(kind) => text(format!("I/O error: {:?}", kind)) + } + } else { + let path_text = match &self.path { + None => String::from("New file"), + Some(path) => path.to_string_lossy().to_string() + }; + let suffix = if self.modified { "*" } else { "" }; + text(format!("{}{}", path_text, suffix)) + }; + let cursor_indicator = { + let (line, column) = self.content.cursor_position(); + + text(format!("Line: {}, Column: {}", line + 1, column + 1)) + }; + let status_bar = row![ + path_indicator, + horizontal_space(Length::Fill), + cursor_indicator + ]; + + container(column![controls, editor, status_bar].spacing(10)).padding(10).into() + } + + fn theme(&self) -> Theme { + if self.theme.is_dark() { + Theme::Dark + } else { + Theme::Light + } + } +} + +async fn pick_file() -> Result<(PathBuf, Arc), Error> { + let file_handle = rfd::AsyncFileDialog::new() + .set_title("Choose a text file...") + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + load_file(file_handle.path()).await +} + +async fn load_file(path: impl AsRef) -> Result<(PathBuf, Arc), Error> { + let content = tokio::fs::read_to_string(path.as_ref()) + .await + .map(Arc::new) + .map_err(|err| err.kind()) + .map_err(Error::IO); + content.and_then(|content| Ok((path.as_ref().to_path_buf(), content))) +} + +async fn save_file(path: Option, content: String) -> Result { + let path = if let Some(path) = path { + path + } else { + rfd::AsyncFileDialog::new() + .set_title("Save the file to...") + .save_file() + .await + .ok_or(Error::DialogClosed)? + .path() + .to_path_buf() + }; + tokio::fs::write(&path, content) + .await + .map_err(|err| err.kind()) + .map_err(Error::IO) + .map(|_| path) +} + +fn toolbar_button<'a>(description: &str, callback: Message) -> Element<'a, Message> { + let font = Font::with_name("editor-icon"); + let lower = description.to_lowercase(); + let icon = text(match lower.as_str() { + "new" => '\u{E800}', + "open" => '\u{F115}', + "save" => '\u{E801}', + _ => ' ' + }).font(font); + + tooltip( + button(container(icon) + .width(30) // Set the width of the button + .center_x() // Center the icon + ).on_press(callback), + description, tooltip::Position::FollowCursor + ).style(theme::Container::Box).into() +} + +#[derive(Debug, Clone)] +enum Error { + DialogClosed, + IO(io::ErrorKind) +} diff --git a/docs/coding/rust/3.md b/docs/coding/rust/3.md index f03d06f23..1362ae695 100644 --- a/docs/coding/rust/3.md +++ b/docs/coding/rust/3.md @@ -118,6 +118,10 @@ enum Result { 3. 特殊对象`self`表示当前结构体的实例,可以以引用(`&self`、`&mut self`)或自身形态(`self`)出现。如果不使用`self`,则为静态方法/类方法,类似于Python中的`@staticmethod`或`@classmethod`。此时需要用`::`调用,而不是`.`。 4. 特殊类型`Self`表示当前结构体的类型,可以用于返回值类型的声明。 +!!! warning "孤儿规则" + + 如果需要对类型`A`实现特征`T`,则`T`或`A`其中之一必须在当前作用域内定义。 + ```rust struct Player { first_name: String, @@ -164,4 +168,79 @@ trait ExpatEmployee : Employee + Expat { // ExpatEmployee inherits from Employee } ``` -trait可以用于泛型参数的限定,如``表示`T`必须实现名为`Person`的`trait`。 +在`trait`的声明中可以用函数体,此时表示方法的默认实现,在`impl`中可以重载这个方法。 + +```rust +struct Blog {}; +struct Tweet {}; + +pub trait Summary { + fn summarize(&self) -> String { + String::from("(Read more...)") + } +} + +impl Summary for Blog {} // Use default implementation +impl Summary for Tweet { + fn summarize(&self) -> String { // Override default implementation + String::from("Tweet") + } +} +``` + +### 特征约束 + +特征可以用于约束函数或泛型的类型。如 + +1. 函数参数的限定,如`fn foo(bar: impl Copy)`表示`bar`参数必须实现名为`Copy`的`trait`。 +2. 泛型参数的限定,如``表示`T`必须实现名为`Copy`的`trait`,可以实现更复杂的类型控制。 + +使用`+`连接多个特征,表示参数必须同时实现多个特征。 + +```rust +fn foo(bar: T) { + println!("{}", bar); // `bar` must implement `Display` and `Copy` +} +``` + +使用`where`关键字可以使特征约束更加清晰。 + +```rust +fn foo(bar: T, baz: U) -> U + where T: Copy + Display, + U: Add +{ + println!("{}", bar); // `bar` must implement `Display` and `Copy` + baz + 1 // `baz` must implement `Add` +} +``` + +特征约束还可以用在`impl`语句中用于选择性地实现特征 + +```rust +trait Increment { + fn increment(&self) -> Self; +} + +impl Increment for T + where T: Add + Copy +{ + fn increment(&self) -> Self { + *self + 1 + } +} +``` + +表示`Increment`特征只能用于同时实现了`Add`和`Copy`的类型。 + +特征约束还可以用于函数返回值的限定,只能有一个特征,表示函数返回一个实现了特定特征的类型。但函数体内部不能返回多个不同的类型。 + +```rust +fn foo(x: bool) -> impl Display { + if x { + 1 + } else { + "hello" // Error, since 1 and "hello" have different types + } +} +``` diff --git a/docs/css/css_override.css b/docs/css/css_override.css index 3fdb1b491..46994d1b1 100644 --- a/docs/css/css_override.css +++ b/docs/css/css_override.css @@ -304,6 +304,14 @@ button[data-md-color-accent=deep-orange] { :root [data-md-color-scheme=default] { --md-code-bg-color: #e6c2cd2d; + --md-code-hl-color: #e01b56; + --md-code-hl-color--light: #ef71972d; +} + +:root [data-md-color-scheme=slate] { + --md-code-bg-color: #1e0c1230; + --md-code-hl-color: #e01b56; + --md-code-hl-color--light: #1e0c1230; } .highlight [data-linenos]::before { diff --git a/main/coding.py b/main/coding.py index 8da88ad2a..5aca1e5df 100644 --- a/main/coding.py +++ b/main/coding.py @@ -7,7 +7,7 @@ def read_code_from_file(file_name: str, language: str | None = None) -> str: file_url = file_name.removeprefix('docs') suffix_mapping = { 'py': 'python', 'rs': 'rust' } link = f'[:bootstrap-cloud-download: Download source code]({file_url})\n\n' - code_block_prefix = f'```{suffix_mapping.get(language, str())}\n' + code_block_prefix = f'```{suffix_mapping.get(language, str())} linenums="1"\n' with open(file_name, 'r') as f: code_block_body = f.read() code_block_suffix = '\n```'