#tui #flexbox #editor #emoji #user-input #line-editor #markdown-text

r3bl_tui

受Elm启发的TUI库,具有Flexbox、CSS、编辑组件、emoji支持等,用于构建现代应用程序

25个版本

0.5.7 2024年8月13日
0.5.6 2024年6月29日
0.5.5 2024年5月20日
0.5.2 2024年1月15日
0.1.3 2022年11月6日

#27命令行界面

Download history 4170/week @ 2024-05-03 2595/week @ 2024-05-10 2838/week @ 2024-05-17 3038/week @ 2024-05-24 2758/week @ 2024-05-31 2530/week @ 2024-06-07 2740/week @ 2024-06-14 3353/week @ 2024-06-21 4508/week @ 2024-06-28 3976/week @ 2024-07-05 3160/week @ 2024-07-12 2329/week @ 2024-07-19 2395/week @ 2024-07-26 1940/week @ 2024-08-02 1782/week @ 2024-08-09 1243/week @ 2024-08-16

每月7,763次下载
用于 5 个Crate(直接使用4个)

Apache-2.0

1.5MB
24K SLoC

为什么构建R3BL TUI库?

R3BL TUI library

我们正在努力构建Rust的命令行应用程序,这些应用程序具有丰富的文本用户界面(TUI)。我们希望将终端视为一个生产力场所,并为它构建各种精彩的应用程序。

  1. 🔮 我们不是仅仅构建一个应用程序,而是在构建一个库,以实现任何类型的丰富TUI开发,并带有独特的转折:将适用于前端移动和Web开发世界的概念进行重新构思,以适应TUI和Rust。
  • 从React、JSX、CSS、Elm、iced-rs、JetPack Compose中汲取灵感,但使事物变得快速、Rusty和简单。例如,我们不是使用Redux进行复杂的状态管理和处理异步中间件函数,而是简单地使用tokio::mpsc通道,并允许任务向主线程发送信号以重新渲染或将这些信号传递到适当的应用程序逻辑。
  • 由于它是异步的,因此运行主事件循环的线程也不会阻塞。
  • 使用过程宏创建DSL以实现CSS和JSX。
  1. 🌎 我们正在构建应用程序以提高开发者的生产力和工作流程。
  • 这里的想法不是在Rust中重建tmux(将多个进程复用到单个终端窗口)。而是构建一组集成的“应用程序”(或“任务”),它们在同一进程中运行,渲染到一个终端窗口。
  • 在这个终端窗口内部,我们可以实现“应用程序”切换、路由、平铺布局、堆叠布局等,以便我们可以管理许多在同一进程中、同一窗口中运行的紧密集成的TUI应用程序。因此,你可以想象所有这些“应用程序”都共享应用程序状态。每个“应用程序”也可能有自己的本地应用程序状态。
  • 以下是我们要构建的“应用程序”类型的一些示例(该基础设施作为开源引擎运行)
    1. 具有语法高亮的多人文本编辑器。
    2. 与GitHub问题的集成。
    3. 与日历、电子邮件、联系人API的集成。

r3bl-open-core 仓库中的所有crate都提供了许多有用的功能,帮助您构建TUI(文本用户界面)应用程序,同时还提供了Rustaceans(Rust程序员)🦀 可以享受的通用优雅和人体工程学功能 🎉

r3bl_tui crate

此crate包含上述描述的第一个功能。

了解更多关于这个库是如何构建的

🦀 下面是一些关于这个crate是如何制作的文章和视频

  1. developerlife.com教程
  2. developerlife.com的YouTube频道

Rust的文本用户界面引擎

您可以使用现代API构建完全异步的TUI(文本用户界面)应用程序,该API将Web前端开发的最佳理念带给用Rust编写的TUI应用程序

  1. 来自前端开发(React、SolidJS、Elm、iced-rs、Jetpack Compose)的反应式和单向数据流架构。
  2. 具有CSS、flexbox等概念的响应式设计。
  3. 声明式表达样式和布局的风格。

由于使用Rust和Tokio,您可以获得内置的并发和并行优势。不再需要阻塞主线程进行用户输入、异步中间件或甚至渲染 🎉。

此框架是 松散耦合且强内聚的,这意味着您可以随意选择您想要使用的任何部分,而无需承担理解代码库中所有内容的认知负担。它更像是大多数独立模块的集合,这些模块可以很好地协同工作,但彼此之间了解很少。

以下是框架的亮点

  • 一个易于使用和接触的API,灵感来自React、JSX、CSS、Elm。为您提供了大量组件和功能,无需从头开始构建。这是一个功能齐全的组件库,包括
    • 类似于Elm的架构,具有单向数据流。状态是可变的。支持异步中间件函数,并且它们通过异步 tokio::mpsc 通道和信号与主线程和 [App] 通信。
    • 类似于CSS的声明式样式引擎。
    • 类似于CSS的flexbox声明式布局引擎,具有完全响应式。您可以根据需要调整终端窗口的大小,所有内容都将正确布局。
    • 一个与终端无关的底层渲染和绘制引擎(可以使用crossterm、termion或您想要的任何东西)。
    • 支持语法高亮的Markdown文本编辑器,支持元数据(标签、标题、作者、日期)、智能列表。此编辑器使用自定义Markdown解析器和自定义语法高亮器。代码块语法高亮由syntect crate提供。
    • 模态对话框和自动完成对话框。
    • 支持彩虹色轮调色板的 Lolcat(颜色渐变)实现。所有颜色输出都敏感于终端的能力。颜色从真彩色优雅地降级到 ANSI256,再到灰度。
    • 支持字符串中的 Unicode 图形集群。您可以在您的 TUI 应用程序中安全地使用表情符号和其他 Unicode 字符。
    • 支持鼠标事件。
  • TUI 框架本身支持并发和并行(用户输入、渲染等通常是非阻塞的)。
  • 它运行得很快!没有不必要的重新渲染或闪烁。动画和颜色变化平滑(您可以自己运行示例来查看)。您甚至可以分层构建您的 TUI(就像浏览器 DOM 中的 z-顺序)。

入门示例

演示视频

video-gif

这是一段使用此 TUI 引擎构建的 R3BL CMDR 应用程序的演示视频。

rc

在本地运行演示

一旦您将 存储库 克隆到计算机上的一个文件夹中,您可以使用以下命令运行视频中的示例

cd tui/examples
cargo run --release --example demo

这些示例涵盖了整个 TUI API 的范围。您还可以查看源中的测试(tui/src/)。存储库中的 tui 子文件夹 中的单个 nu 脚本 run 允许您轻松构建、运行、测试,以及进行更多操作。

run 脚本在 Linux、macOS 和 Windows 上运行。在 Linux 和 macOS 上,您可以直接运行 ./run 而不是 nu run

Nu shell脚本用于构建、运行、测试等。

命令 描述
nu run help 查看您可以传递给 run 脚本的所有命令
nu run examples 运行所有示例
nu run release-examples 使用发布二进制文件运行所有示例
nu run examples-with-flamegraph-分析 这将运行示例并在结束时生成 flamegraph,以便您可以看到应用程序的性能。有关如何使用此功能的视频
nu run log 查看日志输出。有关如何使用此功能的视频
nu run build 构建
nu run clean 清理
nu run test 运行测试
nu run clippy 运行 clippy
nu run docs 构建文档
nu run serve-docs 在 VSCode Remote SSH 会话中提供文档
nu run rustfmt 运行 rustfmt

以下命令将监视源文件夹中的更改并重新运行

命令 描述
nu run watch-all-tests 监视所有测试
nu run watch-one-test<test_name> 监视一个测试
nu run watch-clippy 监视 clippy
nu run watch-macro-expansion-one-test<test_name> 监视一个测试的宏展开

在存储库的 顶级文件夹 中还有一个 run 脚本。它旨在在 CI/CD 环境中使用,其中提供了所有必要的参数,或者在交互模式下,用户将被提示输入。

命令 描述
nu run all 一次运行所有测试、代码检查、格式化等。在 CI/CD 中使用。
nu run build-full 这将构建 Rust 工作空间中的所有 crate。它将安装所有必需的先决工具(例如,install-cargo-tools 将安装所有必需的先决工具),清除 cargo 缓存,清理,然后进行真正的清洁构建。
nu run install-cargo-tools 这将安装所有必需的先决工具(例如,cargo-denyflamegraph 将一次性安装)。
nu run check-licenses 使用 cargo-deny 来审计 Rust 工作空间中使用的所有许可证

通常布局、渲染和事件处理是如何工作的?

┌──────────────────────────────────────────────────┐
│                                                  │
│  main.rs                                         │
│                             ┌──────────────────┐ │
│  GlobalData ───────────────►│ window size      │ │
│  HasFocus                   │ offscreen buffer │ │
│  ComponentRegistryMap       │ state            │ │
│  App & Component(s)         │ channel sender   │ │
│                             └──────────────────┘ │
│                                                  │
└──────────────────────────────────────────────────┘
  • 构建 TUI 应用程序的主要结构是你的结构体,它实现了 [App] 特性。
  • 主事件循环接受一个 [App] 特性对象并开始监听输入事件。它进入原始模式,并将绘制到一个替代屏幕缓冲区,同时保留你的原始滚动回缓冲区和历史记录。当你退出此 TUI 应用程序时,它将返回你的终端到之前的位置。
  • 在 [main_event_loop] 中,许多全局结构体驻留,这些结构体在应用程序的生命周期中共享。以下是一些包括的内容:
    • [HasFocus]
    • [ComponentRegistryMap]
    • [GlobalData] 包含以下内容:
      • 全局应用程序状态。这是可变的。每当处理输入事件或信号时,整个 [App] 都会重新渲染。这是受 React 和 Elm 启发的单向数据流架构。
  • 你的 [App] 特性实现是布置整个应用程序的主要入口点。在第一次渲染之前,[App] 被初始化(通过调用 App::app_init),并负责创建它所使用的所有 [Component],并将它们保存到 [ComponentRegistryMap]。
    • 状态存储在许多地方。在全局的 [GlobalData] 层级,也在 [App],还在 [Component] 中。
  • 这设置了一切,以便在稍后可以调用 App::app_renderApp::app_handle_input_eventApp::app_handle_signal
  • App::app_render 方法负责通过使用 [Surface] 和 [FlexBox] 来排列 [ComponentRegistryMap] 中的任何 [Component],创建布局。
  • App::app_handle_input_event 方法负责处理从键盘或鼠标检测到用户输入时发送到 [App] 特性的事件。类似地,App::app_handle_signal 处理从后台线程(Tokio 任务)发送到主线程的信号,然后被路由到 [App] 特性对象。通常这会随后被路由到当前具有焦点的 [Component]。

从v0.3.10版本开始,从共享内存切换到消息传递架构

此 crate 的版本 <= 0.3.10 使用共享内存在后台线程和主线程之间进行通信。这是通过使用 tokio 中的异步 Arc<RwLock<T>> 来实现的。状态存储、修改、订阅(变更处理程序)都由 r3bl_redux crate 管理。受 React 启发的 Redux 模式带来了很多心理和性能上的开销(因为每次更改状态时都需要克隆,而 memcpyclone 是昂贵的)。

版本 > 0.3.10 使用消息传递通过 tokio::mpsc 通道(也是异步)在后台线程之间进行通信。鉴于引擎的特性及其要处理的用例,这是一个更简单、性能更高的模型。它还具有提供一种简单方法来在未来通过各种传输层(例如:TCP、IPC等)附加协议服务器的优势;这些协议服务器可以用来管理在运行引擎的进程和运行在同一主机或其他主机上的其他进程之间的连接,以处理同步渲染输出或状态等用例。

以下是一些概述消息传递和共享内存之间差异的论文。

  1. https://rits.github-pages.ucl.ac.uk/intro-hpchtc/morea/lesson2/reading4.html
  2. https://www.javatpoint.com/shared-memory-vs-message-passing-in-operating-system

输入事件的生存期

在这个模块中,关注点有明确的分离。为了说明哪些内容放在哪里以及如何工作,让我们看一个将主事件循环放在前面并处理系统如何处理输入事件(按键或鼠标)的示例。

  • 下面的图表显示了具有 3 个 [组件] 的应用程序,用于(类似于 flexbox)布局和(类似于 CSS)样式。
  • 假设您运行此应用程序(通过假设执行 cargo run)。
  • 然后,您在运行此应用程序的终端窗口中点击或键入一些内容。
┌──────────────────────────────────────────────────────────────────────────┐
│In band input event                                                       │
│                                                                          │
│  Input ──► [TerminalWindow]                                              │
│  Event          ▲      │                                                 │
│                 │      ▼                  [ComponentRegistryMap] stores  │
│                 │   [App]────────────────►[Component]s at 1st render     │
│                 │      │                                                 │
│                 │      │                                                 │
│                 │      │          ┌──────► id=1 has focus                │
│                 │      │          │                                      │
│                 │      ├──► [Component] id=1 ─────┐                      │
│                 │      │                          │                      │
│                 │      └──► [Component] id=2      │                      │
│                 │                                 │                      │
│          default handler                          │                      │
│                 ▲                                 │                      │
│                 └─────────────────────────────────┘                      │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────┐
│Out of band app signal                                      │
│                                                            │
│  App                                                       │
│  Signal ──► [App]                                          │
│               │                                            │
│               │                                            │
│               └──────►Update state                         │
│                       main thread rerender                 │
│                              │                             │
│                              │                             │
│                              └─────►[App]                  │
│                                       │                    │
│                                       └────►[Component]s   │
│                                                            │
└────────────────────────────────────────────────────────────┘

当用户通过按键或鼠标事件生成输入事件时,让我们通过图表追踪其旅程。当应用程序通过 cargo run 启动时,它设置主循环,并布局所有 3 个组件的大小、位置,然后绘制它们。然后它异步监听输入事件(没有线程被阻塞)。当用户输入内容时,此输入通过 [TerminalWindow] 的主循环进行处理。

  1. 当前具有焦点的 [组件] 位于 [FlexBox] 中,其 id=1
  2. 当从用户那里接收输入事件(按键或鼠标输入)时,它首先被路由到 [App],然后 [TerminalWindow] 才查看事件。
  3. [App] 中的事件处理器比 [TerminalWindow] 中的默认输入处理器具有更高的特定性。此外,当前具有焦点的 [组件] 的特定性最高。换句话说,输入事件通过 [App] 路由到当前具有焦点的 [组件](在我们的示例中为 [组件] id=1)。
  4. 由于无法保证某些 [组件] 将具有焦点,因此此输入事件可以由 [App] 处理,如果不行,则由 [TerminalWindow] 的默认处理器处理。如果默认处理器不处理它,则简单地忽略它。
  5. 在这个旅程中,当输入事件在这些不同的实体之间移动时,每个实体都决定是否想要处理输入事件。如果它想要处理,则返回一个枚举表示事件已被消费,否则返回一个枚举表示事件应传播。

输入事件在主事件循环中的主线程中被处理。这是一个同步操作,因此可以直接在这个代码路径中修改状态。这就是为什么没有复杂的锁定。您可以直接在以下位置修改状态:

信号的生存期(也称为“带外事件”)

这对于由用户通过键盘或鼠标生成的事件输入非常适用。这些都被认为是“带内”事件或信号,没有延迟或异步行为。但“带外”信号或事件怎么办?这些信号或事件有未知的延迟和异步行为。同样,也需要处理这些情况。例如,如果你想要发起一个HTTP请求,你不想阻塞主线程。在这种情况下,你可以使用一个tokio::mpsc通道,从后台线程向主线程发送信号。这就是如何处理“带外”事件或信号的方式。

为了支持这些“带外”事件或信号,[App]特质有一个名为App::app_handle_signal的方法。这是处理从后台线程发送的信号的地方。这个关联函数的一个参数是signal。这个信号需要包含在主线程上发生状态变更所需的所有数据。因此,后台线程有责任做一些工作(例如:发起一个HTTP请求),获取一些信息作为结果,然后将这些信息打包成一个signal并发送给主线程。然后主线程通过调用App::app_handle_signal方法来处理这个信号。这个方法可以修改[App]的状态,并返回一个[EventPropagation]枚举,表示主线程是否应该重新绘制UI。

到目前为止,我们已经讨论了[App]接收到信号时会发生什么。谁发送这个信号?谁实际创建了发送这个信号的tokio::spawn任务?这可以在[App]和[Component]的任何地方发生。任何可以访问[GlobalData]的代码都可以使用r3bl_rs_utils_core::send_signal!宏在后台任务中发送信号。然而,只有[App]可以接收信号并对它进行操作,这通常是将信号应用于更新状态,然后告诉主线程重新绘制UI。

现在我们已经对输入事件的生命周期进行了快速概述,下面我们将详细探讨每个部分。

窗口

TUI应用程序的主要构建模块是

  1. [TerminalWindow] - 你可以将其视为应用程序的“主窗口”。应用程序的所有内容都绘制在这个“窗口”内。这个“窗口”概念上对应于你的终端模拟器程序(例如:tilix、Terminal.app等)内部的屏幕。你的TUI应用程序将占用终端模拟器100%的屏幕空间。它还将进入原始模式,并将绘制到一个替代屏幕缓冲区中,以保持你的原始滚动回缓冲区和历史记录不变。当你退出这个TUI应用程序时,它将把你退回到之前的位置。你不需要编写这段代码,这是你可以使用的东西。
  2. [App] - 这是你编写代码的地方。你将一个[App]传递给[TerminalWindow],以启动你的TUI应用程序。如果你有一个简单的应用程序,并且不需要复杂的布局或样式,你只需要使用[App]。但如果你需要布局和样式,现在我们必须处理[FlexBox]、[Component]和crate::Style

布局和样式

在[App]内部,如果你想使用类似flexbox的布局和类似CSS的样式,你可以这样构思你的代码

  1. [[应用]]就像一个盒子或容器。您可以为它附加样式和id。id必须唯一,您可以从样式表中引用任意数量的样式。是的,支持级联样式!👏您可以在盒子内部放置盒子。您可以在容器盒子内部添加其他盒子(您可以给它们一个方向,甚至可以设置相对大小,范围为100%)。
  2. 当您接近布局的“叶子”节点时,您会发现[组件]特征对象。这些是黑色盒子,它们的大小、位置和绘制都是相对于父盒子的。相对处理输入事件并将[渲染操作]渲染到[渲染管道]中。这有点像React中的虚拟DOM。这个命令队列是从所有组件收集的,并在每次渲染时最终绘制到屏幕上!您应用的状态是可变的,并存储在[全局数据]结构中。您还可以使用信号机制处理带外事件。

组件、ComponentRegistry、焦点管理和事件路由

通常,您的[[应用]]看起来像这样

#[derive(Default)]
pub struct AppMain {
  ...
}

当我们更仔细地研究[组件]和[[应用]]时,我们会发现一个有趣的东西[组件注册表](由[[应用]]管理)。存在这个的原因是为了输入事件路由。输入事件被路由到当前具有焦点的[组件]。

[[HasFocus]]结构负责处理这个问题。它提供了两点

  1. 它保存了一个具有焦点的[FlexBox] / [组件]的id
  2. 它还保存了一个映射,该映射保存了每个idcrate::Position。这用于表示一个光标(对于您的应用和组件来说,这意味着什么)。这个光标为每个id维护。这允许每个具有焦点的[组件]有一个单独的光标。这是在焦点切换之间维护光标位置的应用程序(如编辑器和查看器)所必需的。

另一个需要注意的事情是,[[应用]]和[终端窗口]在重新渲染之间是持久的。

输入事件的具体性

[终端窗口]在处理输入事件时给予[[应用]]优先权。[ComponentRegistry::route_event_to_focused_component](https://docs.rs/r3bl_tui/latest/r3bl_tui/?search=ComponentRegistry%3A%3Aroute_event_to_focused_component)可以用来直接将事件路由到具有焦点的组件。如果它拒绝处理此事件,则由默认输入事件处理程序处理。如果没有匹配此事件的内容,则简单地丢弃。

渲染和绘制

R3BL TUI引擎使用高性能合成器将UI渲染到终端。这确保只有改变过的“像素”被绘制到终端。这是通过创建一个表示给定列和行索引位置的终端屏幕中单个“像素”的PixelChar概念来实现的。终端屏幕中的PixelChar的数量与行和列的数量相同。索引直接映射到终端屏幕中像素的位置。

离屏缓冲区

以下是一个[[离屏缓冲区]]中单行渲染输出的示例。此图显示[离屏缓冲区]的row_index: 1中的每个PixelChar。在这个例子中,终端屏幕中有80列。这是TUI引擎启用日志记录时生成的实际日志输出。

row_index: 1
000 S ░░░░░░░╳░░░░░░░░001 P    'j'→fg‐bg    002 P    'a'→fg‐bg    003 P    'l'→fg‐bg    004 P    'd'→fg‐bg    005 P    'k'→fg‐bg
006 P    'f'→fg‐bg    007 P    'j'→fg‐bg    008 P    'a'→fg‐bg    009 P    'l'→fg‐bg    010 P    'd'→fg‐bg    011 P    'k'→fg‐bg
012 P    'f'→fg‐bg    013 P    'j'→fg‐bg    014 P    'a'→fg‐bg    015 P     '▒'→rev     016 S ░░░░░░░╳░░░░░░░░017 S ░░░░░░░╳░░░░░░░░
018 S ░░░░░░░╳░░░░░░░░019 S ░░░░░░░╳░░░░░░░░020 S ░░░░░░░╳░░░░░░░░021 S ░░░░░░░╳░░░░░░░░022 S ░░░░░░░╳░░░░░░░░023 S ░░░░░░░╳░░░░░░░░
024 S ░░░░░░░╳░░░░░░░░025 S ░░░░░░░╳░░░░░░░░026 S ░░░░░░░╳░░░░░░░░027 S ░░░░░░░╳░░░░░░░░028 S ░░░░░░░╳░░░░░░░░029 S ░░░░░░░╳░░░░░░░░
030 S ░░░░░░░╳░░░░░░░░031 S ░░░░░░░╳░░░░░░░░032 S ░░░░░░░╳░░░░░░░░033 S ░░░░░░░╳░░░░░░░░034 S ░░░░░░░╳░░░░░░░░035 S ░░░░░░░╳░░░░░░░░
036 S ░░░░░░░╳░░░░░░░░037 S ░░░░░░░╳░░░░░░░░038 S ░░░░░░░╳░░░░░░░░039 S ░░░░░░░╳░░░░░░░░040 S ░░░░░░░╳░░░░░░░░041 S ░░░░░░░╳░░░░░░░░
042 S ░░░░░░░╳░░░░░░░░043 S ░░░░░░░╳░░░░░░░░044 S ░░░░░░░╳░░░░░░░░045 S ░░░░░░░╳░░░░░░░░046 S ░░░░░░░╳░░░░░░░░047 S ░░░░░░░╳░░░░░░░░
048 S ░░░░░░░╳░░░░░░░░049 S ░░░░░░░╳░░░░░░░░050 S ░░░░░░░╳░░░░░░░░051 S ░░░░░░░╳░░░░░░░░052 S ░░░░░░░╳░░░░░░░░053 S ░░░░░░░╳░░░░░░░░
054 S ░░░░░░░╳░░░░░░░░055 S ░░░░░░░╳░░░░░░░░056 S ░░░░░░░╳░░░░░░░░057 S ░░░░░░░╳░░░░░░░░058 S ░░░░░░░╳░░░░░░░░059 S ░░░░░░░╳░░░░░░░░
060 S ░░░░░░░╳░░░░░░░░061 S ░░░░░░░╳░░░░░░░░062 S ░░░░░░░╳░░░░░░░░063 S ░░░░░░░╳░░░░░░░░064 S ░░░░░░░╳░░░░░░░░065 S ░░░░░░░╳░░░░░░░░
066 S ░░░░░░░╳░░░░░░░░067 S ░░░░░░░╳░░░░░░░░068 S ░░░░░░░╳░░░░░░░░069 S ░░░░░░░╳░░░░░░░░070 S ░░░░░░░╳░░░░░░░░071 S ░░░░░░░╳░░░░░░░░
072 S ░░░░░░░╳░░░░░░░░073 S ░░░░░░░╳░░░░░░░░074 S ░░░░░░░╳░░░░░░░░075 S ░░░░░░░╳░░░░░░░░076 S ░░░░░░░╳░░░░░░░░077 S ░░░░░░░╳░░░░░░░░
078 S ░░░░░░░╳░░░░░░░░079 S ░░░░░░░╳░░░░░░░░080 S ░░░░░░░╳░░░░░░░░spacer [ 0, 16-80 ]

当执行 RenderOps 并用于创建与终端窗口大小对应的 OffscreenBuffer 时,会自动执行裁剪。这意味着无法将光标移动到视口(终端窗口大小)之外。并且无法绘制比离屏缓冲区更大的文本。缓冲区实际上代表视口的当前状态。滚动必须由组件本身处理(例如,编辑器组件)。

每个 PixelChar 可以是以下四种之一

  1. 空格。这只是一个空的空间。在 TUI 引擎中没有闪烁。当创建新的离屏缓冲区时,它会全部填充空格。然后组件会在这些空间上绘制。然后,差异算法只会绘制改变的像素。您不需要担心清除屏幕和绘制,这在终端中通常会导致闪烁。您也不必担心在渲染之间清除您想要清除的区域。所有这些都由 TUI 引擎处理。
  2. 。这是一个特殊的像素,用于指示该像素应被忽略。它用于指示一个宽表情符号在某个地方。大多数终端不支持表情符号,所以字符的显示宽度和其在字符串中的索引之间存在差异。
  3. 纯文本。这是一个普通像素,它包装了一个字符,可能是图形簇片段。样式信息编码在每个 PixelChar::PlainText 中,并用于通过差异算法绘制屏幕,该算法足够智能,可以将相邻出现的样式“堆叠”在一起,以便在终端中更快地渲染。

渲染管道

以下图表提供了对如何将包含组件的应用(可能包含组件,依此类推)渲染到终端屏幕的高层次概述。

┌──────────────────────────────────┐
│ Container                        │
│                                  │
│ ┌─────────────┐  ┌─────────────┐ │
│ │ Col 1       │  │ Col 2       │ │
│ │             │  │             │ │
│ │             │  │     ────────┼─┼──────────► RenderPipeline─────┐
│ │             │  │             │ │                               │
│ │             │  │             │ │                               │
│ │      ───────┼──┼─────────────┼─┼──────────► RenderPipeline─┐   │
│ │             │  │             │ │                           │   │
│ │             │  │             │ │                           ▼ + ▼
│ │             │  │             │ │                  ┌─────────────────────┐
│ └─────────────┘  └─────────────┘ │                  │                     │
│                                  │                  │  OffscreenBuffer    │
└──────────────────────────────────┘                  │                     │
                                                      └─────────────────────┘

每个组件生成一个 RenderPipeline,它是一个 ZOrderVec<RenderOps> 的映射。 RenderOps 是组合在一起的指令,例如将光标移动到某个位置、设置颜色并绘制一些文本。

RenderOps 内部,光标是状态的,这意味着在每次 RenderOp 执行后都会记住光标位置。然而,一旦执行了新的 RenderOps,光标位置将仅重置为该 RenderOps。光标位置不会全局存储。您应该在 RenderOp 文档中阅读有关“原子绘制操作”的更多信息。

一旦生成了这些 RenderPipeline 集合,通常在用户输入某些输入事件并产生新的状态后,该状态必须被渲染,然后它们将被组合并绘制到一个 OffscreenBuffer 中。

第一次渲染

paint.rs 文件包含 paint 函数,这是所有渲染的入口点。一旦发生第一次渲染,生成的 OffscreenBuffer 将保存到 GlobalSharedState。以下表格显示了渲染到 OffscreenBuffer 时必须执行的各种任务。对于 ANSI 文本和平文文本(包括 StyledText,它只是带颜色的普通文本)有不同的代码路径。语法高亮的文本也只是 StyledText

UTF-8 任务
Y RenderPipeline 转换为 List<List<PixelChar>> (OffscreenBuffer)
Y 使用 OffscreenBufferPainterImplCrosstermList<List<PixelChar>> 中的每个 PixelChar 绘制到 stdout
Y List<List<PixelChar>> 保存到 GlobalSharedState

目前仅支持 crossterm 在终端上进行实际绘制。但这个过程非常简单,使得替换其他终端库(如 termion)或GUI后端,甚至其他自定义输出驱动变得非常容易。

后续渲染

由于 OffscreenBuffer 存储在 GlobalSharedState 中,后续渲染将执行差异。只有那些差异块被绘制到屏幕上。这确保了屏幕内容改变时没有闪烁。它还最大限度地减少了终端或终端模拟器将 PixelChar 放到屏幕上所需的工作量。

编辑器组件是如何工作的?

EditorComponent 结构可以持有自己的内存中的数据,除了依赖于状态。

  • 它有一个 EditorEngine,该引擎包含语法高亮信息以及编辑器的配置选项(例如,是否启用多行模式,是否启用语法高亮等)。注意,这些信息存在于状态之外。
  • 它还实现了 Component<S, SA> 特性。
  • 然而,对于可重用的编辑器组件,我们需要将表示正在编辑的文档的数据存储在状态(EditorBuffer)中,而不是在 EditorComponent 本身内部。
    • 这就是为什么状态必须实现特性 HasEditorBuffers 的原因,其中存储了文档数据(键是放置编辑器组件的弹性框的 ID)。
    • EditorBuffer 包含在 Vec 中的文本内容 UnicodeString。其中每一行由一个 UnicodeString 表示。它还包含滚动偏移量,光标位置和文件扩展名用于语法高亮。

换句话说,

  1. EditorEngine -> 这属于 EditorComponent
    • 包含处理按键并修改编辑器缓冲区的逻辑。
  2. EditorBuffer -> 这属于 State
    • 包含表示正在编辑的文档的数据。这包含光标(插入点)位置和滚动位置。未来可以包含许多其他信息,如撤销/重做历史等。

以下是与 EditorComponentComponent<S, AS> 实现的连接点:

  1. handle_event(global_data: &mut GlobalData<S, AS>,input_event:InputEvent,has_focus: &mutHasFocus)
    • 可以简单地将参数传递给 EditorEngine::apply(state.editor_buffer, input_event),这将返回另一个 EditorBuffer
    • 返回值可以通过动作派发到商店,动作如下:UpdateEditorBuffer(EditorBuffer)
  2. 渲染(global_data: &mut GlobalData<S, AS>,当前盒子:FlexBox,表面边界:SurfaceBounds,has_focus: &mutHasFocus,)
    • 可以直接将参数传递给 EditorEngine::render(state.editor_buffer)
    • 这将返回一个 RenderPipeline

绘制光标

定义

Caret - 在终端中可视显示的块,代表任何焦点中的插入点。尽管本地用户只有一个可编辑的插入点,但可能会有多个,这时需要有一种方法来区分本地光标和远程光标(可以通过背景颜色实现)。

Cursor - 终端提供的全局“事物”,通常通过闪烁显示光标的位置。此光标在终端窗口中移动,然后在不同的区域执行绘制操作以绘制渲染操作的输出。

显示光标有两种相当不同的方式(每种都有不同的约束)。

  1. 使用全局终端光标(我们不使用这种)。

    • termion::cursor 和 crossterm::cursor 都支持这种方式。光标有很多效果,如闪烁等。
    • 缺点是任何给定终端窗口都只有一个全局光标。并且为了绘制任何内容(例如:MoveTo(col, row), SetColor, PaintText(...) 序列)而不断移动。
  2. 通过将光标处的字符用颜色反转(或某些其他背景颜色)来绘制,从而产生光标视觉效果。

    • 这种方法的优点是我们可以在应用程序中显示多个光标,因为这不是全局的,而是组件特定的。对于需要显示多个光标的多用户编辑用例(例如Google Docs风格的编辑),可以使用这种方法来实现。例如,每个用户都可以获得不同的光标背景颜色,以区分自己的光标。
    • 缺点是不可能闪烁光标或具有上面讨论的全球光标提供的所有其他“标准”光标功能。

模态对话框是如何工作的?

模态对话框与普通可重用组件不同。这是因为

  1. 它在整个屏幕上绘制(在所有其他组件之前,在 ZOrder::Glass 之上,并且不在使用 FlexBox 的任何布局之外)。
  2. 通过键盘快捷键“激活”(否则隐藏)。一旦激活,用户可以接受或取消对话框。这会导致调用回调并带有结果。

因此,必须在 App 特性实现级别(在 app_handle_event() 方法)上执行此激活触发。

  1. 当检测到触发器时,通过信道发送者(带外)发送信号,以便在处理该信号时显示。
  2. 当处理信号时,将焦点设置到对话框,并返回一个 EventPropagation::ConsumedRerender,这将重新绘制带有对话框的UI。

关于用户响应(一旦显示对话框)去哪里的问题?这似乎与 EditorComponent 的性质不同,但它是一样的。以下是原因:

  • EditorComponent 会根据用户输入不断更新其缓冲区,当用户在编辑器上执行某些操作时,没有“处理器”。编辑器需要将缓冲区中的所有更改保存到状态中。这要求状态实现 HasEditorBuffers 特征。
  • 对话框看起来不同,因为你可能会认为它不会总是更新其状态,我们真正关心对话框状态的时候,只是当用户接受他们在对话框中输入的内容,并将其发送到在创建组件时传入的回调函数。然而,由于 TUI 引擎的响应性,甚至在回调被调用之前(由于用户接受或取消),当用户在对话框中输入内容时,它必须更新状态,否则不会触发对话框的重绘,用户将看不到他们输入的内容。这意味着即使中间信息也需要通过 HasDialogBuffers 特征记录到状态中。这将在对话框关闭或接受后保留旧数据,但这没关系,因为标题和文本应该始终在显示之前设置。
    • 注意:可能可以将此类中间数据保存到 ComponentRegistry::user_data 中。并且,handle_event() 可以返回一个 EventPropagation::ConsumedRerender,以确保更改被重绘。这种做法可能存在其他问题,如果不够小心,可能会导致组件注册表的部分同时具有不可变和可变借用。

两个回调函数

创建新的对话框组件时,会传入两个回调函数

  1. on_dialog_press_handler() - 如果用户选择否或是(带他们输入的文本),将会被调用。
  2. on_dialog_editors_changed_handler() - 如果用户在编辑器中输入内容,将会被调用。

如何使用此对话框进行HTTP请求并将结果管道传输到选择区域?

到目前为止,我们已经讨论了简单模态对话框的使用场景。为了提供自动完成功能,需要通过某种类型的网络服务实现一个稍微复杂一些的版本。这就是 DialogEngineConfigOptions 结构的作用所在。它允许我们创建一个可以配置为简单或自动完成的对话框组件和引擎。

在自动完成模式下,会显示一个额外的“结果面板”,并且对话框在屏幕上的布局也不同。它不是在屏幕中央,而是从屏幕顶部开始。回调函数是相同的。

如何进行HTTP请求

我们应该使用 hyper 包(它是 Tokio 的一部分)而不是使用 reqwest 包,并取消在我们所有包中对 reqwest 的支持。

自定义 Markdown(MD)解析和自定义语法高亮

解析和语法高亮的代码在 [try_parse_and_highlight] 中。

提供了一个自定义 Markdown 解析器,以提供一些标准 Markdown 语法之外的扩展。解析器代码在 [parse_markdown] 函数中。以下是一些扩展示例:

  • 元数据标题(例如:@title: <title_text>)。类似于 front matter。
  • 元数据标签(例如:@tags: <tag1>, <tag2>)。
  • 元数据作者(例如:@authors: <author1>, <author2>)。
  • 元数据日期(例如:@date: <date>)。

其他一些更改是增加了对智能列表的支持。这些列表跨越多行文本。并且跟踪缩进级别。这些信息用于以视觉上吸引人的方式渲染列表项。

此外,syntect crate 仍然被编辑器组件 EditorEngineApi::render_engine 使用来为 Markdown 文档中的代码块进行语法高亮。

考虑了使用 crate markdown-rs 的另一种方法来做这件事,但我们决定使用 nom 实现我们自己的解析器,因为它支持流式处理并且使用了更少的 CPU 和内存。

支持图形符号

支持 Unicode(在一定程度上)。有一些注意事项。关于这些图形符号以及支持和不支持的内容,crate::UnicodeStringExt trait 有大量有用的信息。

支持Lolcat

提供了一个 lolcat 颜色轮的实现。以下是一个示例。

use r3bl_rs_utils_core::*;
use r3bl_tui::*;

let mut lolcat = LolcatBuilder::new()
  .set_color_change_speed(ColorChangeSpeed::Rapid)
  .set_seed(1.0)
  .set_seed_delta(1.0)
  .build();

let content = "Hello, world!";
let unicode_string = UnicodeString::from(content);
let lolcat_mut = &mut lolcat;
let st = lolcat_mut.colorize_to_styled_texts(&unicode_string);

lolcat.next_color();

build() 返回的 crate::lolcat::Lolcat 是可以安全重用的。

  • 它循环的这些颜色是“稳定的”,这意味着一旦通过 builder(该 builder 设置了速度、种子和 delta,它们决定了当颜色轮被使用时颜色轮从哪里开始)构建,那么在对话框组件中使用时,重复调用该组件的 render() 函数将会产生相同的生成颜色。
  • 如果您想改变颜色轮“开始”的位置,您必须改变这个 crate::lolcat::Lolcat 实例的速度、种子和 delta。

问题、评论、反馈和PR

请向 问题追踪器 报告任何问题。如果您有任何功能请求,也请随意添加 👍。

依赖项

~19–31MB
~377K SLoC