Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc68102bcd | ||
|
|
9b0d21a0ea | ||
|
|
e9e5809d61 | ||
|
|
43aaef9d43 | ||
|
|
51c2b2a49a | ||
|
|
63cae4765a | ||
|
|
48cd360e37 | ||
|
|
aa8a0624fc | ||
|
|
5f0f059139 | ||
|
|
6756340cb0 | ||
|
|
ed23d6bc35 | ||
|
|
a13c192d65 | ||
|
|
b06035c216 | ||
|
|
143ba5ca58 |
35 changed files with 7470 additions and 105 deletions
29
.github/workflows/release.yml
vendored
Normal file
29
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y build-essential pkg-config libx11-dev libxtst-dev \
|
||||
libdbus-1-dev libevdev-dev libwayland-dev
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build binaries
|
||||
run: |
|
||||
cargo build --release
|
||||
(cd ui && cargo build --release)
|
||||
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst
|
||||
- name: Package tarball
|
||||
run: bash packaging/build-tarball.sh
|
||||
- name: Upload tarball to release
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" target/dist/*.tar.gz
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
# Changelog
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Distro Support
|
||||
|
|
@ -11,7 +15,7 @@
|
|||
|
||||
### Documentation
|
||||
|
||||
- **Roadmap section** added to README (v0.1.8: Wayland IM protocol, event-based AT-SPI2; v0.1.9: CI, Flatpak).
|
||||
- **Roadmap section** added to README (v0.1.22: Wayland IM protocol, event-based AT-SPI2; v0.1.23: CI, Flatpak).
|
||||
- **RELEASE_CHECKLIST.md** removed (process now documented in the release commit messages).
|
||||
|
||||
---
|
||||
|
|
|
|||
180
CHANGELOG.vi.md
Normal file
180
CHANGELOG.vi.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Nhật ký thay đổi (Changelog)
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">English</a>
|
||||
</p>
|
||||
|
||||
## Chưa phát hành (Unreleased)
|
||||
|
||||
### Hỗ trợ Bản phân phối (Distro Support)
|
||||
|
||||
- **Bảng hỗ trợ Distro**: README hiện đã liệt kê các distro hỗ trợ tốt (Ubuntu, Debian, Mint, Pop!_OS, elementary, Zorin, Neon, Fedora, RHEL, CentOS, Arch, Manjaro), có thể hỗ trợ (openSUSE, Solus, Void), và chưa hỗ trợ (NixOS, Alpine, Gentoo).
|
||||
- **libwayland-dev** được thêm vào install.sh cho tất cả các họ distro (trước đây bị thiếu — gây lỗi biên dịch trên các hệ thống chỉ có X11 như Linux Mint).
|
||||
- **libwayland-client0** được thêm vào các gói phụ thuộc runtime (trước đây bị thiếu — gây lỗi "cannot open shared object file" trên Mint).
|
||||
- **Sửa lỗi chính tả cấu hình**: `mặt khẩu` → `mật khẩu` trong cấu hình mặc định và README.
|
||||
|
||||
### Tài liệu hướng dẫn (Documentation)
|
||||
|
||||
- Thêm mục **Roadmap** vào README (v0.1.22: Giao thức Wayland IM, AT-SPI2 hướng sự kiện; v0.1.23: CI, Flatpak).
|
||||
- Loại bỏ **RELEASE_CHECKLIST.md** (quy trình phát hành hiện được ghi nhận trong nội dung các commit phát hành).
|
||||
|
||||
---
|
||||
|
||||
## v0.1.7 (01-07-2026)
|
||||
|
||||
### Tự động nhận diện mật khẩu (Password Auto-Detection)
|
||||
|
||||
- **Tích hợp AT-SPI2 D-Bus**: Truy vấn `org.a11y.atspi.Accessible.GetRole` trên a11y bus (không phải session bus) để phát hiện các trường mật khẩu. Hoạt động trên các hộp thoại mật khẩu GUI và các ứng dụng có bật hỗ trợ tiếp cận (a11y).
|
||||
- **Phát hiện sudo qua cây tiến trình**: Quét `pstree` để tìm các tiến trình `sudo`/`passwd` — tự động tắt tiếng Việt khi có yêu cầu sudo xuất hiện trong terminal.
|
||||
- **Dự phòng tiêu đề cửa sổ**: Các cửa sổ có tiêu đề chứa "password", "sudo", "mật khẩu" sẽ tự động chuyển sang chế độ gõ tiếng Anh.
|
||||
- **Dự phòng lớp cửa sổ (Window class)**: Nhận diện các hộp thoại mật khẩu phổ biến (pinentry, polkit, kwallet) thông qua danh sách ứng dụng `password_apps` trong cấu hình.
|
||||
- **Kiểm tra định kỳ**: Đánh giá lại trạng thái trường mật khẩu sau mỗi 30 phím gõ (giúp phát hiện kịp thời các prompt nhập mật khẩu xuất hiện trong terminal).
|
||||
|
||||
### Phương thức gõ Telex (Telex Input Method)
|
||||
|
||||
- **Hỗ trợ đầy đủ Telex**: Cả hai phương thức gõ VNI và Telex hiện đã được hỗ trợ toàn diện. Chuyển đổi nhanh qua Ctrl+Shift hoặc menu khay hệ thống "Input Method > Telex / VNI".
|
||||
- **Tệp lưu phương thức gõ** (`~/.config/vietc/method`): Tiến trình nền (daemon) ghi phương thức gõ hiện tại; khay hệ thống đọc tệp này để hiển thị icon tương ứng.
|
||||
- **Biểu tượng khay hệ thống**: Màu đỏ "VN" cho VNI, màu xanh dương "TLX" cho Telex, màu xám "EN" cho chế độ tiếng Anh.
|
||||
- **Cấu hình**: Phím nóng `toggle_method_key = "shift"` dùng để thiết lập tổ hợp phím đổi phương thức gõ.
|
||||
|
||||
### Hỗ trợ GNOME/Wayland (GNOME/Wayland Support)
|
||||
|
||||
- **Tích hợp D-Bus của GNOME Shell**: Truy vấn `org.gnome.Shell.Eval` để lấy thông tin về lớp cửa sổ (window class), ID, tiêu đề và PID của ứng dụng đang hoạt động — giải pháp thay thế hoàn hảo trên Wayland GNOME nơi xdotool/xprop không khả dụng.
|
||||
- **Chuỗi nhận diện cửa sổ**: GNOME Shell D-Bus → xprop → wlrctl → xdotool → wmctrl → /proc — hoạt động ổn định trên mọi môi trường máy tính.
|
||||
- **Nhận diện Compositor**: Tự động phát hiện GNOME/Mutter qua `pgrep gnome-shell` và `XDG_CURRENT_DESKTOP`.
|
||||
- **Thư viện phụ thuộc**: Sử dụng thư viện `dbus` (0.9) để giao tiếp với AT-SPI2 và GNOME Shell D-Bus.
|
||||
|
||||
### Chiếm quyền bàn phím an toàn (Keyboard Grab Safety)
|
||||
|
||||
- **Sử dụng sigaction không có SA_RESTART**: Tổ hợp Ctrl+C và tín hiệu SIGTERM hiện đã có thể ngắt lệnh đọc evdev đang bị chặn, giải phóng quyền chiếm giữ bàn phím trước khi thoát.
|
||||
- **Tự động tải uinput**: Bộ giả lập sẽ tự chạy lệnh `modprobe uinput` trước khi mở `/dev/uinput`.
|
||||
- **Xử lý EINTR**: Bắt các cuộc gọi hệ thống bị ngắt quãng và tiến hành kiểm tra lại cờ tín hiệu hệ thống.
|
||||
- **Thời gian chờ an toàn 30 giây**: Tự động giải phóng quyền chiếm giữ bàn phím nếu không nhận được sự kiện nào sau 30 giây (tránh việc người dùng bị khóa bàn phím vĩnh viễn khi bộ gõ gặp sự cố).
|
||||
|
||||
### Clipboard & Giả lập nhập liệu (Clipboard & Injection)
|
||||
|
||||
- **Tối ưu hóa `wl-copy --paste-once`**: Giữ tiến trình clipboard hoạt động cho đến khi thao tác dán được thực hiện xong, loại bỏ hoàn toàn độ trễ từ 300-900ms trên môi trường Wayland/GNOME.
|
||||
- **Tắt log SelectionRequest trên X11**: Loại bỏ hoàn toàn các dòng log rác liên quan đến clipboard trong terminal.
|
||||
- **Ưu tiên uinput**: Giả lập qua uinput luôn được ưu tiên hơn so với giả lập qua X11 XTest.
|
||||
|
||||
### Thay đổi cấu hình (Config Changes)
|
||||
|
||||
- **Mặc định tắt tính năng tự động khôi phục từ tiếng Anh (auto-restore)**: Tránh việc lặp hoặc mất dấu trên các từ tiếng Việt hợp lệ. Người dùng có thể kích hoạt lại nếu muốn bằng cách đặt `[auto_restore] enabled = true`.
|
||||
|
||||
### Cải tiến dòng lệnh (CLI Enhancements)
|
||||
|
||||
- **Chuyển tiếp ký tự**: Tất cả các ký tự đều hiển thị trên đầu ra (thay vì chỉ hiển thị các sự kiện chuyển đổi của bộ xử lý Bamboo).
|
||||
- **Màn hình hiển thị**: Các phím xóa ngược (backspace) được áp dụng trực quan để mang lại trải nghiệm giống thực tế nhất.
|
||||
- **Đặt lại trạng thái**: Mỗi dòng nhập mới sẽ bắt đầu với trạng thái bộ xử lý hoàn toàn sạch.
|
||||
- **Lệnh mới**: Thêm các lệnh hỗ trợ `:help`, `:status`, `:vi`, `:en`, `:ar on|off`, `:macros`, `:macro add/rm/clear`, `:events`.
|
||||
|
||||
### Sửa lỗi (Bug Fixes)
|
||||
|
||||
- **Lỗi lặp dấu cách khi bật/tắt bằng Ctrl+Space**: Chuyển tiếp phím thô hiện đã kiểm tra trạng thái hoạt động của bộ gõ.
|
||||
- **Khóa một phiên chạy (Single-instance lock)**: Ghi PID vào tệp khóa; tự động phát hiện và dọn dẹp các tệp khóa cũ khi daemon tắt không bình thường.
|
||||
- **Dự phòng xprop/wmctrl**: Nhận diện cửa sổ vẫn hoạt động tốt ngay cả khi hệ thống không cài đặt `xdotool`.
|
||||
- **Kết nối AT-SPI2 a11y bus**: Sửa lỗi kết nối nhầm vào session bus; hiện đã kết nối chính xác vào a11y bus riêng biệt.
|
||||
- **Đặt lại trạng thái bộ xử lý giữa các dòng nhập trong CLI**.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.6 (29-06-2026)
|
||||
|
||||
### Ưu tiên giả lập uinput (uinput-First Injection)
|
||||
|
||||
- **Đảo ngược độ ưu tiên giả lập**: Giả lập qua uinput (`/dev/uinput`) trở thành phương thức giả lập nhập liệu chính trên X11, trong khi XTest chỉ đóng vai trò dự phòng.
|
||||
- **Sửa mã phím (keycode) X11 XTest**: Áp dụng độ lệch (offset) +8 cho tất cả các mã phím evdev để đảm bảo tương thích với XTest.
|
||||
- **Sửa lỗi xóa ngược trong `paste_via_clipboard()`**: Khắc phục lỗi gửi nhầm mã phím 14 (tương ứng với số "5") thành mã phím 22 (phím Backspace).
|
||||
|
||||
### Nhận diện chuyển đổi cửa sổ (Window-Switch Detection)
|
||||
|
||||
- **Xác thực ID cửa sổ trên mỗi phím gõ**: Loại bỏ khoảng thời gian bảo vệ 100ms — nhận diện ngay cả khi chuyển cửa sổ cực nhanh dưới 100ms.
|
||||
|
||||
### Phương thức nhập liệu (Input Method)
|
||||
|
||||
- **Tạm ẩn Telex trên khay hệ thống**: Hiển thị màu xám đi kèm ghi chú "(phiên bản tiếp theo)". Chỉ có phương thức gõ VNI hoạt động ở bản này.
|
||||
- **Đổi phương thức gõ mặc định** thành `"vni"`.
|
||||
|
||||
### Đóng gói (Packaging)
|
||||
|
||||
- **Gỡ bỏ Flatpak và AppImage**: Hiện tại chỉ duy trì và phân phối gói cài đặt `.deb`.
|
||||
- **Cải tiến postinst**: Tự động dọn dẹp tệp tin cũ và cấu hình lỗi thời; hiển thị thông báo yêu cầu đăng xuất để áp dụng thay đổi.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.5 (29-06-2026)
|
||||
|
||||
### Đặt lại bộ gõ khi chuyển cửa sổ (Window-Switch Engine Reset)
|
||||
|
||||
- **Đặt lại trạng thái bộ gõ khi chuyển cửa sổ**: Khi Alt+Tab giữa các ứng dụng, bộ đệm ký tự của bộ gõ sẽ được xóa sạch. Tránh tình trạng ký tự gõ ở ứng dụng cũ áp dụng quy tắc gõ dấu sang ứng dụng mới gây lỗi hiển thị.
|
||||
- **Bỏ tính năng ghi nhận `last_key_time` cho phím điều hướng/bổ trợ**: Các phím bổ trợ đơn thuần (Alt, Ctrl, Shift) không còn làm mới bộ đếm thời gian, giúp việc kiểm tra cửa sổ bằng xprop kích hoạt chính xác sau khi chuyển đổi ứng dụng.
|
||||
|
||||
### Nhận diện cửa sổ hoạt động (Active Window Detection)
|
||||
|
||||
- **Dự phòng xprop**: Thử gọi `xdotool` trước, sau đó tự động chuyển sang `xprop -root _NET_ACTIVE_WINDOW` (có sẵn trong `x11-utils`). Hoạt động ổn định dưới quyền sudo kể cả khi không cài `xdotool`.
|
||||
|
||||
### Dọn dẹp mã nguồn (Code Cleanup)
|
||||
|
||||
- **Gỡ bỏ khoảng 400 dòng mã không an toàn (unsafe) không sử dụng**: Xóa toàn bộ khối quản lý chia sẻ trạng thái clipboard X11. Loại bỏ hoàn toàn các cảnh báo `#[warn(dead_code)]` và `#[warn(static_mut_refs)]`.
|
||||
- **Xóa mã chết trong bộ gõ**: Loại bỏ các phương thức không dùng đến trong bộ xử lý `BambooEngine` và `InputMethodRules`.
|
||||
- **Ghi nhật ký vận hành**: Gỡ bỏ các lệnh `eprintln!` in thông tin theo từng phím gõ trong vòng lặp evdev và luồng dán uinput. Chỉ giữ lại các bản ghi quan trọng (khởi động, lỗi, chuyển cửa sổ) ghi ra stderr và tệp log.
|
||||
|
||||
### Biên dịch Flatpak & Khay hệ thống (Flatpak Build & System Tray)
|
||||
|
||||
- **Tích hợp khay hệ thống** (`vietc-tray` viết bằng thư viện ksni/DBus) vào trong gói cài đặt Flatpak. Khay hệ thống sẽ tự khởi chạy daemon và hiển thị trạng thái hiện tại.
|
||||
- **Lối tắt Menu ứng dụng**: Bộ gõ hiển thị đầy đủ khi tìm kiếm từ khóa **"Viet+"** trên khay hệ thống.
|
||||
- **Bỏ hộp thoại mật khẩu khi chạy Flatpak**: Skip sudo khi ứng dụng chạy trong Flatpak do Flatpak đã có sẵn quyền truy cập thiết bị thông qua cờ `--device=all`.
|
||||
|
||||
### Nhận diện cửa sổ hoạt động (Sửa lỗi cho Flatpak)
|
||||
|
||||
- **Tự động gọi thư viện hệ thống X11** `libX11.so.6` thông qua `dlopen`: Đóng vai trò là phương án dự phòng thứ ba. Giải pháp này giúp nhận diện cửa sổ hoạt động bình thường bên trong sandbox Flatpak nơi `xdotool`/`xprop` bị chặn quyền truy cập.
|
||||
|
||||
### Chế độ mặc định (Default Mode)
|
||||
|
||||
- **Mặc định bật bộ gõ**: `start_enabled` hiện mặc định là `true` — chế độ tiếng Việt sẽ kích hoạt ngay khi mở ứng dụng.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.4 (28-06-2026)
|
||||
|
||||
### Đóng gói Flatpak (Flatpak Packaging)
|
||||
|
||||
- Cung cấp đầy đủ các thành phần đóng gói Flatpak bao gồm daemon, CLI, khay hệ thống, uinputd, và kịch bản khởi chạy.
|
||||
|
||||
### Tài liệu hướng dẫn (Documentation)
|
||||
|
||||
- Cập nhật README chi tiết về hướng dẫn cài đặt và biên dịch ứng dụng thông qua Flatpak.
|
||||
|
||||
### Clipboard & Giả lập nhập liệu (Clipboard & Injection)
|
||||
|
||||
- Khắc phục triệt để lỗi tranh chấp clipboard khi giả lập ký tự Unicode tiếng Việt.
|
||||
- Thiết lập quy trình tự động đóng gói `.deb` và `.AppImage` trên mỗi lượt đẩy mã nguồn lên GitHub thông qua GitHub Actions.
|
||||
|
||||
### Kiểm thử (Tests)
|
||||
|
||||
- Hoàn thành **106 bài kiểm thử** đạt yêu cầu (72 bài cho nhân bộ gõ, 16 cho dòng lệnh CLI, 12 cho giao thức, 5 cho tính năng tự động phục hồi và 1 cho quy tắc đặt dấu thanh).
|
||||
|
||||
---
|
||||
|
||||
## v0.1.3 (26-06-2026)
|
||||
|
||||
- Sửa lỗi cụm nguyên âm `ua-horn`, lưu và khôi phục ngữ cảnh clipboard, tối ưu hóa các phím chức năng điều khiển.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.2 (26-06-2026)
|
||||
|
||||
- Chuyển tiếp phím gốc khi xóa đệm, tự động khôi phục từ tiếng Anh gõ nhầm.
|
||||
- Sửa quy tắc dấu cho các cụm `qu`/`gi`/`uê`/`uơ`, bỏ lặp phím tự động, tối ưu hóa phím Enter.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.1 (26-06-2026)
|
||||
|
||||
- Khắc phục lỗi nuốt phím Telex khi gõ dấu, duy trì kết nối X11 liên tục.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 (26-06-2026)
|
||||
|
||||
- Bản phát hành đầu tiên — chuyển đổi từ bamboo engine, bắt phím evdev, giả lập uinput.
|
||||
44
README.md
44
README.md
|
|
@ -2,7 +2,7 @@
|
|||
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.7-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.21-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Tests-108_passing-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
||||
</p>
|
||||
|
|
@ -18,6 +18,10 @@
|
|||
<sub>Zero underline • No pre-edit buffer • Backspace-Replay sync • Built in Rust</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is Viet+?
|
||||
|
|
@ -197,6 +201,10 @@ toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex
|
|||
start_enabled = true # Vietnamese by default
|
||||
grab = true # grab keyboard (evdev)
|
||||
|
||||
[auto_restore]
|
||||
enabled = false # Auto-restore English words (defaults to false)
|
||||
trigger_keys = ["space", "escape"]
|
||||
|
||||
[password_detection]
|
||||
enabled = true
|
||||
check_atspi2 = true
|
||||
|
|
@ -212,21 +220,9 @@ enabled = true
|
|||
english_apps = ["code", "vim"]
|
||||
vietnamese_apps = ["telegram", "discord", "firefox"]
|
||||
bypass_apps = ["steam"]
|
||||
|
||||
### Terminal Usage
|
||||
|
||||
Viet+ works perfectly in terminals. When running inside a terminal (e.g., gnome-terminal, kitty), Vietnamese input is automatically enabled:
|
||||
|
||||
```toml
|
||||
terminal_input_method = "vni" # Automatically switch to VNI when running in a terminal app
|
||||
```
|
||||
|
||||
Supported terminals: `kitty`, `alacritty`, `gnome-terminal`, `konsole`, `foot`, `wezterm`, `st`, `urxvt`, `xterm`
|
||||
|
||||
Type Vietnamese directly — no pre-edit buffer, no underline, no duplication. Just type VNI or Telex digits and see Unicode characters instantly!
|
||||
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
|
||||
"wezterm", "st", "urxvt", "xterm"]
|
||||
terminal_input_method = "vni"
|
||||
terminal_input_method = "vni" # Automatically switch to VNI when running in a terminal app
|
||||
|
||||
[macros]
|
||||
ko = "không"
|
||||
|
|
@ -234,6 +230,14 @@ dc = "được"
|
|||
vs = "với"
|
||||
```
|
||||
|
||||
### Terminal Usage
|
||||
|
||||
Viet+ works perfectly in terminals. When running inside a terminal (e.g., gnome-terminal, kitty), Vietnamese input is automatically enabled using the input method specified by `terminal_input_method` under `[app_state]`.
|
||||
|
||||
Supported terminals: `kitty`, `alacritty`, `gnome-terminal`, `konsole`, `foot`, `wezterm`, `st`, `urxvt`, `xterm`
|
||||
|
||||
Type Vietnamese directly — no pre-edit buffer, no underline, no duplication. Just type VNI or Telex digits and see Unicode characters instantly!
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
|
@ -262,13 +266,13 @@ vietc/
|
|||
|
||||
## Roadmap
|
||||
|
||||
### v0.1.8
|
||||
- Wayland input method protocol (`zwp_input_method_v2`) — eliminates clipboard + backspace race, fixes missing spaces permanently
|
||||
- Event-based AT-SPI2 focus monitoring (subscribe to a11y focus events, no polling)
|
||||
### v0.1.22
|
||||
- [ ] Wayland input method protocol (`zwp_input_method_v2`) — eliminates clipboard + backspace race, fixes missing spaces permanently
|
||||
- [ ] Event-based AT-SPI2 focus monitoring (subscribe to a11y focus events, no polling)
|
||||
|
||||
### v0.1.9
|
||||
- GitHub Actions CI for automated .deb builds
|
||||
- Flatpak re-add for immutable distros
|
||||
### v0.1.23
|
||||
- [ ] GitHub Actions CI for automated .deb builds
|
||||
- [ ] Flatpak re-add for immutable distros
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
287
README.vi.md
Normal file
287
README.vi.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Nền_tảng-Linux-blue?style=for-the-badge" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Ngôn_ngữ-Rust-orange?style=for-the-badge" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/Giấy_phép-MIT-green?style=for-the-badge" alt="License">
|
||||
<img src="https://img.shields.io/badge/Phiên_bản-0.1.21-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Kiểm_thử-108_đạt-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
<br>
|
||||
Viet+
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Bộ gõ tiếng Việt cho Linux</b><br>
|
||||
<sub>Không gạch chân • Không bộ đệm pre-edit • Đồng bộ Backspace-Replay • Viết bằng Rust</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Viet+ là gì?
|
||||
|
||||
Viet+ là một bộ gõ tiếng Việt dành cho Linux sử dụng hướng tiếp cận hoàn toàn khác biệt so với tất cả các bộ gõ khác: **Gõ trực tiếp (Direct Input)**.
|
||||
|
||||
Hầu hết các bộ gõ tiếng Việt hiện nay sử dụng **bộ đệm pre-edit** — khi gõ, các ký tự sẽ nằm trong một bộ đệm tạm thời với dấu gạch chân bên dưới, và chỉ thực sự được gửi đi khi bạn hoàn thành từ đó. Điều này gây ra lỗi lặp từ, xao nhãng bởi dấu gạch chân, lỗi sao chép/dán, và mất đồng bộ giữa bộ gõ với nội dung hiển thị trên màn hình.
|
||||
|
||||
Viet+ loại bỏ hoàn toàn những nhược điểm trên. Các phím gõ được **chuyển đổi ngay lập tức sang Unicode** — những gì bạn gõ là những gì bạn thấy. Không bộ đệm tạm thời. Không gạch chân. Không lặp chữ.
|
||||
|
||||
---
|
||||
|
||||
## Tính năng nổi bật
|
||||
|
||||
| Tính năng | Nguyên lý hoạt động |
|
||||
|-----------|---------------------|
|
||||
| **Gõ trực tiếp** | Không dùng bộ đệm pre-edit. Ký tự được hiển thị ngay lập tức thông qua cơ chế giả lập bàn phím uinput |
|
||||
| **VNI & Telex** | Hỗ trợ đầy đủ cả hai phương thức gõ, chuyển đổi nhanh bằng phím nóng Ctrl+Shift |
|
||||
| **Bamboo Engine** | Sử dụng mô hình biến đổi Bamboo — ghép âm, bỏ dấu, đặt dấu và xóa dấu linh hoạt |
|
||||
| **Ghép âm thông minh** | Hỗ trợ tự động thêm râu/mũ như `uo→ươ` (có hỗ trợ xóa ngược), tự động đặt dấu móc `ua→ưa` |
|
||||
| **Gõ tắt (Macro)** | Hỗ trợ mở rộng viết tắt như `ko → không`, `dc → được`, và cho phép tự định nghĩa |
|
||||
| **Giữ nguyên hoa/thường** | Bảo toàn định dạng viết hoa như `Tieengs → Tiếng`, `TIEENGS → TIẾNG` |
|
||||
| **Nhớ trạng thái theo ứng dụng** | Tự động nhớ trạng thái gõ Anh/Việt cho từng ứng dụng riêng biệt, lưu trữ tại `overrides.toml` |
|
||||
| **Tải lại cấu hình nóng** | Các thay đổi trong tệp cấu hình được áp dụng ngay lập tức mà không cần khởi động lại bộ gõ |
|
||||
| **Đặt lại khi chuyển cửa sổ** | Tự động xóa bộ đệm của bộ gõ khi nhấn Alt+Tab chuyển ứng dụng |
|
||||
| **Độ ưu tiên CPU cao** | Được gán cố định vào các nhân P-core (0-3) và mức ưu tiên nice(-10) để giảm tối đa độ trễ |
|
||||
| **Giả lập uinput** | Sử dụng `/dev/uinput` giúp hoạt động ổn định trên cả X11 và Wayland |
|
||||
| **Hỗ trợ Terminal** | ✅ Hoạt động mượt mà trên tất cả các terminal phổ biến: kitty, alacritty, gnome-terminal, konsole, foot, wezterm, st, urxvt, xterm |
|
||||
| **Tự động nhận diện mật khẩu** | 4 lớp bảo vệ: AT-SPI2 → tiến trình sudo → tiêu đề cửa sổ → lớp (class) cửa sổ |
|
||||
| **Biểu tượng khay hệ thống** | Hiển thị trạng thái hiện tại: Đỏ (VN) / Xanh dương (TLX) / Xám (EN) |
|
||||
| **GNOME/Wayland** | Tích hợp sâu thông qua cơ chế D-Bus của GNOME Shell |
|
||||
|
||||
---
|
||||
|
||||
## Phương thức gõ
|
||||
|
||||
Viet+ hỗ trợ đầy đủ hai phương thức gõ **VNI** và **Telex**. Bạn có thể chuyển đổi qua lại bằng phím tắt **Ctrl+LeftShift** hoặc qua menu khay hệ thống.
|
||||
|
||||
### VNI
|
||||
|
||||
| Phím gõ | Kết quả | Ví dụ |
|
||||
|---------|---------|-------|
|
||||
| `1` | á (sắc) | `a1` → `á` |
|
||||
| `2` | à (huyền) | `a2` → `à` |
|
||||
| `3` | ả (hỏi) | `a3` → `ả` |
|
||||
| `4` | ã (ngã) | `a4` → `ã` |
|
||||
| `5` | ạ (nặng) | `a5` → `ạ` |
|
||||
| `6` | â/ê/ô | `a6→â`, `e6→ê`, `o6→ô` |
|
||||
| `7` | ơ/ư | `o7→ơ`, `u7→ư` |
|
||||
| `8` | ă | `a8→ă` |
|
||||
| `9` | đ | `d9→đ` |
|
||||
|
||||
### Telex
|
||||
|
||||
| Phím gõ | Kết quả | Ví dụ |
|
||||
|---------|---------|-------|
|
||||
| `s` | á (sắc) | `as→á` |
|
||||
| `f` | à (huyền) | `af→à` |
|
||||
| `r` | ả (hỏi) | `ar→ả` |
|
||||
| `x` | ã (ngã) | `ax→ã` |
|
||||
| `j` | ạ (nặng) | `aj→ạ` |
|
||||
| `aa` | â | `aa→â` |
|
||||
| `ee` | ê | `ee→ê` |
|
||||
| `oo` | ô | `oo→ô` |
|
||||
| `ow` | ơ | `ow→ơ` |
|
||||
| `aw` | ă | `aw→ă` |
|
||||
| `uw` | ư | `uw→ư` |
|
||||
| `dd` | đ | `dd→đ` |
|
||||
| `w` | ươ | `chuongw→chương` |
|
||||
|
||||
---
|
||||
|
||||
## Phím tắt mặc định
|
||||
|
||||
| Tổ hợp phím | Hành động |
|
||||
|-------------|-----------|
|
||||
| **Ctrl+Space** | Bật/Tắt bộ gõ tiếng Việt |
|
||||
| **Ctrl+LeftShift** | Chuyển đổi giữa VNI ↔ Telex |
|
||||
|
||||
---
|
||||
|
||||
## Tự động nhận diện mật khẩu
|
||||
|
||||
Viet+ tích hợp hệ thống nhận diện mật khẩu 4 lớp tự động. Khi phát hiện trường nhập mật khẩu, bộ gõ tiếng Việt sẽ tự động tạm thời tắt để tránh lỗi gõ ký tự đặc biệt:
|
||||
|
||||
| Lớp nhận diện | Phương pháp | Đối tượng phát hiện |
|
||||
|---------------|-------------|---------------------|
|
||||
| 1 | AT-SPI2 D-Bus (kiểm tra thuộc tính a11y) | Các trường mật khẩu trong các ứng dụng có hỗ trợ a11y |
|
||||
| 2 | Cây tiến trình (pstree) | Tiến trình `sudo` / `passwd` chạy trong terminal |
|
||||
| 3 | Từ khóa tiêu đề cửa sổ | Cửa sổ có tiêu đề chứa các từ khóa như `password`, `sudo`, `mật khẩu` |
|
||||
| 4 | Lớp cửa sổ (Window class) | Các hộp thoại bảo mật như pinentry, polkit, kwallet |
|
||||
|
||||
---
|
||||
|
||||
## Khả năng hỗ trợ Distro
|
||||
|
||||
| Mức độ | Bản phân phối (Distro) | Cách cài đặt | Trạng thái |
|
||||
|--------|------------------------|--------------|------------|
|
||||
| ✅ **Hỗ trợ tốt** | Ubuntu, Debian, Linux Mint, Pop!_OS, elementary OS, Zorin, Neon | Trình quản lý `apt` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
|
||||
| ✅ **Hỗ trợ tốt** | Fedora, RHEL, CentOS | Trình quản lý `dnf` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
|
||||
| ✅ **Hỗ trợ tốt** | Arch, Manjaro | Trình quản lý `pacman` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
|
||||
| ⚠️ **Có thể hỗ trợ** | openSUSE, Solus, Void | Trình quản lý `zypper`/`eopkg`/`xbps` (thủ công) | Tên gói phụ thuộc có thể khác biệt; chạy install.sh và tự cài thủ công các gói thiếu nếu lỗi |
|
||||
| ❌ **Chưa hỗ trợ** | NixOS, Alpine, Gentoo, các hệ thống khác | N/A | Không có sẵn trong quản lý gói — cần cài gói phụ thuộc thủ công rồi chạy `cargo build --release` |
|
||||
|
||||
> **⚠️ Lưu ý về biểu tượng khay hệ thống:** Môi trường GNOME (Ubuntu) và Cinnamon (Mint) cần có phần mềm theo dõi StatusNotifier để hiển thị khay hệ thống:
|
||||
> - Ubuntu: `sudo apt install gnome-shell-extension-appindicator`
|
||||
> - Mint: Đã được tích hợp sẵn, hoạt động ngay sau khi cài đặt
|
||||
|
||||
---
|
||||
|
||||
## Cài đặt
|
||||
|
||||
### Cài đặt nhanh bằng một câu lệnh
|
||||
|
||||
Áp dụng cho tất cả các distro được đánh dấu ✅ **Hỗ trợ tốt** ở trên. Kịch bản cài đặt sẽ tự động nhận diện trình quản lý gói của hệ thống:
|
||||
|
||||
**Từ GitHub (khuyên dùng):**
|
||||
```bash
|
||||
git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \
|
||||
&& cd /tmp/vietc && sudo ./install.sh
|
||||
```
|
||||
|
||||
**Từ Forgejo (máy chủ riêng):**
|
||||
```bash
|
||||
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git /tmp/vietc \
|
||||
&& cd /tmp/vietc && sudo ./install.sh
|
||||
```
|
||||
|
||||
Kịch bản sẽ tự động cài các thư viện phụ thuộc, biên dịch mã nguồn, cài đặt chương trình vào `/usr/bin/`, thiết lập phân quyền cho uinput qua udev rules, và thêm người dùng hiện tại vào nhóm `input`.
|
||||
|
||||
**Sau khi cài đặt:** Đăng xuất (Log out) và đăng nhập lại hệ thống, sau đó khởi chạy ứng dụng `vietc-tray` từ menu ứng dụng.
|
||||
|
||||
### Gỡ cài đặt nhanh bằng một câu lệnh
|
||||
|
||||
**Từ GitHub:**
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
**Từ Forgejo:**
|
||||
```bash
|
||||
curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
### Biên dịch & Chạy thủ công
|
||||
|
||||
```bash
|
||||
# Cài đặt các thư viện phụ thuộc
|
||||
sudo apt install git curl build-essential pkg-config \
|
||||
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard
|
||||
|
||||
# Kích hoạt tính năng hỗ trợ tiếp cận (Ubuntu Wayland — dùng cho nhận diện mật khẩu)
|
||||
gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
|
||||
|
||||
# Biên dịch mã nguồn
|
||||
git clone https://github.com/vndangkhoa/vietc.git
|
||||
cd vietc
|
||||
cargo build --release
|
||||
|
||||
# Chạy (Hệ điều hành Mint — không cần quyền sudo cho uinput)
|
||||
./target/release/vietc
|
||||
|
||||
# Chạy (Hệ điều hành Ubuntu — cần quyền sudo để bắt sự kiện bàn phím)
|
||||
sudo ./target/release/vietc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cấu hình
|
||||
|
||||
Tệp cấu hình đặt tại: `~/.config/vietc/config.toml` hoặc `./vietc.toml`
|
||||
|
||||
```toml
|
||||
input_method = "vni" # "vni" hoặc "telex"
|
||||
toggle_key = "space" # Ctrl+Space để bật/tắt gõ tiếng Việt
|
||||
toggle_method_key = "shift" # Ctrl+Shift để chuyển đổi VNI/Telex
|
||||
start_enabled = true # Mặc định bật tiếng Việt khi khởi động
|
||||
grab = true # Độc chiếm bàn phím (evdev)
|
||||
|
||||
[auto_restore]
|
||||
enabled = false # Tự động hoàn tác từ tiếng Anh gõ nhầm (mặc định tắt)
|
||||
trigger_keys = ["space", "escape"]
|
||||
|
||||
[password_detection]
|
||||
enabled = true
|
||||
check_atspi2 = true
|
||||
check_window_title = true
|
||||
title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"]
|
||||
password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt",
|
||||
"lxqt-sudo", "kdesudo", "gksudo",
|
||||
"polkit-gnome-authentication-agent-1",
|
||||
"kwallet", "gnome-keyring", "ssh-askpass"]
|
||||
|
||||
[app_state]
|
||||
enabled = true
|
||||
english_apps = ["code", "vim"]
|
||||
vietnamese_apps = ["telegram", "discord", "firefox"]
|
||||
bypass_apps = ["steam"]
|
||||
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
|
||||
"wezterm", "st", "urxvt", "xterm"]
|
||||
terminal_input_method = "vni" # Tự động chuyển sang VNI khi chạy trong terminal
|
||||
|
||||
[macros]
|
||||
ko = "không"
|
||||
dc = "được"
|
||||
vs = "với"
|
||||
```
|
||||
|
||||
### Sử dụng trong Terminal
|
||||
|
||||
Viet+ hoạt động cực kỳ mượt mà trong các môi trường terminal. Khi bạn sử dụng một terminal (ví dụ: gnome-terminal, kitty), bộ gõ tiếng Việt sẽ tự động áp dụng phương thức gõ được thiết lập tại cấu hình `terminal_input_method` trong mục `[app_state]`.
|
||||
|
||||
Các terminal được hỗ trợ sẵn: `kitty`, `alacritty`, `gnome-terminal`, `konsole`, `foot`, `wezterm`, `st`, `urxvt`, `xterm`
|
||||
|
||||
Gõ tiếng Việt trực tiếp — không có thanh gạch chân khó chịu, không lặp từ. Chỉ cần gõ phím số VNI hoặc phím chữ Telex và ký tự Unicode sẽ xuất hiện ngay lập tức!
|
||||
|
||||
---
|
||||
|
||||
## Kiến trúc hệ thống
|
||||
|
||||
```
|
||||
vietc/
|
||||
├── engine/ # Bộ xử lý chuyển đổi chữ tiếng Việt (chuyển đổi từ bamboo-core)
|
||||
├── protocol/ # Thư viện bắt và giả lập sự kiện bàn phím
|
||||
│ ├── uinput_monitor.rs # Giả lập qua /dev/uinput (chính)
|
||||
│ ├── x11_inject.rs # Giả lập qua XTest (dự phòng)
|
||||
│ ├── x11_capture.rs # Bắt phím qua XRecord
|
||||
│ └── wayland_im.rs # Giao thức Wayland IM (đang phát triển)
|
||||
├── daemon/ # Tiến trình nền chính điều khiển bộ gõ
|
||||
│ ├── main.rs # Vòng lặp sự kiện, chiếm quyền bàn phím, xử lý tín hiệu
|
||||
│ ├── config.rs # Tải tệp cấu hình TOML + tự động cập nhật cấu hình nóng
|
||||
│ ├── app_state.rs # Quản lý bộ nhớ trạng thái theo ứng dụng + nhận diện mật khẩu
|
||||
│ ├── password_detector.rs # Nhận diện trường mật khẩu qua AT-SPI2 D-Bus
|
||||
│ └── display.rs # Nhận diện máy chủ đồ họa X11/Wayland/Compositor
|
||||
├── ui/ # Biểu tượng khay hệ thống (sử dụng ksni)
|
||||
│ └── tray.rs # Khay hiển thị chế độ VN/TLX/EN
|
||||
├── cli/ # Công cụ dòng lệnh kiểm thử bộ xử lý tiếng Việt
|
||||
└── uinputd/ # Tiến trình đặc quyền quản lý socket uinput
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lộ trình phát triển
|
||||
|
||||
### Phiên bản v0.1.22
|
||||
- [ ] Hỗ trợ giao thức nhập liệu Wayland (`zwp_input_method_v2`) — loại bỏ việc dùng clipboard và tranh chấp Backspace, khắc phục triệt để lỗi mất khoảng trắng.
|
||||
- [ ] Cơ chế giám sát tiêu điểm AT-SPI2 hướng sự kiện (đăng ký nhận sự kiện tiêu điểm từ a11y thay vì liên tục truy vấn).
|
||||
|
||||
### Phiên bản v0.1.23
|
||||
- [ ] Tự động hóa việc đóng gói tệp tin `.deb` bằng GitHub Actions CI.
|
||||
- [ ] Khôi phục hỗ trợ Flatpak cho các hệ điều hành bất biến (immutable distros).
|
||||
|
||||
---
|
||||
|
||||
## Giấy phép sử dụng
|
||||
|
||||
Giấy phép MIT — xem chi tiết tại tệp tin [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Được phát triển với tình yêu dành cho cộng đồng Linux Việt Nam</sub>
|
||||
</p>
|
||||
|
|
@ -495,6 +495,8 @@ enum OutputCommand {
|
|||
Backspace(usize),
|
||||
}
|
||||
|
||||
const KEY_MAX: u32 = 0x1ff;
|
||||
|
||||
/// Characters that flush the current word and start a new one.
|
||||
fn is_flush_char(ch: char) -> bool {
|
||||
matches!(ch, ' ' | '.' | ',' | '!' | '?' | ';' | ':' | '\t' | '\n')
|
||||
|
|
@ -1280,6 +1282,17 @@ fn run_with_x11_keymap(
|
|||
commands.push(OutputCommand::Type(buf_after));
|
||||
}
|
||||
}
|
||||
// X11 capture: the VNI/Telex control key character reached
|
||||
// the app directly. Add 1 extra backspace to remove it.
|
||||
if !commands.is_empty()
|
||||
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
|
||||
{
|
||||
for cmd in &mut commands {
|
||||
if let OutputCommand::Backspace(ref mut n) = cmd {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
execute_commands(&*injector, &commands, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1296,7 +1309,7 @@ fn run_with_evdev(
|
|||
_engine_enabled: Arc<AtomicBool>,
|
||||
display: display::DisplayServer,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let injector = create_injector(display)?;
|
||||
let mut injector = create_injector(display)?;
|
||||
|
||||
// Use the first device for grab (only one device can be grabbed at a time)
|
||||
let primary_idx = 0usize;
|
||||
|
|
@ -1323,6 +1336,17 @@ fn run_with_evdev(
|
|||
false
|
||||
};
|
||||
|
||||
// Non-grabbed on X11: use XTest injection for fast, synchronous correction
|
||||
if !grabbed {
|
||||
#[cfg(feature = "x11")]
|
||||
if display != display::DisplayServer::Wayland {
|
||||
if let Ok(x11_inj) = vietc_protocol::x11_inject::X11Injector::new() {
|
||||
injector = Box::new(x11_inj);
|
||||
log_info("[vietc] Non-grabbed: using X11 injection (faster than uinput)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut consumed_keys: HashSet<u16> = HashSet::new();
|
||||
let mut last_active_window = String::new();
|
||||
let mut last_window_class = String::new();
|
||||
|
|
@ -1361,6 +1385,14 @@ fn run_with_evdev(
|
|||
let _ = devices[primary_idx].0.ungrab();
|
||||
grabbed = false;
|
||||
log_info("[vietc] Non-grabbed mode: polling all evdev devices for keystrokes");
|
||||
// Switch to XTest injection for fast synchronous non-grabbed correction
|
||||
#[cfg(feature = "x11")]
|
||||
if display != display::DisplayServer::Wayland {
|
||||
if let Ok(x11_inj) = vietc_protocol::x11_inject::X11Injector::new() {
|
||||
injector = Box::new(x11_inj);
|
||||
log_info("[vietc] Non-grabbed: using X11 injection (faster than uinput)");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1399,13 +1431,6 @@ fn run_with_evdev(
|
|||
}
|
||||
idle_polls = 0;
|
||||
|
||||
if !grabbed {
|
||||
log_info(&format!(
|
||||
"[vietc] evdev: {} device(s) have events after ungrab",
|
||||
pfds.iter().filter(|p| (p.revents & libc::POLLIN) != 0).count()
|
||||
));
|
||||
}
|
||||
|
||||
// Check for status changes instantly
|
||||
if status_changed.load(Ordering::SeqCst) {
|
||||
daemon.sync_status_file();
|
||||
|
|
@ -1428,8 +1453,8 @@ fn run_with_evdev(
|
|||
let caps = device_states[i].1;
|
||||
let mut key_state = std::mem::take(&mut device_states[i].0);
|
||||
|
||||
let events = match device.fetch_events() {
|
||||
Ok(events) => events,
|
||||
let event_list = match device.fetch_events() {
|
||||
Ok(events) => events.collect::<Vec<_>>(),
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
|
|
@ -1443,12 +1468,13 @@ fn run_with_evdev(
|
|||
};
|
||||
last_event_time = std::time::Instant::now();
|
||||
|
||||
let mut non_key_logged = 0u32;
|
||||
for event in events {
|
||||
match event.kind() {
|
||||
evdev::InputEventKind::Key(key) => {
|
||||
let value = event.value();
|
||||
let keycode = key.0;
|
||||
for event in event_list {
|
||||
if event.event_type() != evdev::EventType::KEY {
|
||||
continue;
|
||||
}
|
||||
let keycode = event.code();
|
||||
let value = event.value();
|
||||
let key = evdev::Key(keycode);
|
||||
|
||||
// Update key state dynamically
|
||||
if value == 1 {
|
||||
|
|
@ -1501,6 +1527,12 @@ fn run_with_evdev(
|
|||
}
|
||||
}
|
||||
|
||||
// In non-grabbed mode, only process engine from primary device (i==0)
|
||||
// to avoid double-processing when multiple keyboard devices report same keys
|
||||
if !grabbed && i != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !grabbed {
|
||||
if value != 1 {
|
||||
continue;
|
||||
|
|
@ -1694,19 +1726,6 @@ fn run_with_evdev(
|
|||
}
|
||||
injector.send_key_event(keycode, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if non_key_logged < 5 {
|
||||
log_info(&format!(
|
||||
"[vietc] evdev: non-key event type={:?} code={} value={}",
|
||||
event.event_type(),
|
||||
event.code(),
|
||||
event.value()
|
||||
));
|
||||
non_key_logged += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-engine"
|
||||
version = "0.1.7"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
description = "Viet+ Vietnamese IME Core Engine"
|
||||
|
||||
|
|
|
|||
175
install.sh
175
install.sh
|
|
@ -9,32 +9,34 @@ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
|
|||
|
||||
echo -e "${GREEN}=== Viet+ Installer ===${NC}"
|
||||
|
||||
# Detect distro
|
||||
# Architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Distro
|
||||
[ -f /etc/os-release ] && . /etc/os-release
|
||||
DISTRO="${ID:-unknown}"
|
||||
echo "Detected: $DISTRO"
|
||||
echo "Detected: $DISTRO ($ARCH)"
|
||||
|
||||
# Install dependencies
|
||||
install_deps() {
|
||||
install_runtime_deps() {
|
||||
echo "Installing runtime dependencies..."
|
||||
case "$DISTRO" in
|
||||
ubuntu|debian|linuxmint|mint|pop|neon|zorin|elementary)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y build-essential pkg-config libx11-dev libxtst-dev \
|
||||
libdbus-1-dev libevdev-dev libwayland-dev curl git
|
||||
apt-get install -y libevdev2 libdbus-1-3 libx11-6 libxtst6 \
|
||||
libwayland-client0 xclip wl-clipboard
|
||||
libwayland-client0 xclip wl-clipboard curl
|
||||
;;
|
||||
fedora|rhel|centos)
|
||||
dnf install -y gcc pkgconfig libX11-devel libXtst-devel dbus-devel \
|
||||
libevdev-devel libwayland-devel curl git
|
||||
dnf install -y libevdev libX11 libXtst dbus-libs libwayland-client xclip wl-clipboard
|
||||
dnf install -y libevdev libX11 libXtst dbus-libs libwayland-client xclip wl-clipboard curl
|
||||
;;
|
||||
arch|manjaro)
|
||||
pacman -Sy --needed --noconfirm base-devel pkgconf libx11 libxtst dbus \
|
||||
libevdev wayland curl git
|
||||
arch|manjaro|cachyos|endeavouros|garuda|artix)
|
||||
pacman -Sy --needed --noconfirm libevdev libx11 libxtst dbus \
|
||||
libwayland xclip wl-clipboard
|
||||
libwayland xclip wl-clipboard curl
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}Unsupported: $DISTRO. Install deps manually.${NC}"
|
||||
|
|
@ -42,13 +44,49 @@ install_deps() {
|
|||
esac
|
||||
}
|
||||
|
||||
install_deps
|
||||
install_runtime_deps
|
||||
|
||||
# Install Rust if missing
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
echo "Fetching latest release..."
|
||||
RELEASE_JSON=$(curl -sSfL "https://api.github.com/repos/vndangkhoa/vietc/releases/latest" 2>/dev/null || echo "")
|
||||
TAG=$(echo "$RELEASE_JSON" | grep '"tag_name"' | sed 's/.*"v\(.*\)",/\1/')
|
||||
if [ -z "$TAG" ]; then
|
||||
echo -e "${RED}Failed to fetch latest release info.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest version: v$TAG"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
cleanup() { rm -rf "$TMPDIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Try tarball first, then .deb
|
||||
TARBALL="vietc_${TAG}_linux_${ARCH}.tar.gz"
|
||||
TARBALL_URL="https://github.com/vndangkhoa/vietc/releases/download/v${TAG}/${TARBALL}"
|
||||
DEB="vietc_${TAG}-1_amd64.deb"
|
||||
DEB_URL="https://github.com/vndangkhoa/vietc/releases/download/v${TAG}/${DEB}"
|
||||
INSTALL_DIR="$TMPDIR/install"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if curl -sSfL -o "$TMPDIR/$TARBALL" "$TARBALL_URL" 2>/dev/null; then
|
||||
echo "Downloading tarball..."
|
||||
tar -xzf "$TMPDIR/$TARBALL" -C "$INSTALL_DIR"
|
||||
BIN_DIR="$INSTALL_DIR/vietc_${TAG}_linux_${ARCH}/bin"
|
||||
PKG_DIR="$INSTALL_DIR/vietc_${TAG}_linux_${ARCH}"
|
||||
elif curl -sSfL -o "$TMPDIR/$DEB" "$DEB_URL" 2>/dev/null; then
|
||||
echo "Downloading .deb package..."
|
||||
if command -v dpkg-deb &>/dev/null; then
|
||||
dpkg-deb -x "$TMPDIR/$DEB" "$INSTALL_DIR"
|
||||
else
|
||||
ar x "$TMPDIR/$DEB" --output="$TMPDIR/deb" 2>/dev/null
|
||||
tar -xzf "$TMPDIR/deb/data.tar.gz" -C "$INSTALL_DIR" 2>/dev/null || \
|
||||
tar -xJf "$TMPDIR/deb/data.tar.xz" -C "$INSTALL_DIR" 2>/dev/null || true
|
||||
fi
|
||||
BIN_DIR="$INSTALL_DIR/usr/bin"
|
||||
PKG_DIR="$INSTALL_DIR"
|
||||
else
|
||||
echo -e "${RED}No prebuilt binary found for v$TAG ($ARCH).${NC}"
|
||||
echo -e "${YELLOW}Visit https://github.com/vndangkhoa/vietc/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kill old processes
|
||||
|
|
@ -56,64 +94,103 @@ pkill -x vietc-tray 2>/dev/null || true
|
|||
pkill -x vietc-daemon 2>/dev/null || true
|
||||
pkill -x vietc 2>/dev/null || true
|
||||
|
||||
# Build
|
||||
echo "Building..."
|
||||
cargo build --release
|
||||
(cd ui && cargo build --release)
|
||||
if command -v gcc &>/dev/null && [ -f packaging/deb/vietc-xrecord.c ]; then
|
||||
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install binaries
|
||||
echo "Installing to /usr/bin/..."
|
||||
cp target/release/vietc /usr/bin/vietc-daemon
|
||||
cp target/release/vietc-cli /usr/bin/
|
||||
cp target/release/vietc-uinputd /usr/bin/
|
||||
cp ui/target/release/vietc-tray /usr/bin/
|
||||
[ -f target/release/vietc-xrecord ] && cp target/release/vietc-xrecord /usr/bin/
|
||||
cp "$BIN_DIR/vietc-daemon" /usr/bin/vietc-daemon
|
||||
cp "$BIN_DIR/vietc-cli" /usr/bin/vietc-cli
|
||||
cp "$BIN_DIR/vietc-uinputd" /usr/bin/vietc-uinputd
|
||||
cp "$BIN_DIR/vietc-tray" /usr/bin/vietc-tray
|
||||
[ -f "$BIN_DIR/vietc-xrecord" ] && cp "$BIN_DIR/vietc-xrecord" /usr/bin/vietc-xrecord
|
||||
chmod 755 /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd /usr/bin/vietc-tray 2>/dev/null || true
|
||||
|
||||
# Clean old /usr/local/bin/ binaries
|
||||
rm -f /usr/local/bin/vietc /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli \
|
||||
/usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord 2>/dev/null || true
|
||||
|
||||
# Udev rules for uinput
|
||||
# Udev rules
|
||||
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' > /etc/udev/rules.d/99-vietc.rules
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger 2>/dev/null || true
|
||||
|
||||
# Icons
|
||||
if [ -d "$PKG_DIR/icons" ]; then
|
||||
mkdir -p /usr/share/icons/hicolor/256x256/apps
|
||||
cp "$PKG_DIR/icons"/*.svg /usr/share/icons/hicolor/256x256/apps/ 2>/dev/null || true
|
||||
elif [ -d "$INSTALL_DIR/usr/share/icons" ]; then
|
||||
cp -r "$INSTALL_DIR/usr/share/icons/"* /usr/share/icons/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Desktop file
|
||||
if [ -f "$PKG_DIR/desktop/vietc.desktop" ]; then
|
||||
mkdir -p /usr/share/applications
|
||||
cp "$PKG_DIR/desktop/vietc.desktop" /usr/share/applications/
|
||||
elif [ -f "$INSTALL_DIR/usr/share/applications/vietc.desktop" ]; then
|
||||
cp "$INSTALL_DIR/usr/share/applications/vietc.desktop" /usr/share/applications/
|
||||
fi
|
||||
|
||||
# XDG autostart
|
||||
mkdir -p /etc/xdg/autostart
|
||||
cat > /etc/xdg/autostart/vietc-tray.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Viet+ Tray
|
||||
Comment=Vietnamese Input Method Tray
|
||||
Exec=vietc-tray
|
||||
Icon=vietc
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=false
|
||||
NoDisplay=true
|
||||
EOF
|
||||
|
||||
# Systemd user service
|
||||
mkdir -p /usr/lib/systemd/user
|
||||
cat > /usr/lib/systemd/user/vietc.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Viet+ Vietnamese IME Tray
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/vietc-tray
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# User setup
|
||||
INSTALLING_USER="${SUDO_USER:-$USER}"
|
||||
if [ -n "$INSTALLING_USER" ] && [ "$INSTALLING_USER" != "root" ]; then
|
||||
adduser "$INSTALLING_USER" input 2>/dev/null || true
|
||||
if command -v usermod &>/dev/null; then
|
||||
usermod -aG input "$INSTALLING_USER" 2>/dev/null || true
|
||||
elif command -v adduser &>/dev/null; then
|
||||
adduser "$INSTALLING_USER" input 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$(getent passwd "$INSTALLING_USER" | cut -d: -f6)/.config/vietc/config.toml" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create default config
|
||||
# Config
|
||||
mkdir -p /etc/vietc
|
||||
cat > /etc/vietc/config.toml << 'EOF'
|
||||
if [ -f "$PKG_DIR/config/config.toml" ]; then
|
||||
cp "$PKG_DIR/config/config.toml" /etc/vietc/config.toml
|
||||
elif [ -f "$INSTALL_DIR/etc/vietc/config.toml" ]; then
|
||||
cp "$INSTALL_DIR/etc/vietc/config.toml" /etc/vietc/config.toml
|
||||
fi
|
||||
if [ ! -f /etc/vietc/config.toml ]; then
|
||||
cat > /etc/vietc/config.toml << 'EOF'
|
||||
input_method = "vni"
|
||||
toggle_key = "space"
|
||||
toggle_method_key = "shift"
|
||||
start_enabled = true
|
||||
grab = true
|
||||
|
||||
[password_detection]
|
||||
enabled = true
|
||||
check_atspi2 = true
|
||||
check_window_title = true
|
||||
title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"]
|
||||
password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", "kwallet"]
|
||||
|
||||
[app_state]
|
||||
enabled = true
|
||||
english_apps = ["code", "vim"]
|
||||
vietnamese_apps = ["telegram", "discord", "firefox"]
|
||||
bypass_apps = ["steam"]
|
||||
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
|
||||
"wezterm", "st", "urxvt", "xterm"]
|
||||
terminal_input_method = "vni"
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}=== Done! ===${NC}"
|
||||
echo -e "${YELLOW}Log out and log back in, then run: vietc-tray${NC}"
|
||||
|
|
|
|||
|
|
@ -417,10 +417,10 @@ impl X11Injector {
|
|||
// (unlikely at this point, but be safe)
|
||||
self.handle_pending_events();
|
||||
|
||||
// Send backspaces via XTest (X11 keycode 22 = backspace)
|
||||
// Send backspaces via XTest (evdev 14 = KEY_BACKSPACE → X11 22)
|
||||
if backspaces > 0 {
|
||||
for _ in 0..backspaces {
|
||||
self.send_keycode(22, false);
|
||||
self.send_keycode(14, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -523,7 +523,7 @@ impl KeyInjector for X11Injector {
|
|||
}
|
||||
|
||||
fn send_backspace(&self) -> InjectResult {
|
||||
self.send_keycode(22, false); // X11 keycode 22 = backspace
|
||||
self.send_keycode(14, false); // evdev 14 = KEY_BACKSPACE → X11 22
|
||||
InjectResult::Success
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-tray"
|
||||
version = "0.1.7"
|
||||
version = "0.1.19"
|
||||
edition = "2021"
|
||||
description = "Viet+ system tray icon"
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ rm -f /usr/share/icons/hicolor/256x256/apps/vietc*.svg
|
|||
rm -f /usr/share/applications/vietc.desktop
|
||||
rm -f /etc/xdg/autostart/vietc-tray.desktop
|
||||
|
||||
# Reload
|
||||
# Reload udev
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
|
||||
# Reload systemd user daemon
|
||||
if command -v systemctl &>/dev/null; then
|
||||
systemctl --global daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}=== Viet+ removed ===${NC}"
|
||||
|
|
|
|||
9
web/.env.example
Normal file
9
web/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
8
web/.gitignore
vendored
Normal file
8
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
BIN
web/737046771_27588542340832646_2624568238884019344_n.jpg
Normal file
BIN
web/737046771_27588542340832646_2624568238884019344_n.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
20
web/README.md
Normal file
20
web/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/aa240f3c-abe9-494f-8e47-678406f50ae1
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
14
web/index.html
Normal file
14
web/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vietc-favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VietC - Bộ gõ tiếng Việt Native cho Linux Terminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
web/metadata.json
Normal file
6
web/metadata.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "VietC - Native Linux VNI Terminal IME",
|
||||
"description": "Website giới thiệu bộ gõ tiếng Việt VietC siêu nhẹ, siêu mượt cho Linux Terminal, kèm theo hướng dẫn cài đặt và trang tùy biến bộ Keycap 3D Resin Rồng Con dễ thương.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
4278
web/package-lock.json
generated
Normal file
4278
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
web/package.json
Normal file
35
web/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^6.2.3",
|
||||
"express": "^4.21.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"motion": "^12.23.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
62
web/public/vietc-favicon.svg
Normal file
62
web/public/vietc-favicon.svg
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||
<!-- Left ear -->
|
||||
<path d="M38 72 C 15 45, 12 85, 34 108 Z" fill="#1E3A5F"/>
|
||||
<path d="M36 70 C 20 52, 18 80, 32 100 Z" fill="#2563EB" opacity="0.6"/>
|
||||
|
||||
<!-- Right ear -->
|
||||
<path d="M162 72 C 185 45, 188 85, 166 108 Z" fill="#1E3A5F"/>
|
||||
<path d="M164 70 C 180 52, 182 80, 168 100 Z" fill="#2563EB" opacity="0.6"/>
|
||||
|
||||
<!-- Main head -->
|
||||
<ellipse cx="100" cy="100" rx="62" ry="58" fill="#3B82F6"/>
|
||||
|
||||
<!-- Head highlight -->
|
||||
<ellipse cx="100" cy="72" rx="44" ry="22" fill="#60A5FA" opacity="0.3"/>
|
||||
|
||||
<!-- Cheeks -->
|
||||
<ellipse cx="48" cy="112" rx="18" ry="20" fill="#3B82F6"/>
|
||||
<ellipse cx="152" cy="112" rx="18" ry="20" fill="#3B82F6"/>
|
||||
|
||||
<!-- Horns -->
|
||||
<path d="M76 44 C 65 16, 84 6, 90 30 C 92 38, 88 44, 82 46 Z" fill="#1E3A5F" stroke="#0F2942" stroke-width="1"/>
|
||||
<path d="M80 40 C 72 22, 80 14, 86 34 Z" fill="#2563EB" opacity="0.5"/>
|
||||
<path d="M124 44 C 135 16, 116 6, 110 30 C 108 38, 112 44, 118 46 Z" fill="#1E3A5F" stroke="#0F2942" stroke-width="1"/>
|
||||
<path d="M120 40 C 128 22, 120 14, 114 34 Z" fill="#2563EB" opacity="0.5"/>
|
||||
|
||||
<!-- Forehead ridges -->
|
||||
<path d="M82 52 C 90 45, 110 45, 118 52" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
<path d="M86 60 C 92 55, 108 55, 114 60" stroke="#2563EB" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
|
||||
<!-- Snout -->
|
||||
<ellipse cx="100" cy="120" rx="28" ry="20" fill="#60A5FA"/>
|
||||
<path d="M76 114 C 86 106, 114 106, 124 114 C 118 118, 82 118, 76 114 Z" fill="#93C5FD" opacity="0.3"/>
|
||||
|
||||
<!-- Nostrils -->
|
||||
<ellipse cx="91" cy="117" rx="3" ry="3.5" fill="#2563EB"/>
|
||||
<ellipse cx="109" cy="117" rx="3" ry="3.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Blush -->
|
||||
<ellipse cx="60" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35"/>
|
||||
<ellipse cx="140" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35"/>
|
||||
|
||||
<!-- Left eye -->
|
||||
<ellipse cx="78" cy="92" rx="16" ry="18" fill="#111827"/>
|
||||
<ellipse cx="78" cy="92" rx="13" ry="15" fill="#0D9488"/>
|
||||
<ellipse cx="78" cy="92" rx="8" ry="10" fill="#111827"/>
|
||||
<circle cx="72" cy="85" r="5" fill="#FFFFFF"/>
|
||||
<circle cx="85" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7"/>
|
||||
<circle cx="71" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4"/>
|
||||
|
||||
<!-- Right eye -->
|
||||
<ellipse cx="122" cy="92" rx="16" ry="18" fill="#111827"/>
|
||||
<ellipse cx="122" cy="92" rx="13" ry="15" fill="#0D9488"/>
|
||||
<ellipse cx="122" cy="92" rx="8" ry="10" fill="#111827"/>
|
||||
<circle cx="116" cy="85" r="5" fill="#FFFFFF"/>
|
||||
<circle cx="129" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7"/>
|
||||
<circle cx="115" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4"/>
|
||||
|
||||
<!-- Smile -->
|
||||
<path d="M84 124 C 88 130, 98 128, 100 124 C 102 128, 112 130, 116 124" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M88 123 L 90 125 L 92 123 Z" fill="#FFFFFF" opacity="0.7"/>
|
||||
<path d="M112 123 L 110 125 L 108 123 Z" fill="#FFFFFF" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
63
web/src/App.tsx
Normal file
63
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import Navbar from './components/Navbar';
|
||||
import Hero from './components/Hero';
|
||||
import Features from './components/Features';
|
||||
import TerminalSimulator from './components/TerminalSimulator';
|
||||
import SetupGuide from './components/SetupGuide';
|
||||
import KeycapGallery from './components/KeycapGallery';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
export default function App() {
|
||||
const [activeView, setActiveView] = useState<'home' | 'keycaps'>('home');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0b0d] text-slate-200 flex flex-col font-sans antialiased selection:bg-emerald-500/30 selection:text-white">
|
||||
|
||||
{/* Dynamic Navigation bar */}
|
||||
<Navbar activeView={activeView} setActiveView={setActiveView} />
|
||||
|
||||
{/* Main page content layout with view switcher transitions */}
|
||||
<main className="flex-1">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === 'home' ? (
|
||||
<motion.div
|
||||
key="home-view"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Hero & Official Announcement Card */}
|
||||
<Hero setActiveView={setActiveView} />
|
||||
|
||||
{/* Core technical pillars section */}
|
||||
<Features />
|
||||
|
||||
{/* Live Interactive Terminal Simulator VNI Engine */}
|
||||
<TerminalSimulator />
|
||||
|
||||
{/* Step-by-step Linux System-Level Setup Guide */}
|
||||
<SetupGuide />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="keycaps-view"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Trang phụ: 3D Transparent Resin Keycap Customizer & Gallery */}
|
||||
<KeycapGallery />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer component with social repository links & author credits */}
|
||||
<Footer />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
web/src/components/DragonMascot.tsx
Normal file
180
web/src/components/DragonMascot.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface DragonMascotProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export default function DragonMascot({ className = '', size = 150, interactive = true }: DragonMascotProps) {
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!interactive) return;
|
||||
setIsClicked(true);
|
||||
setTimeout(() => setIsClicked(false), 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative select-none flex flex-col items-center justify-center ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => interactive && setIsHovered(true)}
|
||||
onMouseLeave={() => interactive && setIsHovered(false)}
|
||||
>
|
||||
<motion.svg
|
||||
viewBox="0 0 200 200"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-full drop-shadow-xl"
|
||||
animate={{
|
||||
y: isHovered ? [0, -6, 0] : [0, -2, 0],
|
||||
rotate: isClicked ? [0, -8, 8, -4, 4, 0] : 0,
|
||||
scale: isClicked ? [1, 1.12, 0.96, 1.04, 1] : 1,
|
||||
}}
|
||||
transition={{
|
||||
y: { repeat: Infinity, duration: isHovered ? 1.2 : 3, ease: "easeInOut" },
|
||||
rotate: { duration: 0.5 },
|
||||
scale: { duration: 0.5 }
|
||||
}}
|
||||
>
|
||||
{/* Left ear */}
|
||||
<motion.path
|
||||
d="M38 72 C 15 45, 12 85, 34 108 Z"
|
||||
fill="#1E3A5F"
|
||||
animate={{ rotate: isHovered ? -3 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<path d="M36 70 C 20 52, 18 80, 32 100 Z" fill="#2563EB" opacity="0.6" />
|
||||
|
||||
{/* Right ear */}
|
||||
<motion.path
|
||||
d="M162 72 C 185 45, 188 85, 166 108 Z"
|
||||
fill="#1E3A5F"
|
||||
animate={{ rotate: isHovered ? 3 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<path d="M164 70 C 180 52, 182 80, 168 100 Z" fill="#2563EB" opacity="0.6" />
|
||||
|
||||
{/* Main head - bigger, fills more of the viewbox */}
|
||||
<ellipse cx="100" cy="100" rx="62" ry="58" fill="#3B82F6" />
|
||||
|
||||
{/* Head highlight */}
|
||||
<ellipse cx="100" cy="72" rx="44" ry="22" fill="#60A5FA" opacity="0.3" />
|
||||
|
||||
{/* Cheeks */}
|
||||
<ellipse cx="48" cy="112" rx="18" ry="20" fill="#3B82F6" />
|
||||
<ellipse cx="152" cy="112" rx="18" ry="20" fill="#3B82F6" />
|
||||
|
||||
{/* Horns */}
|
||||
<motion.path
|
||||
d="M76 44 C 65 16, 84 6, 90 30 C 92 38, 88 44, 82 46 Z"
|
||||
fill="#1E3A5F"
|
||||
stroke="#0F2942"
|
||||
strokeWidth="1"
|
||||
animate={{ rotate: isHovered ? -3 : 0 }}
|
||||
/>
|
||||
<path d="M80 40 C 72 22, 80 14, 86 34 Z" fill="#2563EB" opacity="0.5" />
|
||||
|
||||
<motion.path
|
||||
d="M124 44 C 135 16, 116 6, 110 30 C 108 38, 112 44, 118 46 Z"
|
||||
fill="#1E3A5F"
|
||||
stroke="#0F2942"
|
||||
strokeWidth="1"
|
||||
animate={{ rotate: isHovered ? 3 : 0 }}
|
||||
/>
|
||||
<path d="M120 40 C 128 22, 120 14, 114 34 Z" fill="#2563EB" opacity="0.5" />
|
||||
|
||||
{/* Forehead ridges */}
|
||||
<path d="M82 52 C 90 45, 110 45, 118 52" stroke="#2563EB" strokeWidth="2.5" strokeLinecap="round" fill="none" opacity="0.5" />
|
||||
<path d="M86 60 C 92 55, 108 55, 114 60" stroke="#2563EB" strokeWidth="2" strokeLinecap="round" fill="none" opacity="0.35" />
|
||||
|
||||
{/* Snout */}
|
||||
<ellipse cx="100" cy="120" rx="28" ry="20" fill="#60A5FA" />
|
||||
<path d="M76 114 C 86 106, 114 106, 124 114 C 118 118, 82 118, 76 114 Z" fill="#93C5FD" opacity="0.3" />
|
||||
|
||||
{/* Nostrils */}
|
||||
<ellipse cx="91" cy="117" rx="3" ry="3.5" fill="#2563EB" />
|
||||
<ellipse cx="109" cy="117" rx="3" ry="3.5" fill="#2563EB" />
|
||||
|
||||
{/* Blush */}
|
||||
<ellipse cx="60" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35" />
|
||||
<ellipse cx="140" cy="120" rx="12" ry="6" fill="#F87171" opacity="0.35" />
|
||||
|
||||
{/* Eyes */}
|
||||
<g>
|
||||
<ellipse cx="78" cy="92" rx="16" ry="18" fill="#111827" />
|
||||
<ellipse cx="78" cy="92" rx="13" ry="15" fill="#0D9488" />
|
||||
<ellipse cx="78" cy="92" rx="8" ry="10" fill="#111827" />
|
||||
<motion.circle
|
||||
cx="72" cy="85" r="5" fill="#FFFFFF"
|
||||
animate={{ scale: isHovered ? [1, 1.25, 1] : 1 }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
/>
|
||||
<circle cx="85" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7" />
|
||||
<circle cx="71" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4" />
|
||||
<motion.path
|
||||
d="M60 72 H 96 V 94 H 60 Z"
|
||||
fill="#3B82F6"
|
||||
transformOrigin="78px 72px"
|
||||
animate={{ scaleY: [0, 0, 1, 0, 0, 0, 1, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 3.5, times: [0, 0.4, 0.45, 0.5, 0.85, 0.9, 0.95, 1] }}
|
||||
/>
|
||||
</g>
|
||||
<g>
|
||||
<ellipse cx="122" cy="92" rx="16" ry="18" fill="#111827" />
|
||||
<ellipse cx="122" cy="92" rx="13" ry="15" fill="#0D9488" />
|
||||
<ellipse cx="122" cy="92" rx="8" ry="10" fill="#111827" />
|
||||
<motion.circle
|
||||
cx="116" cy="85" r="5" fill="#FFFFFF"
|
||||
animate={{ scale: isHovered ? [1, 1.25, 1] : 1 }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
/>
|
||||
<circle cx="129" cy="99" r="2.2" fill="#FFFFFF" opacity="0.7" />
|
||||
<circle cx="115" cy="97" r="1.4" fill="#FFFFFF" opacity="0.4" />
|
||||
<motion.path
|
||||
d="M104 72 H 140 V 94 H 104 Z"
|
||||
fill="#3B82F6"
|
||||
transformOrigin="122px 72px"
|
||||
animate={{ scaleY: [0, 0, 1, 0, 0, 0, 1, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 3.5, times: [0, 0.4, 0.45, 0.5, 0.85, 0.9, 0.95, 1] }}
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Smile */}
|
||||
{isHovered || isClicked ? (
|
||||
<g>
|
||||
<path d="M82 124 C 82 138, 118 138, 118 124 Z" fill="#991B1B" />
|
||||
<path d="M86 130 C 90 134, 110 134, 114 130 Z" fill="#FCA5A5" />
|
||||
<path d="M84 124 L 88 128 L 92 124 Z" fill="#FFFFFF" />
|
||||
<path d="M116 124 L 112 128 L 108 124 Z" fill="#FFFFFF" />
|
||||
</g>
|
||||
) : (
|
||||
<g>
|
||||
<path d="M84 124 C 88 130, 98 128, 100 124 C 102 128, 112 130, 116 124" stroke="#2563EB" strokeWidth="2.5" strokeLinecap="round" fill="none" />
|
||||
<path d="M88 123 L 90 125 L 92 123 Z" fill="#FFFFFF" opacity="0.7" />
|
||||
<path d="M112 123 L 110 125 L 108 123 Z" fill="#FFFFFF" opacity="0.7" />
|
||||
</g>
|
||||
)}
|
||||
</motion.svg>
|
||||
|
||||
{interactive && (
|
||||
<motion.div
|
||||
className="absolute -top-6 bg-[#0d0e12] text-emerald-400 text-[10px] font-mono px-2 py-0.5 rounded-full border border-emerald-500/20 shadow-md pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
scale: isHovered ? 1 : 0.8,
|
||||
y: isHovered ? -4 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{isClicked ? "Rawrr! ^_^" : "Click me!"}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
web/src/components/Features.tsx
Normal file
188
web/src/components/Features.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { ShieldCheck, Cpu, GitCompare, HelpCircle, Layers, ArrowRight } from 'lucide-react';
|
||||
|
||||
export default function Features() {
|
||||
const [hoveredState, setHoveredState] = useState<number | null>(null);
|
||||
|
||||
const stateDetails = [
|
||||
{
|
||||
id: 0,
|
||||
name: "S0 - Idle (Chờ phím)",
|
||||
desc: "Trạng thái nghỉ ngơi ban đầu. VietC lắng nghe thụ động thiết bị đầu vào evdev mà không can thiệp, đảm bảo CPU tiêu thụ ~0%."
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "S1 - Vowel Buffer (Thu nhận nguyên âm)",
|
||||
desc: "Kích hoạt khi phát hiện nguyên âm gốc (a, e, o, u, y, i). VietC tạo bộ đệm từ cục bộ để chuẩn bị ghép dấu thanh."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "S2 - Accent Applied (Tạo dấu thanh)",
|
||||
desc: "Nạp các phím gõ dấu thanh VNI (1-5). Thuật toán tối ưu hóa vị trí đặt dấu theo đúng ngữ pháp Việt ngữ chuẩn."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "S3 - Modifiers (Ký tự đặc biệt)",
|
||||
desc: "Áp dụng mũ/râu (6-9) để biến đổi thành ă, â, đ, ê, ô, ơ, ư. Kết thúc chu kỳ xử lý và sẵn sàng phát phím uinput mới."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="features" className="py-16 bg-[#0a0b0d] border-t border-white/10 scroll-mt-20">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Title */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono mb-4"
|
||||
>
|
||||
<Cpu size={12} className="text-emerald-500" />
|
||||
<span>HOW IT WORKS</span>
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl font-serif text-white tracking-tight"
|
||||
>
|
||||
Sự Khác Biệt Làm Nên Sức Mạnh <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC</span>
|
||||
</motion.h2>
|
||||
<p className="mt-4 text-slate-400 text-sm sm:text-base leading-relaxed">
|
||||
VietC được phát triển dựa trên 3 trụ cột kỹ thuật cốt lõi giúp tối đa hóa tốc độ, độ ổn định tuyệt đối và khả năng tương thích 100% với môi trường giả lập Linux Terminal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3 Pillars Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
|
||||
|
||||
{/* Pillar 1: State Machine */}
|
||||
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
|
||||
<div>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
|
||||
<Layers size={20} />
|
||||
</div>
|
||||
<h3 className="text-base font-sans font-bold text-white mb-3">
|
||||
1. State Machine Deterministic
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
|
||||
Sử dụng mô hình toán học Finite State Machine (FSM) tất định để phân tích tổ hợp phím gõ tiếng Việt. Mọi ký tự được tính toán rõ ràng giúp tránh tình trạng xung đột, mất từ hoặc sai vị trí đặt dấu khi gõ nhanh.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10">
|
||||
S0 (Chờ) → S1 (Gõ) → S2 (Dấu) → S3 (Chữ mũ)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 2: Token-Level Diffing */}
|
||||
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
|
||||
<div>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
|
||||
<GitCompare size={20} />
|
||||
</div>
|
||||
<h3 className="text-base font-sans font-bold text-white mb-3">
|
||||
2. Token-Level Diffing
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
|
||||
Thay vì xóa trắng toàn bộ từ hoặc phát lại một loạt phím Backspace dồn dập gây giật màn hình trong Terminal, VietC tính toán sự khác biệt nhỏ nhất giữa từ đã gõ và từ mong muốn để thay thế cục bộ tức thì.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10 flex items-center justify-between">
|
||||
<span>trang thái</span>
|
||||
<ArrowRight size={10} />
|
||||
<span className="font-bold">trạng thái [1ms]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 3: Privacy-First Event Sourcing */}
|
||||
<div className="bg-white/[0.02] rounded-2xl border border-white/5 p-6 flex flex-col justify-between hover:border-emerald-500/30 transition-all">
|
||||
<div>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mb-5">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<h3 className="text-base font-sans font-bold text-white mb-3">
|
||||
3. Privacy-First Event Sourcing
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
|
||||
Xử lý sự kiện bàn phím theo luồng độc lập dưới quyền user thông qua uinput cục bộ. VietC nói KHÔNG với kết nối Internet, đảm bảo toàn bộ mật khẩu, lệnh Terminal nhạy cảm luôn được bảo vệ an toàn tuyệt đối.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-emerald-400 mt-2 bg-emerald-950/15 p-2.5 rounded-lg border border-emerald-500/10">
|
||||
Kiểm soát cục bộ 100% • Không thu thập dữ liệu
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Detalized State Machine Explanation Block */}
|
||||
<div className="bg-white/[0.02] p-6 sm:p-8 rounded-3xl border border-white/10 flex flex-col lg:flex-row gap-8 items-center">
|
||||
|
||||
{/* Diagrams Left */}
|
||||
<div className="w-full lg:w-1/2 space-y-4">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-white mb-2">
|
||||
Tìm Hiểu Trạng Thái Finite State Machine
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed">
|
||||
Khi bạn gõ phím, VietC không lưu trữ ký tự dưới dạng văn bản tĩnh thô sơ. Hệ thống sẽ thay đổi các nút liên kết (S0, S1, S2, S3) dựa trên loại phím nhận được để tính toán cách phản hồi phím nhanh nhất. Di chuột vào các nút dưới đây để xem mô tả:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-3">
|
||||
{stateDetails.map((det) => (
|
||||
<div
|
||||
key={det.id}
|
||||
onMouseEnter={() => setHoveredState(det.id)}
|
||||
onMouseLeave={() => setHoveredState(null)}
|
||||
className={`p-3 rounded-xl border transition-all cursor-pointer ${
|
||||
hoveredState === det.id
|
||||
? 'bg-emerald-500/10 border-emerald-500 text-emerald-300'
|
||||
: 'bg-[#0a0b0d] border-white/5 text-slate-400 hover:border-emerald-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-white mb-1">
|
||||
Trạng thái S{det.id}
|
||||
</div>
|
||||
<div className="text-[10px] leading-relaxed">
|
||||
{det.name.split(' - ')[1]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive State explanation box Right */}
|
||||
<div className="w-full lg:w-1/2 bg-[#0a0b0d] p-6 rounded-2xl border border-white/10 min-h-[220px] flex flex-col justify-center">
|
||||
{hoveredState !== null ? (
|
||||
<motion.div
|
||||
key={`state-det-${hoveredState}`}
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="inline-flex px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 font-mono text-[10px] font-bold">
|
||||
SỰ KIỆN ĐANG HOẠT ĐỘNG: S{hoveredState}
|
||||
</div>
|
||||
<h4 className="text-base font-sans font-bold text-white">
|
||||
{stateDetails[hoveredState].name}
|
||||
</h4>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed">
|
||||
{stateDetails[hoveredState].desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500 text-xs sm:text-sm leading-relaxed py-8">
|
||||
<HelpCircle className="mx-auto text-slate-600 mb-3" size={24} />
|
||||
Hãy di chuột qua các nút trạng thái bên cạnh để khám phá cách thiết lập hệ thống logic gõ phím tất định của VietC!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/components/Footer.tsx
Normal file
66
web/src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import { Github, Heart, MessageSquare } from 'lucide-react';
|
||||
import DragonMascot from './DragonMascot';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-[#0a0b0d] border-t border-white/10 py-12 px-4">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
|
||||
{/* Left branding */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white/[0.02] rounded-lg border border-white/10 flex items-center justify-center p-0.5">
|
||||
<DragonMascot size={34} interactive={false} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-sans font-black text-slate-100 text-lg tracking-wider block">
|
||||
VietC Project
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-500 leading-none">
|
||||
Bàn phím cơ & Bộ gõ tiếng Việt mức thấp cho Linux Terminal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Credits */}
|
||||
<div className="text-center md:text-right text-xs text-slate-500 font-mono space-y-1">
|
||||
<div>
|
||||
Phát triển bởi <a href="https://github.com/vndangkhoa" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-emerald-400 underline font-semibold">vndangkhoa</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-center md:justify-end gap-1.5 text-[11px] text-slate-600">
|
||||
<span>Made with</span>
|
||||
<Heart size={10} className="text-rose-500 animate-pulse fill-rose-500" />
|
||||
<span>for Vietnamese Linux Community</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right External Links */}
|
||||
<div className="flex items-center gap-4 text-slate-500">
|
||||
<a
|
||||
href="https://github.com/vndangkhoa/vietc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-emerald-400 transition-colors"
|
||||
title="GitHub Repository"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/vndangkhoa/vietc/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-emerald-400 transition-colors"
|
||||
title="Đóng góp ý kiến"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto mt-8 pt-6 border-t border-white/5 text-center text-[10px] font-mono text-slate-600">
|
||||
© {new Date().getFullYear()} VietC. Phát hành theo Giấy phép Apache-2.0 / MIT.
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
156
web/src/components/Hero.tsx
Normal file
156
web/src/components/Hero.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Terminal, ArrowRight, Sparkles, Shield, Cpu, Zap, Download } from 'lucide-react';
|
||||
import DragonMascot from './DragonMascot';
|
||||
|
||||
interface HeroProps {
|
||||
setActiveView: (view: 'home' | 'keycaps') => void;
|
||||
}
|
||||
|
||||
export default function Hero({ setActiveView }: HeroProps) {
|
||||
const scrollToDemo = () => {
|
||||
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const scrollToSetup = () => {
|
||||
document.getElementById('setup-guide')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative pt-10 pb-20 px-4 sm:px-6 overflow-hidden bg-[#0a0b0d]">
|
||||
|
||||
{/* Background ambient lighting */}
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] rounded-full bg-emerald-500/5 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute top-1/3 right-1/4 w-[600px] h-[600px] rounded-full bg-teal-500/5 blur-[150px] pointer-events-none" />
|
||||
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-12 items-center relative z-10">
|
||||
|
||||
{/* LEFT COLUMN: Main Presentation & CTAs */}
|
||||
<div className="lg:col-span-6 space-y-6 text-left">
|
||||
|
||||
{/* Version badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono font-semibold"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span>VietC v1.2.0 - Native Linux Input Mode</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-4xl sm:text-5xl lg:text-6xl font-serif text-white leading-tight"
|
||||
>
|
||||
Gõ Tiếng Việt <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">
|
||||
"Như Bay"
|
||||
</span> <br />
|
||||
Mượt Mà Trên Linux!
|
||||
</motion.h1>
|
||||
|
||||
<p className="text-slate-400 text-sm sm:text-base leading-relaxed max-w-lg">
|
||||
VietC là giải pháp nhập liệu mã nguồn mở hiện đại cho môi trường Linux, tối ưu hóa tốc độ và sự đơn giản với linh vật chú rồng con Long-kun. Không qua IBus/Fcitx5 phức tạp, giải quyết triệt để lỗi nuốt phím và lag chữ.
|
||||
</p>
|
||||
|
||||
{/* Quick Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md pt-2">
|
||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
|
||||
<span className="text-xs text-slate-500 font-mono">Keystroke</span>
|
||||
<span className="text-sm font-bold text-emerald-400 font-mono">0ms</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
|
||||
<span className="text-xs text-slate-500 font-mono">IBus/Fcitx5</span>
|
||||
<span className="text-sm font-bold text-emerald-300 font-mono">Bypass</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 flex flex-col items-center">
|
||||
<span className="text-xs text-slate-500 font-mono">Trễ Phím</span>
|
||||
<span className="text-sm font-bold text-emerald-400 font-mono">Giảm 20x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
onClick={scrollToSetup}
|
||||
className="px-6 py-3.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-[#0a0b0d] font-sans font-bold text-sm transition-all shadow-[0_0_20px_rgba(16,185,129,0.25)] flex items-center justify-center gap-2 group cursor-pointer"
|
||||
>
|
||||
Cài Đặt Trên Linux
|
||||
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('keycaps')}
|
||||
className="px-6 py-3.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 hover:text-white font-sans font-bold text-sm transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Sparkles size={14} className="text-emerald-400 animate-pulse" />
|
||||
Artisan Keycaps 3D
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Official Announcement Card */}
|
||||
<div className="lg:col-span-6 flex flex-col items-center">
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="w-full max-w-md bg-gradient-to-b from-white/[0.04] to-transparent p-6 sm:p-8 rounded-[2rem] border border-white/10 shadow-2xl relative overflow-hidden"
|
||||
>
|
||||
{/* Soft inner corner borders */}
|
||||
<div className="absolute top-2 left-2 w-4 h-4 border-t border-l border-white/20 rounded-tl" />
|
||||
<div className="absolute top-2 right-2 w-4 h-4 border-t border-r border-white/20 rounded-tr" />
|
||||
<div className="absolute bottom-2 left-2 w-4 h-4 border-b border-l border-white/20 rounded-bl" />
|
||||
<div className="absolute bottom-2 right-2 w-4 h-4 border-b border-r border-white/20 rounded-br" />
|
||||
|
||||
{/* Mascot on top of announcement */}
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<DragonMascot size={110} />
|
||||
<h2 className="font-sans font-black text-white text-2xl tracking-widest uppercase mt-1">
|
||||
VIETC<span className="text-emerald-500">.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Official Announcement body */}
|
||||
<div className="space-y-6 text-center">
|
||||
|
||||
<div className="bg-emerald-950/20 border border-emerald-500/20 py-2.5 px-4 rounded-xl">
|
||||
<span className="font-sans font-black text-xl sm:text-2xl text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 via-teal-300 to-emerald-400 tracking-wide block uppercase">
|
||||
TUYÊN BỐ CHÍNH THỨC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-300 text-xs sm:text-sm leading-relaxed text-justify">
|
||||
Để đơn giản hóa tối đa việc gõ VNI trên Terminal bấy lâu nay vô cùng <span className="text-emerald-400 font-bold">‘gian khổ’</span> cho dân Linux, <span className="text-emerald-400 font-bold">VIETC</span> tự hào công bố đã support native gõ VNI trên Terminal.
|
||||
</p>
|
||||
|
||||
<p className="text-slate-400 text-xs leading-relaxed text-justify">
|
||||
Quý khách có thể tải toàn bộ các bản thiết kế <span className="text-emerald-400 font-bold">3D phím Numlock và Keycap</span> trên website VIETC xuống. Sau đấy, tận dụng trí tưởng tượng phong phú để <span className="text-emerald-400 font-bold">lắp ghép</span> và trải nghiệm cảm giác <span className="text-teal-300 font-bold">‘gõ như bay’</span> ngay trên Terminal ảo của bạn mà không cần bất kỳ phần cứng vật lý nào.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Try online indicator */}
|
||||
<div className="mt-6 pt-4 border-t border-white/5 flex justify-center">
|
||||
<button
|
||||
onClick={scrollToDemo}
|
||||
className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 flex items-center gap-1.5 transition-colors cursor-pointer"
|
||||
>
|
||||
<Terminal size={12} className="text-emerald-500" />
|
||||
<span>Trải nghiệm giả lập Terminal bên dưới ↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
web/src/components/KeycapGallery.tsx
Normal file
475
web/src/components/KeycapGallery.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Download, Sliders, Palette, Lightbulb, Type, Layers, CheckCircle2, Star, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { KeycapCustomization, KeycapModel } from '../types';
|
||||
import DragonMascot from './DragonMascot';
|
||||
|
||||
export default function KeycapGallery() {
|
||||
const [custom, setCustom] = useState<KeycapCustomization>({
|
||||
baseColor: '#0E7490', // Cyan 700
|
||||
stemColor: '#10B981', // Emerald 500
|
||||
dragonColor: '#3B82F6', // Blue 500
|
||||
material: 'resin_clear',
|
||||
ledColor: '#06B6D4', // Cyan 500
|
||||
ledIntensity: 75,
|
||||
selectedLetter: 'đ',
|
||||
showStem: true
|
||||
});
|
||||
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
const lettersList = ['ă', 'â', 'đ', 'ê', 'ô', 'ơ', 'ư', 's (́)', 'f (̀)', 'r (̉)', 'x (̃)', 'j (̣)'];
|
||||
|
||||
const colorPresets = [
|
||||
{ name: 'Rồng Biển Trầm', value: '#0E7490', text: 'text-cyan-400' },
|
||||
{ name: 'Ngọc Lục Bảo', value: '#047857', text: 'text-emerald-400' },
|
||||
{ name: 'Hồng Anh Đào', value: '#BE185D', text: 'text-pink-400' },
|
||||
{ name: 'Hổ Phách Sáng', value: '#B45309', text: 'text-amber-400' },
|
||||
{ name: 'Thạch Anh Tím', value: '#6D28D9', text: 'text-violet-400' },
|
||||
{ name: 'Khói Obsidian', value: '#374151', text: 'text-slate-400' }
|
||||
];
|
||||
|
||||
const ledPresets = [
|
||||
{ name: 'Cyan Neon', value: '#06B6D4' },
|
||||
{ name: 'Toxic Green', value: '#10B981' },
|
||||
{ name: 'Sunset Orange', value: '#F97316' },
|
||||
{ name: 'Sakura Pink', value: '#EC4899' },
|
||||
{ name: 'Chroma RGB', value: '#8B5CF6' }
|
||||
];
|
||||
|
||||
const keycapModels: KeycapModel[] = [
|
||||
{
|
||||
id: 'dragon_keycap_oem',
|
||||
name: 'Rồng Con OEM Esc Keycap',
|
||||
letter: 'ESC',
|
||||
desc: 'Mẫu phím cơ Esc chứa rồng con dễ thương ở trung tâm, đúc khuôn resin thủ công siêu chi tiết.',
|
||||
rarity: 'Legendary',
|
||||
stlUrl: 'vietc_dragon_esc_oem.stl'
|
||||
},
|
||||
{
|
||||
id: 'vietnamese_diacritic_caps',
|
||||
name: 'Bộ Ký Tự Nguyên Âm Tiếng Việt',
|
||||
letter: 'ă/â/đ/ê/ô/ơ/ư',
|
||||
desc: 'Trọn bộ keycap các chữ cái đặc trưng và bộ thanh dấu trong tiếng Việt dành cho hàng phím Alpha.',
|
||||
rarity: 'Epic',
|
||||
stlUrl: 'vietc_vietnamese_alphas.zip'
|
||||
},
|
||||
{
|
||||
id: 'numlock_dragon_plate',
|
||||
name: 'Tấm Ốp Phím Numlock 3D',
|
||||
letter: 'NUM',
|
||||
desc: 'Bản thiết kế ốp bàn phím số cơ phong cách Rồng Con đan xen các vảy rồng bảo vệ cực chất.',
|
||||
rarity: 'Rare',
|
||||
stlUrl: 'vietc_numlock_plate.stl'
|
||||
},
|
||||
{
|
||||
id: 'dragon_spacebar_625u',
|
||||
name: 'Thanh Spacebar Thủy Cung Rồng Con 6.25u',
|
||||
letter: 'SPACE',
|
||||
desc: 'Thanh phím dài uốn lượn phong cách rồng con bay lượn dưới đáy đại dương resin trong suốt.',
|
||||
rarity: 'Legendary',
|
||||
stlUrl: 'vietc_spacebar_dragon.stl'
|
||||
}
|
||||
];
|
||||
|
||||
const startDownload = (model: KeycapModel) => {
|
||||
if (downloadingId) return;
|
||||
setDownloadingId(model.id);
|
||||
setDownloadProgress(0);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDownloadProgress(p => {
|
||||
if (p >= 100) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
setDownloadingId(null);
|
||||
setToastMessage(`Đã tải về thành công tệp thiết kế 3D: ${model.stlUrl}! Sẵn sàng để in 3D FDM/SLA.`);
|
||||
setShowSuccessToast(true);
|
||||
setTimeout(() => setShowSuccessToast(false), 4500);
|
||||
}, 400);
|
||||
return 100;
|
||||
}
|
||||
return p + 5 + Math.floor(Math.random() * 8);
|
||||
});
|
||||
}, 120);
|
||||
};
|
||||
|
||||
// Compute CSS styles based on material
|
||||
const getMaterialStyles = () => {
|
||||
switch (custom.material) {
|
||||
case 'resin_frosted':
|
||||
return {
|
||||
backdropFilter: 'blur(8px)',
|
||||
background: `rgba(${hexToRgb(custom.baseColor)}, 0.45)`,
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
boxShadow: `inset 0 0 15px rgba(255, 255, 255, 0.3), 0 0 25px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
|
||||
};
|
||||
case 'glass':
|
||||
return {
|
||||
backdropFilter: 'blur(3px)',
|
||||
background: `rgba(${hexToRgb(custom.baseColor)}, 0.25)`,
|
||||
border: '1px solid rgba(255, 255, 255, 0.4)',
|
||||
boxShadow: `inset 0 0 20px rgba(255, 255, 255, 0.4), 0 0 35px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
|
||||
};
|
||||
case 'matte':
|
||||
return {
|
||||
background: custom.baseColor,
|
||||
border: '1px solid rgba(0, 0, 0, 0.3)',
|
||||
boxShadow: 'inset 0 4px 6px rgba(255, 255, 255, 0.1), inset 0 -4px 6px rgba(0, 0, 0, 0.2)'
|
||||
};
|
||||
default: // resin_clear
|
||||
return {
|
||||
backdropFilter: 'blur(1px)',
|
||||
background: `rgba(${hexToRgb(custom.baseColor)}, 0.65)`,
|
||||
border: '1px solid rgba(255, 255, 255, 0.35)',
|
||||
boxShadow: `inset 0 0 10px rgba(255, 255, 255, 0.4), 0 0 25px ${custom.ledColor}${Math.floor(custom.ledIntensity / 100 * 255).toString(16)}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to convert hex to rgb
|
||||
function hexToRgb(hex: string): string {
|
||||
const bigint = parseInt(hex.replace('#', ''), 16);
|
||||
const r = (bigint >> 16) & 255;
|
||||
const g = (bigint >> 8) & 255;
|
||||
const b = bigint & 255;
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="keycaps" className="py-16 bg-[#0a0b0d] border-t border-white/10 scroll-mt-20">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono mb-4"
|
||||
>
|
||||
<Sparkles size={12} className="text-emerald-400 animate-pulse" />
|
||||
<span>ARTISAN 3D KEYCAPS</span>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl font-serif text-white tracking-tight">
|
||||
Trang Trí Phím Cơ <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC Resin 3D</span>
|
||||
</h2>
|
||||
<p className="mt-4 text-slate-400 text-sm sm:text-base leading-relaxed">
|
||||
Như công bố chính thức, VietC không chỉ là phần mềm gõ phím, chúng tôi chia sẻ bản vẽ thiết kế 3D hoàn toàn miễn phí của <span className="text-emerald-400 font-semibold">Mascot Rồng Con Resin trong suốt</span> và bộ ký tự dấu mũ tiếng Việt để bạn tự in 3D cá nhân hóa bàn phím cơ của mình!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* WORKSPACE: Customizer on the left, interactive keycap on the right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center mb-16">
|
||||
|
||||
{/* Controls Panel (6 cols) */}
|
||||
<div className="lg:col-span-6 bg-white/[0.02] p-5 sm:p-6 rounded-2xl border border-white/10 space-y-6">
|
||||
<div className="flex items-center gap-2 border-b border-white/5 pb-3">
|
||||
<Sliders size={18} className="text-emerald-400" />
|
||||
<h3 className="font-sans font-bold text-sm text-slate-200 tracking-wider uppercase">Bảng Điều Khiển Tùy Biến 3D</h3>
|
||||
</div>
|
||||
|
||||
{/* Vải Màu Resin */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<Palette size={14} className="text-emerald-400" />
|
||||
<span>Màu Sắc Resin Bọc Ngoài</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => setCustom(prev => ({ ...prev, baseColor: preset.value }))}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg border text-[11px] font-medium transition-all cursor-pointer ${
|
||||
custom.baseColor === preset.value
|
||||
? 'bg-white/5 border-emerald-500 text-white'
|
||||
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full border border-slate-700/50" style={{ backgroundColor: preset.value }} />
|
||||
<span className="truncate">{preset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chất liệu Resin */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-emerald-400" />
|
||||
<span>Chất Liệu Đúc Keycap</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] font-mono">
|
||||
{[
|
||||
{ id: 'resin_clear', name: 'Trong Suốt' },
|
||||
{ id: 'resin_frosted', name: 'Nhám Mờ' },
|
||||
{ id: 'glass', name: 'Thạch Anh' },
|
||||
{ id: 'matte', name: 'Nhựa Đục' }
|
||||
].map((mat) => (
|
||||
<button
|
||||
key={mat.id}
|
||||
onClick={() => setCustom(prev => ({ ...prev, material: mat.id as any }))}
|
||||
className={`py-1.5 px-1 rounded-lg border text-center font-medium transition-all cursor-pointer ${
|
||||
custom.material === mat.id
|
||||
? 'bg-emerald-500/10 border-emerald-500 text-emerald-400'
|
||||
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{mat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ký Tự Tiếng Việt */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<Type size={14} className="text-emerald-400" />
|
||||
<span>Ký Tự / Dấu Thanh Tiếng Việt Khắc Trên Mặt Phím</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lettersList.map((letItem) => (
|
||||
<button
|
||||
key={letItem}
|
||||
onClick={() => setCustom(prev => ({ ...prev, selectedLetter: letItem }))}
|
||||
className={`w-10 h-10 rounded-lg border flex items-center justify-center font-sans font-bold text-sm transition-all cursor-pointer ${
|
||||
custom.selectedLetter === letItem
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)]'
|
||||
: 'bg-[#0d0e12] border-white/5 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{letItem}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Đèn LED gầm (Underglow) */}
|
||||
<div className="space-y-3 pt-2 border-t border-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<Lightbulb size={14} className="text-emerald-400" />
|
||||
<span>Hệ Thống Đèn LED Gầm (PCB Underglow)</span>
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-emerald-400">{custom.ledIntensity}% Độ sáng</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{ledPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => setCustom(prev => ({ ...prev, ledColor: preset.value }))}
|
||||
className={`w-full h-7 rounded-md border flex items-center justify-center transition-all cursor-pointer ${
|
||||
custom.ledColor === preset.value
|
||||
? 'border-white scale-110 shadow-lg'
|
||||
: 'border-transparent opacity-65 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.value, boxShadow: custom.ledColor === preset.value ? `0 0 10px ${preset.value}` : 'none' }}
|
||||
title={preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={custom.ledIntensity}
|
||||
onChange={(e) => setCustom(prev => ({ ...prev, ledIntensity: parseInt(e.target.value) }))}
|
||||
className="w-full h-1 bg-[#0d0e12] rounded-lg appearance-none cursor-pointer accent-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Interactive 3D Render Viewer (6 cols) */}
|
||||
<div className="lg:col-span-6 flex flex-col items-center justify-center p-6 bg-white/[0.01] rounded-2xl border border-white/5 h-[420px] relative overflow-hidden group">
|
||||
|
||||
{/* Grid background effect */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#334155_1px,transparent_1px),linear-gradient(to_bottom,#334155_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
|
||||
|
||||
{/* Floating glowing indicator */}
|
||||
<div className="absolute top-4 right-4 bg-[#0d0e12]/80 px-2.5 py-1 rounded-full border border-emerald-500/20 text-[9px] font-mono text-emerald-400 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: custom.ledColor }} />
|
||||
<span>3D RENDER PREVIEW</span>
|
||||
</div>
|
||||
|
||||
{/* Rotating 3D Keycap Stage Container */}
|
||||
<div className="relative w-64 h-64 flex items-center justify-center perspective-[1000px]">
|
||||
|
||||
{/* LED Underglow radial halo */}
|
||||
<div
|
||||
className="absolute w-48 h-48 rounded-full blur-3xl opacity-40 transition-all duration-300 pointer-events-none"
|
||||
style={{
|
||||
backgroundColor: custom.ledColor,
|
||||
transform: 'translateY(50px) scale(0.8)',
|
||||
filter: `blur(45px)`,
|
||||
opacity: (custom.ledIntensity / 100) * 0.5
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The Keycap Body Wrapper (3D effect) */}
|
||||
<motion.div
|
||||
className="w-40 h-40 relative transform-style-3d cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||
animate={{
|
||||
rotateY: [0, 360],
|
||||
rotateX: [12, 12]
|
||||
}}
|
||||
transition={{
|
||||
rotateY: { repeat: Infinity, duration: 16, ease: 'linear' }
|
||||
}}
|
||||
>
|
||||
|
||||
{/* 1. KEYCAP BASE / STEM (Inner structure visible through clear resin) */}
|
||||
{custom.material !== 'matte' && (
|
||||
<div className="absolute inset-4 rounded-xl flex items-center justify-center border border-dashed border-slate-700/30 z-10 pointer-events-none">
|
||||
{/* The mechanical switch cross stem (+) inside */}
|
||||
<div className="relative w-8 h-8 flex items-center justify-center">
|
||||
<div className="absolute w-2 h-7 rounded-sm shadow-md" style={{ backgroundColor: custom.stemColor }} />
|
||||
<div className="absolute w-7 h-2 rounded-sm shadow-md" style={{ backgroundColor: custom.stemColor }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. CUTE MASCOT DRAGON RESTING INSIDE */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none scale-65 translate-y-[-5px]">
|
||||
<DragonMascot size={110} interactive={false} />
|
||||
</div>
|
||||
|
||||
{/* 3. TRANSPARENT RESIN OUTER SHELL (Styled with standard custom glassmorphism) */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl transition-all duration-300 z-30 flex flex-col justify-between p-4.5"
|
||||
style={getMaterialStyles()}
|
||||
>
|
||||
{/* Vietnamese Letter Engraving on top facet */}
|
||||
<div className="text-right w-full">
|
||||
<span className="font-sans font-extrabold text-2xl tracking-tight text-white drop-shadow-lg opacity-85 select-none">
|
||||
{custom.selectedLetter.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Aesthetic detail: micro-bubbles or text inside */}
|
||||
<div className="flex justify-between items-end w-full text-[8px] font-mono text-white/55">
|
||||
<span>VIETC 3D</span>
|
||||
<span>OEM-R1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Base rim */}
|
||||
<div className="absolute inset-[-4px] rounded-3xl border-2 border-slate-800/40 translate-y-[8px] scale-95 opacity-50 z-0" />
|
||||
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Customizer Instructions */}
|
||||
<div className="absolute bottom-4 left-4 text-[10px] font-mono text-slate-500 flex items-center gap-1">
|
||||
<AlertCircle size={10} />
|
||||
<span>Góc xoay 3D giả lập trực quan 360°</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3D PRINTING FILES DOWNLOAD SECTION */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-sans font-bold text-slate-100 flex items-center gap-2 border-b border-white/5 pb-3">
|
||||
<Download className="text-emerald-400 animate-bounce" size={18} />
|
||||
<span>Tải Về File Thiết Kế 3D Miễn Phí (STL/OBJ)</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{keycapModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="bg-white/[0.02] rounded-2xl border border-white/5 p-5 flex flex-col justify-between hover:border-emerald-500/30 transition-all group"
|
||||
>
|
||||
<div>
|
||||
{/* Rarity and Rating info */}
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<span className={`text-[9px] font-mono px-2 py-0.5 rounded-full border ${
|
||||
model.rarity === 'Legendary'
|
||||
? 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||||
: model.rarity === 'Epic'
|
||||
? 'bg-purple-500/10 border-purple-500/20 text-purple-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
}`}>
|
||||
{model.rarity} Design
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 text-amber-400">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<Star size={10} fill="currentColor" />
|
||||
<Star size={10} fill="currentColor" />
|
||||
<Star size={10} fill="currentColor" />
|
||||
<Star size={10} fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-sans font-bold text-slate-200 group-hover:text-emerald-400 transition-colors">
|
||||
{model.name}
|
||||
</h4>
|
||||
<p className="text-slate-400 text-xs mt-2.5 leading-relaxed min-h-[48px]">
|
||||
{model.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-white/5">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 font-mono mb-3">
|
||||
<span>Định dạng: STL / STEP</span>
|
||||
<span className="text-emerald-400 font-bold">FREE</span>
|
||||
</div>
|
||||
|
||||
{downloadingId === model.id ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-mono text-emerald-400">
|
||||
<span>Đang tải...</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#0d0e12] h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-emerald-500 h-full transition-all duration-100" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startDownload(model)}
|
||||
className="w-full py-2 rounded-lg bg-[#0d0e12] hover:bg-emerald-600 border border-white/10 hover:border-emerald-500 text-slate-300 hover:text-white font-sans font-bold text-xs transition-all flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Download size={13} />
|
||||
Tải File STL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Action Toast Notification */}
|
||||
<AnimatePresence>
|
||||
{showSuccessToast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="fixed bottom-6 right-6 z-50 max-w-sm bg-[#0d0e12] border border-emerald-500/30 p-4 rounded-xl shadow-2xl flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle2 className="text-emerald-400 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div>
|
||||
<h4 className="text-xs font-sans font-bold text-slate-200">Bắt đầu tải file 3D</h4>
|
||||
<p className="text-slate-400 text-[11px] mt-1 leading-relaxed">
|
||||
{toastMessage}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
web/src/components/Navbar.tsx
Normal file
122
web/src/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Github, Key, Terminal, Code, Home, Sparkles } from 'lucide-react';
|
||||
import DragonMascot from './DragonMascot';
|
||||
|
||||
interface NavbarProps {
|
||||
activeView: 'home' | 'keycaps';
|
||||
setActiveView: (view: 'home' | 'keycaps') => void;
|
||||
}
|
||||
|
||||
export default function Navbar({ activeView, setActiveView }: NavbarProps) {
|
||||
const scrollToId = (id: string) => {
|
||||
// Switch to home first if on keycaps and clicking scroll targets
|
||||
if (activeView !== 'home') {
|
||||
setActiveView('home');
|
||||
setTimeout(() => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 150);
|
||||
} else {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-[#0a0b0d]/90 backdrop-blur-md border-b border-white/10 px-6 h-20 flex items-center">
|
||||
<div className="w-full max-w-6xl mx-auto flex items-center justify-between">
|
||||
|
||||
{/* LOGO AND BRANDING */}
|
||||
<div
|
||||
className="flex items-center gap-3 cursor-pointer select-none group"
|
||||
onClick={() => { setActiveView('home'); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
>
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-[0_0_20px_rgba(16,185,129,0.35)] transition-transform group-hover:scale-105 duration-300">
|
||||
<DragonMascot size={32} interactive={false} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-sans font-black text-2xl text-white tracking-tighter">
|
||||
VietC<span className="text-emerald-500">.</span>
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-emerald-500 font-bold -mt-1 tracking-widest uppercase">
|
||||
Native Linux IME
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NAVIGATION LINKS */}
|
||||
<div className="hidden md:flex items-center gap-8 text-xs font-semibold tracking-widest uppercase text-slate-400">
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveView('home'); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
className={`hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 ${
|
||||
activeView === 'home' ? 'text-emerald-400 border-emerald-400 font-bold' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
Giới Thiệu
|
||||
</button>
|
||||
|
||||
{activeView === 'home' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => scrollToId('features')}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent"
|
||||
>
|
||||
Tính Năng
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => scrollToId('demo')}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent flex items-center gap-1.5"
|
||||
>
|
||||
<Terminal size={12} className="text-emerald-500" />
|
||||
Giả Lập Demo
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => scrollToId('setup-guide')}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors pb-1 border-b-2 border-transparent"
|
||||
>
|
||||
Setup Guide
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('keycaps')}
|
||||
className={`hover:text-emerald-400 cursor-pointer transition-all flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${
|
||||
activeView === 'keycaps'
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 font-bold'
|
||||
: 'border-white/10 text-slate-400 hover:border-emerald-500/30'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={11} className={activeView === 'keycaps' ? 'animate-pulse text-emerald-400' : 'text-slate-500'} />
|
||||
Artisan Keycaps
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* EXTERNAL GITHUB BUTTON */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobile view toggle */}
|
||||
<button
|
||||
onClick={() => setActiveView(activeView === 'home' ? 'keycaps' : 'home')}
|
||||
className="md:hidden text-[10px] font-bold px-3 py-1.5 rounded-md bg-white/5 border border-white/10 text-slate-300"
|
||||
>
|
||||
{activeView === 'home' ? 'Keycaps 3D' : 'Bộ Gõ VietC'}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/vndangkhoa/vietc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3.5 py-1.5 rounded-xl bg-white/5 border border-white/10 hover:border-emerald-500/30 text-slate-300 hover:text-emerald-400 transition-all flex items-center gap-1.5 text-xs font-semibold"
|
||||
>
|
||||
<Github size={14} />
|
||||
<span className="hidden sm:inline">GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
379
web/src/components/SetupGuide.tsx
Normal file
379
web/src/components/SetupGuide.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Copy, Check, Terminal, Shield, Cpu, RefreshCw, Layers, GitBranch, Hammer } from 'lucide-react';
|
||||
import { SetupStep } from '../types';
|
||||
|
||||
type TabId = 'mint_ubuntu' | 'arch' | 'fedora' | 'dev';
|
||||
|
||||
export default function SetupGuide() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('mint_ubuntu');
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedText(id);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const installSteps: Record<Exclude<TabId, 'dev'>, SetupStep[]> = {
|
||||
mint_ubuntu: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Cài đặt VietC (Pre-built)",
|
||||
description: "Chạy lệnh dưới đây để tự động tải về, cài đặt phụ thuộc và biên dịch VietC trên hệ thống của bạn.",
|
||||
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
|
||||
&& cd /tmp/vietc && sudo ./install.sh`,
|
||||
notes: "Script tự động phát hiện distro, cài đặt dependencies, build và cấu hình udev rules cho uinput."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Gỡ cài đặt (Uninstall)",
|
||||
description: "Xoá hoàn toàn VietC khỏi hệ thống, bao gồm binary, service và udev rules.",
|
||||
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
|
||||
notes: "Lệnh này sẽ xoá /usr/local/bin/vietc, systemd service và các file cấu hình."
|
||||
}
|
||||
],
|
||||
arch: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Cài đặt VietC (Pre-built)",
|
||||
description: "Tự động clone, build và cài đặt VietC trên Arch Linux.",
|
||||
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
|
||||
&& cd /tmp/vietc && sudo ./install.sh`,
|
||||
notes: "Script hỗ trợ pacman, tự động cài đặt base-devel và các thư viện cần thiết."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Gỡ cài đặt (Uninstall)",
|
||||
description: "Xoá VietC hoàn toàn khỏi hệ thống Arch.",
|
||||
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
|
||||
}
|
||||
],
|
||||
fedora: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Cài đặt VietC (Pre-built)",
|
||||
description: "Tự động clone, build và cài đặt VietC trên Fedora.",
|
||||
command: `git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \\
|
||||
&& cd /tmp/vietc && sudo ./install.sh`,
|
||||
notes: "Script hỗ trợ dnf, tự động cài đặt Development Tools và thư viện X11."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Gỡ cài đặt (Uninstall)",
|
||||
description: "Xoá VietC hoàn toàn khỏi hệ thống Fedora.",
|
||||
command: `curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash`,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const devSteps: SetupStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Clone mã nguồn",
|
||||
description: "Nhánh main chứa code mới nhất.",
|
||||
command: `git clone https://github.com/vndangkhoa/vietc.git
|
||||
cd vietc`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Cài đặt Rust (nếu chưa có)",
|
||||
description: "Dùng rustup để cài Rust toolchain mới nhất.",
|
||||
command: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"`,
|
||||
notes: "Kiểm tra với 'rustc --version' và 'cargo --version'."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Cài đặt hệ thống phụ thuộc",
|
||||
description: "Thư viện dev cho X11, evdev và dbus.",
|
||||
command: `sudo apt install build-essential pkg-config libx11-dev libxtst-dev \\
|
||||
libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard`,
|
||||
notes: "Trên Fedora: dnf install; trên Arch: pacman -S. Xem install.sh để biết chi tiết."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Biên dịch (debug)",
|
||||
description: "Build nhanh không tối ưu, phù hợp khi phát triển.",
|
||||
command: `cargo build`,
|
||||
notes: "Binary ở target/debug/vietc. Chạy thử: ./target/debug/vietc"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Biên dịch (release - tối ưu)",
|
||||
description: "Build với tối ưu hóa cho hiệu năng cao nhất.",
|
||||
command: `cargo build --release`,
|
||||
notes: "Binary ở target/release/vietc. Chạy thử: ./target/release/vietc"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Cấp quyền uinput",
|
||||
description: "VietC cần quyền ghi /dev/uinput. Thêm user vào group input và uinput.",
|
||||
command: `sudo gpasswd -a $USER input
|
||||
sudo groupadd -f uinput
|
||||
sudo gpasswd -a $USER uinput
|
||||
echo 'KERNEL=="uinput", GROUP="uinput", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-vietc.rules
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger`,
|
||||
notes: "Đăng xuất và đăng nhập lại (hoặc reboot) để group có hiệu lực."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Chạy thử (không cần cài đặt)",
|
||||
description: "Chạy trực tiếp từ thư mục build, không cần systemd service.",
|
||||
command: `./target/release/vietc`,
|
||||
notes: "Tắt bằng Ctrl+C. Có thể chạy ở chế độ nền với '&' và dùng 'fg' để đưa lên foreground."
|
||||
}
|
||||
];
|
||||
|
||||
const tabs: { id: TabId; label: string; icon?: React.ReactNode }[] = [
|
||||
{ id: 'mint_ubuntu', label: 'Mint / Ubuntu' },
|
||||
{ id: 'arch', label: 'Arch Linux' },
|
||||
{ id: 'fedora', label: 'Fedora' },
|
||||
{ id: 'dev', label: 'Dev Build', icon: <Hammer size={12} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="setup-guide" className="py-16 bg-[#0a0b0d] border-t border-white/10 scroll-mt-20">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono mb-4"
|
||||
>
|
||||
<Terminal size={12} className="text-emerald-400" />
|
||||
<span>NATIVE LINUX INTEGRATION</span>
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl font-serif text-white tracking-tight"
|
||||
>
|
||||
Hướng Dẫn Cài Đặt <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC</span>
|
||||
</motion.h2>
|
||||
<p className="mt-4 text-slate-400 text-sm sm:text-base">
|
||||
Vì VietC là bộ gõ mức thấp (System & Application level) không phụ thuộc IBus hay Fcitx5, việc cài đặt sẽ tác động trực tiếp lên driver uinput hệ thống để đạt tốc độ gõ tuyệt đối.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex justify-center mb-10">
|
||||
<div className="bg-white/[0.02] p-1.5 rounded-xl border border-white/10 flex gap-2 w-full max-w-2xl">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2.5 rounded-lg text-xs font-semibold tracking-wide transition-all cursor-pointer flex items-center justify-center gap-1.5 ${
|
||||
activeTab === tab.id
|
||||
? 'bg-emerald-500 text-[#0a0b0d] font-bold shadow-[0_0_15px_rgba(16,185,129,0.25)]'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'dev' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<GitBranch size={18} className="text-emerald-400" />
|
||||
<h3 className="text-lg font-semibold text-slate-100">Build từ mã nguồn (dành cho Developer)</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mb-8 max-w-3xl">
|
||||
Các bước dưới đây hướng dẫn bạn tự biên dịch VietC từ source, chạy thử mà không cần cài đặt
|
||||
system-wide. Phù hợp cho developer muốn đóng góp hoặc tùy chỉnh.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{devSteps.map((step, idx) => (
|
||||
<motion.div
|
||||
key={`dev-step-${step.id}`}
|
||||
initial={{ opacity: 0, x: -15 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.08 }}
|
||||
className="relative bg-white/[0.02] rounded-2xl border border-white/5 p-5 sm:p-6 lg:p-8 hover:border-emerald-500/30 transition-all group"
|
||||
>
|
||||
{idx !== devSteps.length - 1 && (
|
||||
<div className="absolute left-[33px] sm:left-[37px] top-[75px] bottom-[-35px] w-0.5 bg-white/5 pointer-events-none group-hover:bg-emerald-500/15 transition-all" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4 sm:gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 font-mono font-bold text-sm sm:text-base shadow-inner">
|
||||
0{step.id}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-sans font-semibold text-slate-100 mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{step.command && (
|
||||
<div className="relative rounded-xl overflow-hidden bg-[#0d0e12] border border-white/10 shadow-2xl font-mono text-xs text-slate-300 group/term">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#0a0b0d] border-b border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
|
||||
<span className="ml-2 text-[10px] text-slate-500 font-mono font-medium">BASH TERMINAL</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(step.command!, `dev-${step.id}`)}
|
||||
className="p-1 rounded hover:bg-white/5 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
title="Sao chép lệnh"
|
||||
>
|
||||
{copiedText === `dev-${step.id}` ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto whitespace-pre leading-5 selection:bg-emerald-500/30 selection:text-white">
|
||||
{step.command}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.notes && (
|
||||
<div className="mt-3 flex gap-2 p-3.5 rounded-xl bg-emerald-950/15 border border-emerald-500/10 text-xs text-emerald-300/90">
|
||||
<Shield size={14} className="flex-shrink-0 mt-0.5 text-emerald-400" />
|
||||
<div className="leading-relaxed">
|
||||
<span className="font-semibold text-emerald-400">Lưu ý:</span> {step.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{installSteps[activeTab].map((step, idx) => (
|
||||
<motion.div
|
||||
key={`${activeTab}-${step.id}`}
|
||||
initial={{ opacity: 0, x: -15 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="relative bg-white/[0.02] rounded-2xl border border-white/5 p-5 sm:p-6 lg:p-8 hover:border-emerald-500/30 transition-all group"
|
||||
>
|
||||
{idx !== installSteps[activeTab].length - 1 && (
|
||||
<div className="absolute left-[33px] sm:left-[37px] top-[75px] bottom-[-45px] w-0.5 bg-white/5 pointer-events-none group-hover:bg-emerald-500/15 transition-all" />
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4 sm:gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-emerald-400 font-mono font-bold text-sm sm:text-base shadow-inner group-hover:border-emerald-500/30 group-hover:bg-emerald-500/10 transition-all">
|
||||
0{step.id}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<h3 className="text-base sm:text-lg font-sans font-semibold text-slate-100 group-hover:text-emerald-300 transition-colors">
|
||||
{step.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-400 text-xs sm:text-sm leading-relaxed mb-4">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{step.command && (
|
||||
<div className="relative rounded-xl overflow-hidden bg-[#0d0e12] border border-white/10 shadow-2xl font-mono text-xs text-slate-300 group/term">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#0a0b0d] border-b border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
|
||||
<span className="ml-2 text-[10px] text-slate-500 font-mono font-medium">BASH TERMINAL</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(step.command!, `${activeTab}-${step.id}`)}
|
||||
className="p-1 rounded hover:bg-white/5 text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||
title="Sao chép lệnh"
|
||||
>
|
||||
{copiedText === `${activeTab}-${step.id}` ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto whitespace-pre leading-5 selection:bg-emerald-500/30 selection:text-white">
|
||||
{step.command}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.notes && (
|
||||
<div className="mt-3 flex gap-2 p-3.5 rounded-xl bg-emerald-950/15 border border-emerald-500/10 text-xs text-emerald-300/90">
|
||||
<Shield size={14} className="flex-shrink-0 mt-0.5 text-emerald-400" />
|
||||
<div className="leading-relaxed">
|
||||
<span className="font-semibold text-emerald-400">Lưu ý:</span> {step.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture graphic */}
|
||||
<div className="mt-16 bg-gradient-to-br from-white/[0.03] to-transparent p-6 sm:p-8 rounded-3xl border border-white/10">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-slate-100 flex items-center gap-2 mb-6">
|
||||
<Layers className="text-emerald-400" size={18} />
|
||||
<span>Mô Hình Hoạt Động Khác Biệt của VietC</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-5 rounded-2xl bg-[#0d0e12] border border-white/5">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-center font-bold text-xs mb-3">
|
||||
OLD
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-200 mb-2">IBus / Fcitx5</h4>
|
||||
<p className="text-slate-400 text-xs leading-relaxed">
|
||||
Hoạt động ở Application Layer qua cơ chế giao tiếp DBus phức tạp. Khi gõ trong Terminal ảo, các lệnh Backspace/Delete giả lập thường bị trễ hoặc nuốt ký tự gây lỗi nhân đôi hoặc mất chữ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-[#0d0e12] border border-white/5">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold text-xs mb-3">
|
||||
NEW
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-200 mb-2">VietC (uinput + evdev)</h4>
|
||||
<p className="text-slate-400 text-xs leading-relaxed">
|
||||
Chặn (grab) sự kiện gốc từ bàn phím vật lý thông qua driver <code className="text-emerald-400 font-mono">evdev</code>, sau đó tự tính toán bằng State Machine và xuất ra bàn phím ảo mới thông qua <code className="text-emerald-400 font-mono">uinput</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-emerald-950/15 border border-emerald-500/20">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 text-emerald-300 flex items-center justify-center font-bold text-xs mb-3">
|
||||
WIN
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-emerald-300 mb-2">Trải Nghiệm "Như Bay"</h4>
|
||||
<p className="text-emerald-400/80 text-xs leading-relaxed">
|
||||
Độ trễ phản hồi phím <span className="text-white font-semibold">Keystroke: 0ms</span> và giải phóng nút <span className="text-white font-semibold"><1ms</span>. Gõ tiếng Việt gốc 100% không bị lag, không kén Terminal nào (Alacritty, Kitty, GNOME Terminal, v.v.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
web/src/components/TerminalSimulator.tsx
Normal file
347
web/src/components/TerminalSimulator.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Terminal, Send, Play, RefreshCw, Zap, Cpu, Lock, HelpCircle } from 'lucide-react';
|
||||
import { parseVni } from '../utils/vniParser';
|
||||
import { TerminalLog } from '../types';
|
||||
|
||||
export default function TerminalSimulator() {
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [typedOutput, setTypedOutput] = useState('');
|
||||
const [imeState, setImeState] = useState('S0');
|
||||
const [terminalLogs, setTerminalLogs] = useState<TerminalLog[]>([]);
|
||||
const [isTypingDemo, setIsTypingDemo] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Suggested pre-recorded typing strings (VNI sequence)
|
||||
const presets = [
|
||||
{ label: "Gõ 'Việt Nam'", code: "Vie6t5 Nam" },
|
||||
{ label: "Gõ 'tiếng việt'", code: "tie61ng vie6t5" },
|
||||
{ label: "Gõ 'đường sá'", code: "d9uo7ng2 sa1" },
|
||||
{ label: "Gõ 'rồng con'", code: "ro6ng2 con" },
|
||||
];
|
||||
|
||||
// Process live input
|
||||
useEffect(() => {
|
||||
const result = parseVni(inputText);
|
||||
setTypedOutput(result.text);
|
||||
setImeState(result.state);
|
||||
|
||||
// Convert string logs into TerminalLog structures
|
||||
const parsedLogs: TerminalLog[] = result.logs.map((logStr, idx) => ({
|
||||
id: `log-${idx}-${Date.now()}`,
|
||||
type: logStr.includes('Diffing') ? 'diff' : logStr.includes('uinput') ? 'ime_state' : 'system',
|
||||
text: logStr,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}));
|
||||
setTerminalLogs(parsedLogs);
|
||||
}, [inputText]);
|
||||
|
||||
// Auto-scroll the log container to the bottom when new events arrive.
|
||||
// Uses scrollTop on the container (never scrollIntoView, which scrolls the page).
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [terminalLogs]);
|
||||
|
||||
// Simulate automated character-by-character typing demo
|
||||
const startDemo = async (vniString: string) => {
|
||||
if (isTypingDemo) return;
|
||||
setIsTypingDemo(true);
|
||||
setInputText('');
|
||||
|
||||
let current = '';
|
||||
for (let i = 0; i < vniString.length; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 150));
|
||||
current += vniString[i];
|
||||
setInputText(current);
|
||||
}
|
||||
setIsTypingDemo(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputText('');
|
||||
setTypedOutput('');
|
||||
setImeState('S0');
|
||||
setTerminalLogs([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="demo" className="py-16 bg-[#0a0b0d] border-t border-white/10 scroll-mt-20">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="text-center max-w-3xl mx-auto mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-mono mb-4"
|
||||
>
|
||||
<Zap size={12} className="text-emerald-400 animate-pulse" />
|
||||
<span>INTERACTIVE EXPERIMENT</span>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl font-serif text-white tracking-tight">
|
||||
Trải Nghiệm <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400 italic">VietC Simulator</span>
|
||||
</h2>
|
||||
<p className="mt-4 text-slate-400 text-sm sm:text-base">
|
||||
Hãy tự tay gõ chuỗi phím VNI hoặc chọn các mẫu gõ nhanh dưới đây để xem cách State Machine của VietC biên dịch và gửi tín hiệu trực tiếp lên Linux Terminal ảo cực mượt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interactive Workspace Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
|
||||
|
||||
{/* LEFT: The Linux Terminal Emulator (7 cols) */}
|
||||
<div className="lg:col-span-7 flex flex-col h-[480px] bg-[#0d0e12] rounded-2xl border border-white/10 shadow-2xl overflow-hidden relative">
|
||||
|
||||
{/* Terminal Window Chrome Title bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-[#0a0b0d] border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-rose-500/60" />
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500/60" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500/60" />
|
||||
<span className="ml-2 text-xs text-slate-400 font-mono flex items-center gap-1.5 font-semibold">
|
||||
<Terminal size={12} className="text-emerald-500" />
|
||||
vietc@linuxmint-terminal: ~
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/20">
|
||||
VIETC: ON (Double Shift)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Screen Body */}
|
||||
<div className="flex-1 p-5 overflow-y-auto font-mono text-sm leading-relaxed space-y-4 select-text">
|
||||
<div className="text-slate-500 text-xs border-b border-white/5 pb-3 leading-relaxed">
|
||||
<div>VietC uinput Emulator Engine v1.2.0 (x86_64-linux-mint)</div>
|
||||
<div>Trạng thái: Hoạt động trực tiếp ở driver nhân (kernel space)...</div>
|
||||
<div>Gõ phím số 1-9 để gõ dấu VNI (vd: 'ro6ngs2' hoặc 'ro6ng2' → rồng).</div>
|
||||
</div>
|
||||
|
||||
{/* History Console Feed */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-slate-500"># Gõ tiếng Việt cực nhanh không cần DBus/IBus</div>
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="text-emerald-500">user@mint:~$</span>
|
||||
<span className="text-slate-300">cat vietc_stats.txt</span>
|
||||
</div>
|
||||
<div className="text-emerald-400/90 pl-4 text-xs space-y-1 bg-white/[0.01] p-2.5 rounded border border-white/5">
|
||||
<div>+ Keystroke Latency: 0ms (Mức phần cứng)</div>
|
||||
<div>+ Press-Release Latency: <1ms (Driver-level)</div>
|
||||
<div>+ Event Type: evdev grab / virtual uinput raw keypress</div>
|
||||
<div>+ Memory footprint: ~1.2 MB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Terminal Line */}
|
||||
<div className="pt-2 border-t border-white/5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap shrink-0 pt-0.5">
|
||||
vietc@linuxmint-terminal:~$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isTypingDemo}
|
||||
placeholder="Gõ VNI tại đây (vd: Vie6t1 Nam)..."
|
||||
className="flex-1 bg-transparent border-none text-slate-100 placeholder-slate-600 focus:outline-none font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{inputText && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1 rounded hover:bg-white/5 text-slate-500 hover:text-slate-300 transition-colors cursor-pointer shrink-0"
|
||||
title="Clear"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Converted Output */}
|
||||
<div className="flex items-start gap-2 ml-0">
|
||||
<span className="text-slate-500 font-mono text-sm shrink-0 pt-0.5 select-none">
|
||||
>
|
||||
</span>
|
||||
<span className="text-emerald-300 font-mono text-sm break-all">
|
||||
{typedOutput || <span className="text-slate-600 italic">Kết quả tiếng Việt sẽ hiện ở đây...</span>}
|
||||
</span>
|
||||
{typedOutput && (
|
||||
<span className="inline-block w-2 h-4 bg-emerald-400 animate-pulse ml-0.5 shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Quick Demo bar */}
|
||||
<div className="p-3 bg-[#0a0b0d] border-t border-white/10 flex flex-wrap gap-2 items-center flex-shrink-0">
|
||||
<span className="text-[10px] text-slate-500 font-mono mr-1">Gợi ý gõ nhanh:</span>
|
||||
{presets.map((preset, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => startDemo(preset.code)}
|
||||
disabled={isTypingDemo}
|
||||
className="px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 border border-white/10 hover:border-emerald-500/30 text-slate-300 hover:text-white font-mono text-[10px] transition-all flex items-center gap-1 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play size={8} className="text-emerald-400" />
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Real-time Monitor & Event Logs (5 cols) */}
|
||||
<div className="lg:col-span-5 flex flex-col bg-white/[0.02] rounded-2xl border border-white/10 p-5 lg:p-6 shadow-xl relative">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu size={16} className="text-emerald-400" />
|
||||
<span className="font-sans font-bold text-sm text-slate-200 tracking-wide uppercase">Màn Hình Kiểm Soát VietC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-emerald-500/10 text-emerald-400 px-2 py-0.5 rounded-full text-[10px] font-mono border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping" />
|
||||
<span>Live Monitor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State Machine Visualization */}
|
||||
<div className="bg-[#0d0e12] p-4 rounded-xl border border-white/5 mb-5">
|
||||
<div className="text-xs text-slate-400 font-semibold mb-3 flex items-center justify-between">
|
||||
<span>Deterministic State Machine</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">Sự thay đổi trạng thái gốc</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2 py-1.5 relative">
|
||||
{/* Horizontal progress background bar */}
|
||||
<div className="absolute left-6 right-6 top-[22px] h-0.5 bg-white/5 z-0" />
|
||||
|
||||
{/* S0 */}
|
||||
<div className="flex flex-col items-center z-10">
|
||||
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
|
||||
imeState === 'S0'
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
|
||||
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
|
||||
}`}>
|
||||
S0
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Chờ phím</span>
|
||||
</div>
|
||||
|
||||
{/* S1 */}
|
||||
<div className="flex flex-col items-center z-10">
|
||||
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
|
||||
imeState === 'S1'
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
|
||||
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
|
||||
}`}>
|
||||
S1
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Nguyên âm</span>
|
||||
</div>
|
||||
|
||||
{/* S2 */}
|
||||
<div className="flex flex-col items-center z-10">
|
||||
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
|
||||
imeState === 'S2'
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
|
||||
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
|
||||
}`}>
|
||||
S2
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Dấu thanh</span>
|
||||
</div>
|
||||
|
||||
{/* S3 */}
|
||||
<div className="flex flex-col items-center z-10">
|
||||
<div className={`w-8 h-8 rounded-full border flex items-center justify-center font-mono text-xs font-bold transition-all duration-300 ${
|
||||
imeState === 'S3'
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-[0_0_15px_rgba(16,185,129,0.35)] scale-110'
|
||||
: 'bg-[#0a0b0d] text-slate-500 border-white/5'
|
||||
}`}>
|
||||
S3
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-slate-500 mt-1.5">Ký tự phụ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Specs metrics */}
|
||||
<div className="grid grid-cols-3 gap-2.5 mb-5 font-mono text-center">
|
||||
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
|
||||
<div className="text-[9px] text-slate-500">Keystroke</div>
|
||||
<div className="text-sm font-bold text-emerald-400 mt-0.5">0 ms</div>
|
||||
</div>
|
||||
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
|
||||
<div className="text-[9px] text-slate-500">Press-Release</div>
|
||||
<div className="text-sm font-bold text-emerald-400 mt-0.5"><1 ms</div>
|
||||
</div>
|
||||
<div className="bg-[#0d0e12] p-2.5 rounded-lg border border-white/5">
|
||||
<div className="text-[9px] text-slate-500">Clipboard</div>
|
||||
<div className="text-sm font-bold text-emerald-400 mt-0.5">1 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Log Stream */}
|
||||
<div className="text-xs text-slate-400 font-semibold mb-2 flex items-center justify-between">
|
||||
<span>Sự Kiện Thiết Bị Thấp (uinput Event Logs)</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">Thời gian thực</span>
|
||||
</div>
|
||||
|
||||
<div ref={logContainerRef} className="flex-1 bg-[#0d0e12] p-4 rounded-xl border border-white/5 overflow-y-auto h-[170px] font-mono text-[11px] space-y-2.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{terminalLogs.map((log) => (
|
||||
<motion.div
|
||||
key={log.id}
|
||||
initial={{ opacity: 0, x: 5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="border-b border-white/5 pb-2 last:border-none"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[9px] text-slate-500 mb-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`w-1 h-1 rounded-full ${
|
||||
log.type === 'diff' ? 'bg-emerald-400' : log.type === 'ime_state' ? 'bg-teal-400' : 'bg-slate-400'
|
||||
}`} />
|
||||
{log.type.toUpperCase()}
|
||||
</span>
|
||||
<span>{log.timestamp}</span>
|
||||
</div>
|
||||
<div className={
|
||||
log.type === 'diff' ? 'text-emerald-300' : log.type === 'ime_state' ? 'text-teal-200' : 'text-slate-300'
|
||||
}>
|
||||
{log.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{terminalLogs.length === 0 && (
|
||||
<div className="h-full flex items-center justify-center text-slate-600 italic">
|
||||
Gõ phím hoặc chọn mẫu để hiển thị logs sự kiện nhân (kernel event logs)
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<div className="mt-4 flex gap-2 items-center text-[10px] text-slate-500 bg-[#0d0e12] p-2.5 rounded-lg border border-white/10">
|
||||
<Lock size={12} className="text-emerald-500 flex-shrink-0" />
|
||||
<span>An toàn & Bảo mật: VietC thu thập sự kiện phím tại local và không bao giờ gửi bất kỳ dữ liệu nào qua mạng Internet.</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
web/src/index.css
Normal file
26
web/src/index.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;950&family=JetBrains+Mono:wght@400;500;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-serif: "Playfair Display", Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Hide all scrollbars */
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* Custom 3D Perspective Utility class */
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.transform-style-3d {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
35
web/src/types.ts
Normal file
35
web/src/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export interface KeycapCustomization {
|
||||
baseColor: string;
|
||||
stemColor: string;
|
||||
dragonColor: string;
|
||||
material: 'resin_clear' | 'resin_frosted' | 'matte' | 'glass';
|
||||
ledColor: string;
|
||||
ledIntensity: number; // 0 to 100
|
||||
selectedLetter: string; // e.g. "ă", "â", "đ", "ê", "ô", "ơ", "ư", "Sắc (s)", "Huyền (f)"...
|
||||
showStem: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalLog {
|
||||
id: string;
|
||||
type: 'input' | 'system' | 'ime_state' | 'diff';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SetupStep {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
command?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface KeycapModel {
|
||||
id: string;
|
||||
name: string;
|
||||
letter: string;
|
||||
desc: string;
|
||||
rarity: 'Common' | 'Rare' | 'Epic' | 'Legendary';
|
||||
price?: string;
|
||||
stlUrl: string;
|
||||
}
|
||||
233
web/src/utils/vniParser.ts
Normal file
233
web/src/utils/vniParser.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Simple VNI Vietnamese Parser & State Machine for the VietC Terminal simulator
|
||||
// It processes VNI input (numbers 1-9 for diacritics) and returns the converted string
|
||||
// and logs detailing the State Machine transitions and Token-Level Diffing.
|
||||
|
||||
interface VniStateResult {
|
||||
text: string;
|
||||
logs: string[];
|
||||
state: string; // S0, S1, S2, S3
|
||||
}
|
||||
|
||||
// Maps for tone accents on vowels
|
||||
const ACUTE = '\u0301'; // Sắc (1)
|
||||
const GRAVE = '\u0300'; // Huyền (2)
|
||||
const HOOK = '\u0309'; // Hỏi (3)
|
||||
const TILDE = '\u0303'; // Ngã (4)
|
||||
const DOT = '\u0323'; // Nặng (5)
|
||||
|
||||
const TONES_MAP: Record<string, string> = {
|
||||
'1': ACUTE,
|
||||
'2': GRAVE,
|
||||
'3': HOOK,
|
||||
'4': TILDE,
|
||||
'5': DOT,
|
||||
};
|
||||
|
||||
// Maps for letter modifiers
|
||||
// 6 -> â, ê, ô
|
||||
// 7 -> ơ, ư
|
||||
// 8 -> ă
|
||||
// 9 -> đ
|
||||
const MOD_6: Record<string, string> = {
|
||||
'a': 'â', 'A': 'Â',
|
||||
'e': 'ê', 'E': 'Ê',
|
||||
'o': 'ô', 'O': 'Ô',
|
||||
};
|
||||
|
||||
const MOD_7: Record<string, string> = {
|
||||
'o': 'ơ', 'O': 'Ơ',
|
||||
'u': 'ư', 'U': 'Ư',
|
||||
};
|
||||
|
||||
const MOD_8: Record<string, string> = {
|
||||
'a': 'ă', 'A': 'Ă',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes combining diacritics to standard precomposed Vietnamese characters.
|
||||
*/
|
||||
function normalizeVietnamese(text: string): string {
|
||||
return text.normalize('NFC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full sentence/text typed in VNI.
|
||||
* E.g., "tieengs vieetj" -> "tiếng việt"
|
||||
* VNI: "vietetj" or "viet1" -> "viết"
|
||||
* Let's process word by word.
|
||||
*/
|
||||
export function parseVni(inputText: string): VniStateResult {
|
||||
const words = inputText.split(' ');
|
||||
const processedWords: string[] = [];
|
||||
const logs: string[] = [];
|
||||
let currentState = 'S0';
|
||||
|
||||
for (let w = 0; w < words.length; w++) {
|
||||
const word = words[w];
|
||||
if (!word) {
|
||||
processedWords.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let resultWord = '';
|
||||
let tone: string | null = null;
|
||||
let dStroke = false;
|
||||
|
||||
// We will build the word character-by-character
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
const char = word[i];
|
||||
|
||||
// Check for Đ (9)
|
||||
if (char === '9') {
|
||||
const lastChar = resultWord[resultWord.length - 1];
|
||||
if (lastChar === 'd' || lastChar === 'D') {
|
||||
resultWord = resultWord.slice(0, -1) + (lastChar === 'd' ? 'đ' : 'Đ');
|
||||
dStroke = true;
|
||||
currentState = 'S3';
|
||||
logs.push(`[uinput / S3] Nhận phím '9': Chuyển đổi phụ âm '${lastChar}' -> 'đ' (Độ trễ: 0ms)`);
|
||||
} else {
|
||||
resultWord += '9';
|
||||
logs.push(`[uinput / S0] Nhận phím '9': Không khớp phụ âm d/D, giữ nguyên chữ '9'`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for circumflex â, ê, ô (6)
|
||||
if (char === '6') {
|
||||
// Find last matching vowel in resultWord to apply modifier
|
||||
let applied = false;
|
||||
for (let j = resultWord.length - 1; j >= 0; j--) {
|
||||
const c = resultWord[j];
|
||||
if (MOD_6[c]) {
|
||||
resultWord = resultWord.substring(0, j) + MOD_6[c] + resultWord.substring(j + 1);
|
||||
applied = true;
|
||||
currentState = 'S3';
|
||||
logs.push(`[uinput / S3] Nhận phím '6': Thêm mũ ô/ê/â cho '${c}' -> '${MOD_6[c]}' (Độ trễ: 0ms)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!applied) {
|
||||
resultWord += '6';
|
||||
logs.push(`[uinput / S0] Nhận phím '6': Không tìm thấy nguyên âm thích hợp để đội mũ (giữ nguyên '6')`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for horn ơ, ư (7)
|
||||
if (char === '7') {
|
||||
let applied = false;
|
||||
for (let j = resultWord.length - 1; j >= 0; j--) {
|
||||
const c = resultWord[j];
|
||||
if (MOD_7[c]) {
|
||||
resultWord = resultWord.substring(0, j) + MOD_7[c] + resultWord.substring(j + 1);
|
||||
// If 'o'->'ơ' preceded by 'u', merge to 'ươ' (standard VNI digraph)
|
||||
if (MOD_7[c] === 'ơ' && j > 0 && (resultWord[j-1] === 'u' || resultWord[j-1] === 'U')) {
|
||||
const prefix = resultWord.substring(0, j - 1);
|
||||
const suffix = resultWord.substring(j + 1);
|
||||
resultWord = prefix + (resultWord[j-1] === 'U' ? 'Ươ' : 'ươ') + suffix;
|
||||
}
|
||||
applied = true;
|
||||
currentState = 'S3';
|
||||
logs.push(`[uinput / S3] Nhận phím '7': Thêm râu ơ/ư cho '${c}' -> '${MOD_7[c]}' (Độ trễ: 0ms)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!applied) {
|
||||
resultWord += '7';
|
||||
logs.push(`[uinput / S0] Nhận phím '7': Không tìm thấy nguyên âm o/u để thêm râu`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for breve ă (8)
|
||||
if (char === '8') {
|
||||
let applied = false;
|
||||
for (let j = resultWord.length - 1; j >= 0; j--) {
|
||||
const c = resultWord[j];
|
||||
if (MOD_8[c]) {
|
||||
resultWord = resultWord.substring(0, j) + MOD_8[c] + resultWord.substring(j + 1);
|
||||
applied = true;
|
||||
currentState = 'S3';
|
||||
logs.push(`[uinput / S3] Nhận phím '8': Thêm á cho '${c}' -> '${MOD_8[c]}' (Độ trễ: 0ms)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!applied) {
|
||||
resultWord += '8';
|
||||
logs.push(`[uinput / S0] Nhận phím '8': Không tìm thấy nguyên âm a để chuyển thành ă`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for tones (1, 2, 3, 4, 5)
|
||||
if (TONES_MAP[char]) {
|
||||
tone = TONES_MAP[char];
|
||||
currentState = 'S2';
|
||||
const toneNames: Record<string, string> = { '1': 'Sắc', '2': 'Huyền', '3': 'Hỏi', '4': 'Ngã', '5': 'Nặng' };
|
||||
logs.push(`[uinput / S2] Nhận phím '${char}': Áp dụng dấu thanh [${toneNames[char]}] lên từ đang gõ`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cancel tone (0)
|
||||
if (char === '0') {
|
||||
tone = null;
|
||||
currentState = 'S1';
|
||||
logs.push(`[uinput / S1] Nhận phím '0': Xóa toàn bộ dấu thanh đang áp dụng`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard alphabetical letters
|
||||
resultWord += char;
|
||||
currentState = 'S1';
|
||||
}
|
||||
|
||||
// Apply the tone accent if any
|
||||
if (tone) {
|
||||
// Find the correct vowel to put the tone on (Vietnamese grammar rule)
|
||||
// Standard rules: usually the last vowel if double vowel, or the middle one.
|
||||
// E.g., "hoàng" -> tone on "à", "tiếng" -> tone on "ế"
|
||||
// Let's implement a simple heuristic:
|
||||
const vowels = ['a', 'e', 'i', 'o', 'u', 'y', 'â', 'ê', 'ô', 'ơ', 'ư', 'ă', 'Ă', 'Â', 'Ê', 'Ô', 'Ơ', 'Ư'];
|
||||
let vowelPositions: number[] = [];
|
||||
for (let i = 0; i < resultWord.length; i++) {
|
||||
if (vowels.includes(resultWord[i].toLowerCase())) {
|
||||
vowelPositions.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (vowelPositions.length > 0) {
|
||||
// Decide which vowel receives the tone
|
||||
let targetIndex = vowelPositions[0];
|
||||
if (vowelPositions.length === 2) {
|
||||
// If there is "uy", tone is on "y", else "oa", "oe", "ue", "uy", etc.
|
||||
const pair = (resultWord[vowelPositions[0]] + resultWord[vowelPositions[1]]).toLowerCase();
|
||||
if (pair === 'oa' || pair === 'oe' || pair === 'uâ' || pair === 'uy' || pair === 'iê' || pair === 'yê' || pair === 'uô' || pair === 'ươ') {
|
||||
targetIndex = vowelPositions[1];
|
||||
} else {
|
||||
targetIndex = vowelPositions[0];
|
||||
}
|
||||
} else if (vowelPositions.length === 3) {
|
||||
// Three vowels (e.g. "oai", "uay", "ươu"), tone usually on the middle one
|
||||
targetIndex = vowelPositions[1];
|
||||
}
|
||||
|
||||
const targetChar = resultWord[targetIndex];
|
||||
resultWord = resultWord.substring(0, targetIndex) + targetChar + tone + resultWord.substring(targetIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
processedWords.push(normalizeVietnamese(resultWord));
|
||||
}
|
||||
|
||||
// Generate a final state change summary for the diff system
|
||||
const finalOutput = processedWords.join(' ');
|
||||
if (inputText !== finalOutput && finalOutput !== '') {
|
||||
logs.push(`[Token-Level Diffing] Đã đồng bộ sự kiện phím ảo: Thay thế chuỗi "${inputText}" thành "${finalOutput}" trong 1ms`);
|
||||
}
|
||||
|
||||
return {
|
||||
text: finalOutput,
|
||||
logs: logs.length > 0 ? logs : ["Chờ phím gõ từ terminal..."],
|
||||
state: currentState,
|
||||
};
|
||||
}
|
||||
26
web/tsconfig.json
Normal file
26
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
Reference in a new issue