Xây dựng trình soạn thảo văn bản Terminal: Tìm hiểu về lớp View (Phần 3)

06 tháng 4, 2026·8 phút đọc

Bài viết này là phần 3 trong series xây dựng trình soạn thảo văn bản trên Terminal, tập trung vào thành phần View. Tác giả giải thích cách thức hiển thị dữ liệu, lý do chọn thư viện FTXUI thay vì ncurses, cùng cách xử lý con trỏ và bản dịch sự kiện bàn phím trong kiến trúc Model-View-Presenter.

Xây dựng trình soạn thảo văn bản Terminal: Tìm hiểu về lớp View (Phần 3)

Trong Phần 1, tôi đã trình bày về nguồn gốc của dự án, các quyết định kiến trúc ban đầu và lý do tôi chọn GapBuffer với giao diện IBuffer làm hợp đồng cho tất cả các triển khai buffer sau này. Ở Phần 2, chúng ta đã đi sâu vào chi tiết của Presenter, cách nó kết nối các thành phần, xử lý đầu vào của người dùng, quản lý trạng thái và giữ cho Model và View đồng bộ. Bây giờ, chúng ta hãy cùng chuyển sang phần cuối cùng: View.

View là thành phần mà người dùng tương tác trực tiếp. Mọi đầu vào của người dùng đều được thu thập và chuyển đến Presenter; View không quyết định sẽ làm gì với chúng. View cũng chịu trách nhiệm hiển thị văn bản người dùng gõ, cùng với các cảnh báo, thống kê như số lượng từ và liệu tệp đã được lưu hay chưa. View không thực hiện bất kỳ phép tính nào, nó chỉ hiển thị bất cứ thứ gì Presenter gửi tới. Đây là một ví dụ khác về cách ba phần của mô hình Model-View-Presenter tách biệt các mối quan tâm.

Tại sao chọn FTXUI thay vì ncurses

Trong thử nghiệm khái niệm (proof of concept), tôi đã sử dụng ncurses, thư viện chuẩn thực tế để xây dựng các giao diện terminal. Nó hoạt động tốt cho các bài kiểm tra ban đầu và tôi thấy nhiều dự án vẫn đang sử dụng nó; đây là một lựa chọn đã được kiểm chứng. Mặc dù ncurses sẽ là một lựa chọn tuyệt vời, tôi đã tìm hiểu thêm một chút và tìm thấy FTXUI, một thư viện C++ hiện đại với các thành phần để xây dựng giao diện người dùng terminal tương tác. Tôi muốn tận dụng việc nó sử dụng C++ hiện đại, phù hợp với thông số kỹ thuật ban đầu của dự án là hướng tới C++20.

Giao diện IView

Trong khi triển khai các thành phần khác (Model và Presenter), tôi đã tạo một giao diện mỏng cho View, đó là IView. Ban đầu, nó chỉ nhận đầu vào ký tự và không tính đến các ký tự đặc biệt, cũng như không xử lý việc hiển thị bất kỳ dữ liệu nào. View trong bài kiểm tra ban đầu chỉ lấy đầu vào, gửi nó cho Presenter, sau đó Presenter sẽ gửi nó đến buffer và ghi log để tôi có thể thấy dữ liệu thực sự đang chảy qua hệ thống.

Tôi đã từng nghĩ đến việc loại bỏ nó, nhưng sau khi tìm hiểu thêm, tôi quyết định mở rộng giao diện này thay thế. Việc giữ IView dưới dạng một lớp trừu tượng (abstract class) có nghĩa là nếu ai đó muốn viết View của riêng mình cho một hệ điều hành không được FTXUI hỗ trợ, họ có thể triển khai giao diện này và cắm nó vào mà không cần chạm vào Presenter hay Model.

class IView {
  public:
    virtual ~IView() = default;

    virtual void run(std::function<void(const InputEvent &)> onInput) = 0;
    virtual void render(const ViewState &state) = 0;
    virtual void exit() = 0;
    virtual std::pair<int, int> getTerminalSize() const = 0;
    virtual void showMessage(const std::string &message, bool isError = false) = 0;
};

Để giải thích ngắn gọn: run() khởi động vòng lặp sự kiện và nhận một hàm callback để xử lý đầu vào, đây là những gì Presenter gọi trong vòng lặp chính của nó. render() nhận ViewState mà chúng ta đã thấy ở Phần 2 và vẽ nó lên màn hình. exit()getTerminalSize() làm đúng như tên gọi của chúng, và showMessage() là cách Presenter hiển thị các cảnh báo như lời nhắc "thay đổi chưa được lưu" khi thoát.

Hiển thị giao diện người dùng (UI)

Như chúng ta đã thấy ở Phần 2, struct ViewState định nghĩa hợp đồng về cách Presenter gửi dữ liệu đến View:

struct ViewState {
    // Nội dung
    std::string visibleText; // Văn bản hiện đang hiển thị trong khung nhìn
    int cursorPosition; // Vị trí tuyến tính của con trỏ trong visibleText

    // Thông tin thanh trạng thái
    int wordCount; // Tổng số từ (số liệu chính cho người viết)
    std::string filename; // Tên tệp hiện tại (hoặc "Untitled")
    bool isDirty; // Chỉ báo thay đổi chưa được lưu

    // Trạng thái UI
    std::string statusMessage; // Thông báo trạng thái tạm thời (ví dụ: "Saved", "Error: ...")
    bool showHelp; // Có hiển thị lớp trợ giúp hay không
};

Có ba loại dữ liệu chính trong hợp đồng ViewState: Nội dung (Content), cho chúng ta biết cursorPositionvisibleText. Thông tin thanh trạng thái (Status bar information): wordCount, filename, và isDirty cho biết liệu tệp chưa được lưu hay không. Cuối cùng là Trạng thái UI (UI state): statusMessageshowHelp.

Một trong những thách thức lớn nhất là cách xử lý con trỏ, di chuyển nó và có thể thêm nhiều nội dung hơn vào buffer khi con trỏ di chuyển. Một trong những giải pháp đơn giản nhất là chia visibleText thành ba thành phần: beforeCursor (trước con trỏ), cursor (con trỏ), và afterCursor (sau con trỏ).

ftxui::Element FtxuiView::renderEditor() {
    const auto &text = currentState.visibleText;
    const size_t pos = static_cast<size_t>(currentState.cursorPosition);

    auto before = ftxui::text(text.substr(0, pos));

    // Ký tự tại con trỏ với nền màu xanh lơ (cyan)
    std::string cursorChar = pos < text.size() ? std::string(1, text[pos]) : " ";
    auto cursor = ftxui::text(cursorChar) | ftxui::bgcolor(ftxui::Color::Cyan) | ftxui::color(ftxui::Color::Black);

    auto after = pos + 1 < text.size() ? ftxui::text(text.substr(pos + 1)) : ftxui::text("");

    return ftxui::hbox({before, cursor, after});
}

Con trỏ được làm nổi bật với nền màu cyan để người dùng có thể thấy vị trí của họ trong văn bản. Thách thức này tồn tại trong cả ncurses và FTXUI; không có thư viện nào cung cấp con trỏ có sẵn cho trình soạn thảo văn bản tùy chỉnh. Bạn phải tự mô phỏng nó.

Dịch sự kiện bàn phím

View cũng chịu trách nhiệm chuyển đổi các sự kiện bàn phím thô thành các struct InputEvent mà Presenter hiểu được. FTXUI có hệ thống sự kiện riêng, vì vậy View cần một lớp dịch chuyển giữa những gì FTXUI cung cấp và những gì Presenter mong đợi. Phương thức translateEvent() xử lý việc ánh xạ này:

InputEvent FtxuiView::translateEvent(const ftxui::Event &event) {
    if (event == ftxui::Event::ArrowLeft)
        return {InputEvent::Type::ARROW_LEFT};
    if (event == ftxui::Event::ArrowRight)
        return {InputEvent::Type::ARROW_RIGHT};
    if (event == ftxui::Event::Backspace)
        return {InputEvent::Type::BACKSPACE};
    if (event == ftxui::Event::Return)
        return {InputEvent::Type::ENTER};
    if (event == ftxui::Event::CtrlQ)
        return {InputEvent::Type::CTRL_Q};
    if (event == ftxui::Event::CtrlS)
        return {InputEvent::Type::CTRL_S};

    // Ký tự có thể in được
    if (event.is_character()) {
        InputEvent ie{InputEvent::Type::CHARACTER};
        ie.character = event.character()[0];
        return ie;
    }

    return {InputEvent::Type::UNKNOWN};
}

Mô hình này rất đơn giản: mỗi sự kiện FTXUI ánh xạ tới một trong các loại InputEvent của chúng ta. Nếu đó là một ký tự có thể in được, chúng ta trích xuất giá trị ký tự. Nếu chúng ta không nhận ra sự kiện, nó sẽ trả về UNKNOWN và bị bỏ qua. Đây chính là lớp dịch chuyển sẽ cần được triển khai lại nếu ai đó viết một View mới sử dụng thư viện khác, một lợi ích khác của giao diện IView.

Khả năng hiện tại của View

View hoạt động tốt cho trạng thái hiện tại của dự án. Nó hiển thị văn bản khi người dùng gõ, hiển thị thanh trạng thái với tên tệp, số lượng từ và chỉ báo thay đổi chưa được lưu, bắt giữ tất cả các sự kiện đầu vào và định tuyến chúng đến Presenter, cũng như chuyển đổi lớp trợ giúp bằng phím F1. Nó chưa hoàn hảo, xuống dòng chưa được hiển thị và các phím mũi tên chưa thể điều hướng hoàn toàn lên và xuống qua văn bản. Nhưng đối với Giai đoạn 1 (Phase 1), nó hoàn thành tốt vai trò là lớp thụ động trong mô hình MVP.

Tổng kết Series

Điều này kết thúc loạt bài gồm ba phần về việc xây dựng Giai đoạn 1 của wordNebula. Qua các bài viết này, chúng ta đã đi từ nguồn gốc của dự án và lý do tôi chọn Model-View-Presenter, qua Model với GapBuffer và giao diện IBuffer, Presenter điều phối mọi thứ và quản lý trạng thái, và cuối cùng là View hiển thị tất cả lên terminal.

Việc xây dựng từng lớp một cách độc lập và kết nối chúng thông qua các hợp đồng như IBufferIView đã làm cho dự án trở nên dễ quản lý. Tôi có thể làm việc trên một phần tại một thời điểm mà không lo ngại việc phá vỡ các phần khác. Sự tách biệt đó chính là toàn bộ ý nghĩa của MVP, và nó đã được đền đáp.

Giai đoạn 1 đã hoàn tất nhưng vẫn còn xa mới hoàn thiện. Còn nhiều thứ để xây dựng, nhưng hiện tại tôi sẽ tạm dừng để tập trung vào các dự án khác. Nếu bạn muốn khám phá mã nguồn, hãy kiểm tra dự án trên GitHub.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗