feat: add mouse support and update install flow

This commit is contained in:
gary committed 2026-07-02 18:22:27 +08:00
1 parent b8e444bd0a
commit 71855bf4e7
16 files changed
+1214 -39

No files matched your search

+2
View File
@@ -36,6 +36,8 @@ archives:
files:
- README.md
- LICENSE
- install.sh
- install.ps1
checksum:
name_template: checksums.txt
+44 -1
View File
@@ -24,9 +24,43 @@ https://www.nodeseek.com/post-710243-1
- 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情
- 支持配置文件,默认持久化到系统用户配置目录
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
- 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端
- 支持远程一行安装脚本,自动识别系统和 CPU 架构并下载对应 Release
## 构建
## 安装
Linux / macOS:
```sh
curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | sh
```
Windows PowerShell:
```powershell
irm https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.ps1 | iex
```
默认安装位置:
- Linux / macOS:`~/.local/bin/ktui`
- Windows:`%LOCALAPPDATA%\ktui\bin\ktui.exe`
安装指定版本或目录:
```sh
curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_VERSION=v0.1.0 sh
curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_INSTALL_DIR=/usr/local/bin sh
```
私有仓库或需要认证的 Release 可以传 token:
```sh
curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_UPDATE_TOKEN=your_token sh
```
## 从源码构建
需要 Go 1.26 或更新版本。
@@ -65,6 +99,12 @@ go run ./cmd/ktui
./ktui update
```
进入 TUI 时会自动在后台检查是否有新版本。发现新版本后,界面顶部会提示 `UPDATE ... available`,底部会出现 `u update`;按 `u` 或点击该提示会显示更新命令。实际更新请退出 TUI 后运行:
```sh
ktui update
```
指定 Komari 地址:
```sh
@@ -208,6 +248,8 @@ EXP -
- `↑` / `k`:列表页选择上一个节点;详情页向上滚动一张卡片
- `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片
- 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项
- 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回
- `PgUp` / `PgDn`:快速滚动列表或详情页
- `Enter` / `o`:打开选中节点的详情页
- `Esc` / `b` / `q`:从详情页返回列表页
@@ -217,6 +259,7 @@ EXP -
- `r`:立即刷新
- `d`:打开或重新加载选中节点的详情数据
- `a`:切换 ASCII 兼容模式
- `u`:有新版本时显示更新命令
- `q` / `Ctrl-C`:在列表页退出
## 版本信息
+28
View File
@@ -158,6 +158,7 @@ func main() {
RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis,
SaveSettings: saveTUISettings,
CheckUpdate: checkSoftwareUpdate,
ASCII: ascii,
NoColor: noColor,
Mode: mode,
@@ -219,6 +220,22 @@ func saveTUISettings(settings tui.PersistentSettings) error {
return err
}
func checkSoftwareUpdate(ctx context.Context) (tui.UpdateCheckResult, error) {
result, err := update.Check(ctx, update.Options{
CurrentVersion: version,
Timeout: 8 * time.Second,
})
if err != nil {
return tui.UpdateCheckResult{}, err
}
return tui.UpdateCheckResult{
CurrentVersion: result.CurrentVersion,
LatestVersion: result.LatestVersion,
AssetName: result.AssetName,
Available: result.Available,
}, nil
}
func handleConfig(args []string) error {
if len(args) == 0 {
printConfigHelp()
@@ -453,6 +470,9 @@ func printKeysHelp() {
List layer:
Up/k, Down/j select server
Mouse wheel select previous/next server
Mouse click open server detail
Footer click settings/mode/refresh/ascii/quit
PgUp, PgDn jump faster
Enter/o open selected server detail
s open settings
@@ -460,19 +480,27 @@ List layer:
r refresh now
d open or reload selected server detail data
a toggle ASCII compatibility mode
u show update command when an update is available
q, Ctrl-C quit
Detail layer:
Esc, b, q return to list layer
Mouse click Back return to list layer
h/l, 1-5, Tab switch detail tabs
[, ] switch time window
Up/k, Down/j scroll one card
Mouse wheel scroll detail cards
Mouse click switch tabs or time window
Footer click Back/settings/refresh
s open settings
u show update command when an update is available
PgUp, PgDn scroll faster
Settings layer:
Esc, q, s return to previous layer
Up/k, Down/j select setting
Mouse wheel/click select setting
Footer click back/adjust/toggle
Left/h, Right/l adjust value
Enter toggle or advance value
`)
+110
View File
@@ -0,0 +1,110 @@
$ErrorActionPreference = "Stop"
$ApiBaseUrl = if ($env:KTUI_API_BASE_URL) { $env:KTUI_API_BASE_URL } else { "https://gitea.bytevibe.dev/api/v1/repos/gary/ktui" }
$InstallDir = if ($env:KTUI_INSTALL_DIR) { $env:KTUI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "ktui\bin" }
$Version = if ($env:KTUI_VERSION) { $env:KTUI_VERSION } else { "latest" }
$BinaryName = "ktui.exe"
function Fail($Message) {
Write-Error "ktui install: $Message"
exit 1
}
function Invoke-KtuiGet($Url, $OutFile) {
$headers = @{}
if ($env:KTUI_UPDATE_TOKEN) {
$headers["Authorization"] = "token $env:KTUI_UPDATE_TOKEN"
} elseif ($env:GITEA_TOKEN) {
$headers["Authorization"] = "token $env:GITEA_TOKEN"
}
if ($OutFile) {
Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $Url -OutFile $OutFile
} else {
Invoke-RestMethod -Headers $headers -Uri $Url
}
}
function Get-KtuiArch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "amd64"; break }
"ARM64" { "arm64"; break }
default { Fail "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" }
}
}
$endpoint = "$ApiBaseUrl/releases/latest"
if ($Version -ne "latest") {
$endpoint = "$ApiBaseUrl/releases/tags/$Version"
}
$release = Invoke-KtuiGet $endpoint
if (-not $release.tag_name) {
Fail "release response does not include tag_name"
}
$arch = Get-KtuiArch
$suffix = "_windows_${arch}.zip"
$asset = $release.assets | Where-Object {
$_.name -like "ktui_*" -and $_.name.EndsWith($suffix) -and $_.browser_download_url
} | Select-Object -First 1
$checksums = $release.assets | Where-Object {
$_.name -eq "checksums.txt" -and $_.browser_download_url
} | Select-Object -First 1
if (-not $asset) {
Fail "release $($release.tag_name) does not contain an asset for windows/$arch"
}
if (-not $checksums) {
Fail "release does not contain checksums.txt"
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ktui-install-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $tmp | Out-Null
try {
$archivePath = Join-Path $tmp $asset.name
$checksumsPath = Join-Path $tmp "checksums.txt"
Write-Host "ktui install: downloading $($asset.name)"
Invoke-KtuiGet $asset.browser_download_url $archivePath
Invoke-KtuiGet $checksums.browser_download_url $checksumsPath
$expected = $null
foreach ($line in Get-Content $checksumsPath) {
$parts = $line -split "\s+"
if ($parts.Length -lt 2) {
continue
}
$filename = Split-Path ($parts[-1].TrimStart("*")) -Leaf
if ($filename -eq $asset.name) {
$expected = $parts[0].ToLowerInvariant()
break
}
}
if (-not $expected) {
Fail "checksums.txt does not contain $($asset.name)"
}
$got = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
if ($got -ne $expected) {
Fail "checksum mismatch for $($asset.name)"
}
Expand-Archive -Force -Path $archivePath -DestinationPath $tmp
$binary = Get-ChildItem -Path $tmp -Recurse -File -Filter $BinaryName | Select-Object -First 1
if (-not $binary) {
Fail "archive does not contain $BinaryName"
}
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$installPath = Join-Path $InstallDir $BinaryName
Copy-Item -Force $binary.FullName $installPath
Write-Host "ktui install: installed $installPath"
$pathEntries = $env:PATH -split ";"
if ($pathEntries -notcontains $InstallDir) {
Write-Host "ktui install: add $InstallDir to PATH if ktui is not found"
}
& $installPath version
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
Executable
+144
View File
@@ -0,0 +1,144 @@
#!/bin/sh
set -eu
API_BASE_URL="${KTUI_API_BASE_URL:-https://gitea.bytevibe.dev/api/v1/repos/gary/ktui}"
INSTALL_DIR="${KTUI_INSTALL_DIR:-$HOME/.local/bin}"
VERSION="${KTUI_VERSION:-latest}"
BINARY_NAME="ktui"
fail() {
printf 'ktui install: %s\n' "$*" >&2
exit 1
}
need() {
command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
}
http_get() {
if [ -n "${KTUI_UPDATE_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $KTUI_UPDATE_TOKEN" "$1"
elif [ -n "${GITEA_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITEA_TOKEN" "$1"
else
curl -fsSL "$1"
fi
}
detect_os() {
case "$(uname -s)" in
Linux) printf linux ;;
Darwin) printf darwin ;;
*) fail "unsupported OS: $(uname -s)" ;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64 | amd64) printf amd64 ;;
arm64 | aarch64) printf arm64 ;;
*) fail "unsupported architecture: $(uname -m)" ;;
esac
}
json_value() {
printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1
}
asset_url() {
name="$1"
printf '%s' "$release_json" |
tr '{' '\n' |
grep "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" |
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
head -n 1
}
checksum_for() {
name="$1"
awk -v name="$name" '
{
file = $NF
sub(/^\*/, "", file)
n = split(file, parts, "/")
if (parts[n] == name) {
print tolower($1)
exit
}
}
' "$2"
}
verify_sha256() {
file="$1"
expected="$2"
if command -v sha256sum >/dev/null 2>&1; then
got="$(sha256sum "$file" | awk '{print tolower($1)}')"
elif command -v shasum >/dev/null 2>&1; then
got="$(shasum -a 256 "$file" | awk '{print tolower($1)}')"
else
fail "missing sha256sum or shasum"
fi
[ "$got" = "$expected" ] || fail "checksum mismatch for $(basename "$file")"
}
need curl
need tar
need awk
need sed
need grep
os="$(detect_os)"
arch="$(detect_arch)"
endpoint="$API_BASE_URL/releases/latest"
if [ "$VERSION" != "latest" ]; then
endpoint="$API_BASE_URL/releases/tags/$VERSION"
fi
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/ktui-install.XXXXXX")"
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
release_json="$(http_get "$endpoint")"
tag="$(json_value "$release_json" tag_name)"
[ -n "$tag" ] || fail "release response does not include tag_name"
asset_name="$(printf '%s' "$release_json" |
tr '{' '\n' |
sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"\(ktui_[^\"]*_${os}_${arch}\.tar\.gz\)\".*/\1/p" |
head -n 1)"
[ -n "$asset_name" ] || fail "release $tag does not contain an asset for $os/$arch"
archive_url="$(asset_url "$asset_name")"
checksums_url="$(asset_url checksums.txt)"
[ -n "$archive_url" ] || fail "release asset $asset_name has no download URL"
[ -n "$checksums_url" ] || fail "release does not contain checksums.txt"
archive_path="$tmp_dir/$asset_name"
checksums_path="$tmp_dir/checksums.txt"
printf 'ktui install: downloading %s\n' "$asset_name"
http_get "$archive_url" >"$archive_path"
http_get "$checksums_url" >"$checksums_path"
expected="$(checksum_for "$asset_name" "$checksums_path")"
[ -n "$expected" ] || fail "checksums.txt does not contain $asset_name"
verify_sha256 "$archive_path" "$expected"
tar -xzf "$archive_path" -C "$tmp_dir"
[ -f "$tmp_dir/$BINARY_NAME" ] || fail "archive does not contain $BINARY_NAME"
mkdir -p "$INSTALL_DIR"
install_path="$INSTALL_DIR/$BINARY_NAME"
if command -v install >/dev/null 2>&1; then
install -m 0755 "$tmp_dir/$BINARY_NAME" "$install_path"
else
cp "$tmp_dir/$BINARY_NAME" "$install_path"
chmod 0755 "$install_path"
fi
printf 'ktui install: installed %s\n' "$install_path"
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) printf 'ktui install: add %s to PATH if ktui is not found\n' "$INSTALL_DIR" ;;
esac
"$install_path" version
+59 -1
View File
@@ -16,6 +16,7 @@ type App struct {
detailCacheTTL time.Duration
realtimePoints int
saveSettings func(PersistentSettings) error
checkUpdate func(context.Context) (UpdateCheckResult, error)
style Style
mode Mode
@@ -32,11 +33,13 @@ type App struct {
settingsURL string
settingsAPIKey string
chartYAxisMode chartYAxisMode
notice string
snapshot komari.Snapshot
err error
loading bool
fetching bool
update updateState
// At most one refresh is queued while an in-flight request is finishing.
refreshPending bool
intervalChanged bool
@@ -53,6 +56,7 @@ type App struct {
refreshCh chan struct{}
resultCh chan fetchResult
detailCh chan detailResult
updateCh chan updateResult
keyCh chan keyEvent
}
@@ -66,11 +70,19 @@ type Options struct {
RealtimePoints int
ChartYAxisMode string
SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error)
ASCII bool
NoColor bool
Mode Mode
}
type UpdateCheckResult struct {
CurrentVersion string
LatestVersion string
AssetName string
Available bool
}
type PersistentSettings struct {
Interval string
Timeout string
@@ -117,6 +129,20 @@ type detailResult struct {
detail nodeDetail
}
type updateResult struct {
result UpdateCheckResult
err error
}
type updateState struct {
Checking bool
Checked bool
Available bool
Latest string
AssetName string
Err error
}
type detailKey struct {
UUID string
Window int
@@ -124,6 +150,8 @@ type detailKey struct {
type keyEvent struct {
name string
x int
y int
}
var tabNames = []string{"overview", "node", "history", "ping", "meta"}
@@ -209,6 +237,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
detailTimeout: opts.DetailTimeout,
detailCacheTTL: opts.DetailCacheTTL,
realtimePoints: opts.RealtimePoints,
checkUpdate: opts.CheckUpdate,
settingsURL: opts.URL,
settingsAPIKey: opts.APIKey,
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor},
@@ -217,6 +246,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
refreshCh: make(chan struct{}, 2),
resultCh: make(chan fetchResult, 2),
detailCh: make(chan detailResult, 4),
updateCh: make(chan updateResult, 1),
keyCh: make(chan keyEvent, 16),
loading: true,
nodeDetail: map[detailKey]nodeDetail{},
@@ -238,6 +268,7 @@ func (a *App) Run(ctx context.Context) error {
defer stopResize()
go a.readKeys(ctx)
a.startUpdateCheck(ctx)
a.requestRefresh()
ticker := time.NewTicker(a.refreshInterval)
@@ -289,6 +320,19 @@ func (a *App) Run(ctx context.Context) error {
case detail := <-a.detailCh:
a.nodeDetail[detail.key] = detail.detail
a.render()
case update := <-a.updateCh:
a.update.Checking = false
a.update.Checked = true
a.update.Err = update.err
if update.err == nil {
a.update.Available = update.result.Available
a.update.Latest = update.result.LatestVersion
a.update.AssetName = update.result.AssetName
if update.result.Available {
a.notice = ""
}
}
a.render()
case key := <-a.keyCh:
a.handleKey(ctx, key)
a.render()
@@ -299,6 +343,20 @@ func (a *App) Run(ctx context.Context) error {
return nil
}
func (a *App) startUpdateCheck(ctx context.Context) {
if a.checkUpdate == nil {
return
}
a.update.Checking = true
go func() {
result, err := a.checkUpdate(ctx)
select {
case a.updateCh <- updateResult{result: result, err: err}:
case <-ctx.Done():
}
}()
}
type refreshTicker interface {
Reset(time.Duration)
}
@@ -360,7 +418,7 @@ func (a *App) fetchSnapshot(ctx context.Context, previous komari.Snapshot, fullF
}
func (a *App) fetchDetail(ctx context.Context, uuid string, force bool) {
if uuid == "" {
if uuid == "" || a.client == nil {
return
}
key := detailKey{UUID: uuid, Window: a.window}
+54
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -57,6 +58,59 @@ func TestNewWithOptionsUsesTimeoutOptions(t *testing.T) {
}
}
func TestStartUpdateCheckRecordsAvailableUpdate(t *testing.T) {
app := NewWithOptions(nil, Options{
CheckUpdate: func(ctx context.Context) (UpdateCheckResult, error) {
return UpdateCheckResult{LatestVersion: "v0.2.0", AssetName: "ktui_v0.2.0_linux_amd64.tar.gz", Available: true}, nil
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.startUpdateCheck(ctx)
result := <-app.updateCh
app.update.Checking = false
app.update.Checked = true
app.update.Err = result.err
app.update.Available = result.result.Available
app.update.Latest = result.result.LatestVersion
if !app.update.Available || app.update.Latest != "v0.2.0" {
t.Fatalf("update = %+v, want available v0.2.0", app.update)
}
}
func TestUpdateAvailableFooterAndHint(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.update.Available = true
app.update.Latest = "v0.2.0"
if !strings.Contains(app.footerText(), "u update") {
t.Fatalf("footer = %q, want update action", app.footerText())
}
app.handleKey(context.Background(), keyEvent{name: "update-hint"})
if !strings.Contains(app.notice, "ktui update") {
t.Fatalf("notice = %q, want update command", app.notice)
}
}
func TestUpdateFooterClickShowsHint(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.update.Available = true
app.update.Latest = "v0.2.0"
_, height := terminalSize()
x, _, ok := footerLabelBounds(app.footerText(), "u update")
if !ok {
t.Fatal("missing u update footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
if !strings.Contains(app.notice, "v0.2.0") {
t.Fatalf("notice = %q, want update hint", app.notice)
}
}
func TestRealtimeRecordsMergeRecentAndLiveSamples(t *testing.T) {
app := NewWithOptions(nil, Options{RefreshInterval: 2 * time.Second})
node := komari.Node{UUID: "node-1", Name: "node"}
+24 -6
View File
@@ -115,6 +115,12 @@ func (a *App) headerLines(width int) []string {
context := strings.Join(contextParts, " ")
if a.err != nil {
context = a.style.red("ERROR " + a.err.Error())
} else if a.notice != "" {
context = a.style.yellow(a.notice)
} else if a.update.Available {
context = a.style.yellow(fmt.Sprintf("UPDATE %s available run `ktui update`", valueOr(a.update.Latest, "latest")))
} else if a.update.Checking {
context = a.style.dim("checking for updates...")
}
lines := []string{
@@ -148,13 +154,25 @@ func headerCompactUnit(value string) string {
}
func (a *App) footerLine(width int) string {
footer := " ↑↓/jk select Enter detail s settings m mode r refresh a ascii q quit "
if a.settings {
footer = " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle "
} else if a.detail {
footer = " Esc/q back 1-5/h/l tabs [ ] window j/k scroll s settings r refresh "
return a.style.inverse(cleanLine(a.footerText(), width))
}
return a.style.inverse(cleanLine(footer, width))
func (a *App) footerText() string {
if a.settings {
return " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle "
}
if a.detail {
footer := " Back(Esc/q) 1-5/h/l tabs [ ] window j/k scroll s settings r refresh "
if a.update.Available {
footer += " u update "
}
return footer
}
footer := " ↑↓/jk select Enter detail s settings m mode r refresh a ascii q quit "
if a.update.Available {
footer += " u update "
}
return footer
}
func (a *App) adjustScroll(visibleRows int) {
+99 -10
View File
@@ -4,10 +4,12 @@ import (
"context"
"io"
"os"
"strconv"
)
func (a *App) readKeys(ctx context.Context) {
buf := make([]byte, 8)
pending := make([]byte, 0, 32)
for {
select {
case <-ctx.Done():
@@ -24,7 +26,10 @@ func (a *App) readKeys(ctx context.Context) {
if n == 0 {
continue
}
for _, key := range parseKeys(buf[:n]) {
pending = append(pending, buf[:n]...)
keys, rest := parseKeysWithRemainder(pending)
pending = rest
for _, key := range keys {
select {
case a.keyCh <- key:
default:
@@ -34,6 +39,11 @@ func (a *App) readKeys(ctx context.Context) {
}
func parseKeys(data []byte) []keyEvent {
keys, _ := parseKeysWithRemainder(data)
return keys
}
func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
out := make([]keyEvent, 0, len(data))
for i := 0; i < len(data); i++ {
switch data[i] {
@@ -57,6 +67,8 @@ func parseKeys(data []byte) []keyEvent {
out = append(out, keyEvent{name: "mode"})
case 's', 'S':
out = append(out, keyEvent{name: "settings"})
case 'u', 'U':
out = append(out, keyEvent{name: "update-hint"})
case 'j':
out = append(out, keyEvent{name: "down"})
case 'k':
@@ -79,6 +91,17 @@ func parseKeys(data []byte) []keyEvent {
out = append(out, keyEvent{name: "window-right"})
case 0x1b:
if i+2 < len(data) && data[i+1] == '[' {
if data[i+2] == '<' {
key, next, ok, incomplete := parseSGRMouse(data, i)
if incomplete {
return out, append([]byte(nil), data[i:]...)
}
if ok {
out = append(out, key)
i = next - 1
}
continue
}
switch data[i+2] {
case 'A':
out = append(out, keyEvent{name: "up"})
@@ -112,13 +135,74 @@ func parseKeys(data []byte) []keyEvent {
}
}
}
return out
return out, nil
}
func parseSGRMouse(data []byte, start int) (keyEvent, int, bool, bool) {
i := start + 3
fieldStart := i
values := make([]int, 0, 3)
for ; i < len(data); i++ {
switch data[i] {
case ';':
value, ok := parseMouseNumber(data[fieldStart:i])
if !ok {
return keyEvent{}, i + 1, false, false
}
values = append(values, value)
fieldStart = i + 1
case 'M', 'm':
value, ok := parseMouseNumber(data[fieldStart:i])
if !ok {
return keyEvent{}, i + 1, false, false
}
values = append(values, value)
if len(values) != 3 {
return keyEvent{}, i + 1, false, false
}
return mouseKey(values[0], values[1], values[2], data[i] == 'm'), i + 1, true, false
}
}
return keyEvent{}, len(data), false, true
}
func parseMouseNumber(data []byte) (int, bool) {
if len(data) == 0 {
return 0, false
}
value, err := strconv.Atoi(string(data))
if err != nil {
return 0, false
}
return value, true
}
func mouseKey(button int, x int, y int, release bool) keyEvent {
switch button & 0b11_000000 {
case 64:
if button&1 == 0 {
return keyEvent{name: "mouse-wheel-up", x: x, y: y}
}
return keyEvent{name: "mouse-wheel-down", x: x, y: y}
}
if !release && button&0b11 == 0 {
return keyEvent{name: "mouse-left", x: x, y: y}
}
return keyEvent{name: "mouse-ignore", x: x, y: y}
}
func (a *App) handleKey(ctx context.Context, key keyEvent) {
previous := a.selected
previousTab := a.tab
previousWindow := a.window
if isMouseKey(key.name) {
a.handleMouse(ctx, key)
a.clampSelection()
if a.scroll < 0 {
a.scroll = 0
}
return
}
if a.settings {
a.handleSettingsKey(key)
return
@@ -139,10 +223,9 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
a.scroll = 0
}
case "settings":
a.settingsWasDetail = a.detail
a.settings = true
a.detail = false
a.scroll = 0
a.openSettings()
case "update-hint":
a.showUpdateHint()
case "open":
if len(a.snapshot.Nodes) > 0 {
a.detail = true
@@ -241,15 +324,21 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
}
}
func isMouseKey(name string) bool {
switch name {
case "mouse-left", "mouse-wheel-up", "mouse-wheel-down", "mouse-ignore":
return true
default:
return false
}
}
func (a *App) handleSettingsKey(key keyEvent) {
switch key.name {
case "force-quit":
a.quit = true
case "quit", "back", "settings":
a.settings = false
a.detail = a.settingsWasDetail
a.settingsWasDetail = false
a.scroll = 0
a.closeSettings()
case "up":
a.moveSettingsSelection(-1)
case "down":
+331
View File
@@ -0,0 +1,331 @@
package tui
import (
"context"
"strings"
)
const (
mouseHeaderRows = 4
sheetCardHeight = 9
sheetCardGap = 2
sheetMinWidth = 40
lineHeaderRows = 3
detailBackClickWidth = 14
)
func (a *App) handleMouse(ctx context.Context, key keyEvent) {
switch key.name {
case "mouse-wheel-up":
a.handleMouseWheel(ctx, -1)
case "mouse-wheel-down":
a.handleMouseWheel(ctx, 1)
case "mouse-left":
a.handleMouseClick(ctx, key.x, key.y)
}
}
func (a *App) handleMouseWheel(ctx context.Context, delta int) {
previous := a.selected
previousTab := a.tab
previousWindow := a.window
if a.settings {
a.moveSettingsSelection(delta)
return
}
if a.detail {
a.scroll += delta * a.detailScrollStep()
if a.scroll < 0 {
a.scroll = 0
}
if previous != a.selected || previousTab != a.tab || previousWindow != a.window || a.tabNeedsDetail() {
a.ensureSelectedDetail(ctx)
}
return
}
a.selected += delta
a.clampSelection()
if previous != a.selected && a.detail {
a.ensureSelectedDetail(ctx)
}
}
func (a *App) handleMouseClick(ctx context.Context, x int, y int) {
if x <= 0 {
return
}
if a.clickFooter(ctx, x, y) {
return
}
if a.settings {
a.clickSettings(y)
return
}
if a.detail {
if a.clickDetailBack(x, y) {
return
}
if y <= mouseHeaderRows {
return
}
a.clickDetailChrome(ctx, x, y)
return
}
if y <= mouseHeaderRows {
return
}
if len(a.snapshot.Nodes) == 0 {
return
}
if a.mode == ModeLine {
a.clickLine(ctx, y)
return
}
a.clickSheet(ctx, x, y)
}
func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
_, height := terminalSize()
if y != height {
return false
}
if a.settings {
switch {
case footerHit(a.footerText(), "Esc/q back", x):
a.closeSettings()
case footerHit(a.footerText(), "←→/h/l adjust", x), footerHit(a.footerText(), "Enter toggle", x):
a.adjustSelectedSetting(1)
default:
return false
}
return true
}
if a.detail {
switch {
case footerHit(a.footerText(), "Back", x):
a.detail = false
a.scroll = 0
case footerHit(a.footerText(), "s settings", x):
a.openSettings()
case footerHit(a.footerText(), "r refresh", x):
a.requestFullRefresh()
if len(a.snapshot.Nodes) > 0 {
a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true)
}
case footerHit(a.footerText(), "u update", x):
a.showUpdateHint()
default:
return false
}
return true
}
switch {
case footerHit(a.footerText(), "Enter detail", x):
a.openSelectedDetail(ctx)
case footerHit(a.footerText(), "s settings", x):
a.openSettings()
case footerHit(a.footerText(), "m mode", x):
if a.mode == ModeLine {
a.mode = ModeSheet
} else {
a.mode = ModeLine
}
case footerHit(a.footerText(), "r refresh", x):
a.requestFullRefresh()
case footerHit(a.footerText(), "a ascii", x):
a.style.ASCII = !a.style.ASCII
case footerHit(a.footerText(), "q quit", x):
a.quit = true
case footerHit(a.footerText(), "u update", x):
a.showUpdateHint()
default:
return false
}
return true
}
func (a *App) clickSettings(y int) {
bodyRow := y - mouseHeaderRows - 1
if bodyRow < 0 {
return
}
itemRow := bodyRow - 1
if a.settingsStatus != "" {
itemRow--
}
if itemRow < 0 || itemRow >= a.settingsCount() {
return
}
a.settingsSelected = itemRow
}
func (a *App) clickLine(ctx context.Context, y int) {
bodyRow := y - mouseHeaderRows - 1
row := bodyRow - lineHeaderRows
if row < 0 {
return
}
index := a.scroll + row
if index >= 0 && index < len(a.snapshot.Nodes) {
a.selected = index
a.clampSelection()
a.openSelectedDetail(ctx)
}
}
func (a *App) clickSheet(ctx context.Context, x int, y int) {
width, height := terminalSize()
drawWidth := width
if drawWidth > 1 {
drawWidth--
}
bodyHeight := height - mouseHeaderRows - 1
if bodyHeight < 1 {
bodyHeight = 1
}
columns, cardWidth := sheetLayout(drawWidth)
bodyRow := y - mouseHeaderRows - 1
if bodyRow < 0 {
return
}
row := bodyRow / sheetCardHeight
lineInCard := bodyRow % sheetCardHeight
if lineInCard >= sheetCardHeight {
return
}
x0 := x - 1
columnSpan := cardWidth + sheetCardGap
col := x0 / columnSpan
if col < 0 || col >= columns {
return
}
if x0%columnSpan >= cardWidth {
return
}
rowsVisible := max(1, bodyHeight/sheetCardHeight)
if row >= rowsVisible {
return
}
index := (a.scroll+row)*columns + col
if index >= 0 && index < len(a.snapshot.Nodes) {
a.selected = index
a.clampSelection()
a.openSelectedDetail(ctx)
}
}
func (a *App) openSelectedDetail(ctx context.Context) {
if len(a.snapshot.Nodes) == 0 {
return
}
a.detail = true
a.scroll = 0
a.ensureSelectedDetail(ctx)
}
func (a *App) clickDetailBack(x int, y int) bool {
_, height := terminalSize()
if y != height || x > detailBackClickWidth {
return false
}
a.detail = false
a.scroll = 0
return true
}
func (a *App) openSettings() {
a.settingsWasDetail = a.detail
a.settings = true
a.detail = false
a.scroll = 0
}
func (a *App) closeSettings() {
a.settings = false
a.detail = a.settingsWasDetail
a.settingsWasDetail = false
a.scroll = 0
}
func (a *App) showUpdateHint() {
if !a.update.Available {
return
}
latest := valueOr(a.update.Latest, "latest")
a.notice = "update available: " + latest + " quit and run `ktui update`"
}
func footerHit(footer string, label string, x int) bool {
start, end, ok := footerLabelBounds(footer, label)
return ok && x >= start && x <= end
}
func footerLabelBounds(footer string, label string) (int, int, bool) {
index := strings.Index(footer, label)
if index < 0 {
return 0, 0, false
}
start := displayWidth(footer[:index]) + 1
end := start + displayWidth(label) - 1
return start, end, true
}
func (a *App) clickDetailChrome(ctx context.Context, x int, y int) {
bodyRow := y - mouseHeaderRows - 1
switch bodyRow {
case 2:
if tab, ok := hitDetailTab(x); ok {
a.tab = tab
a.scroll = 0
if a.tabNeedsDetail() {
a.ensureSelectedDetail(ctx)
}
}
case 3:
if window, ok := hitDetailWindow(x); ok {
a.window = window
a.scroll = 0
a.ensureSelectedDetail(ctx)
}
}
}
func sheetLayout(width int) (int, int) {
columns := max(1, (width+sheetCardGap)/(sheetMinWidth+sheetCardGap))
for columns > 1 {
cardWidth := (width - sheetCardGap*(columns-1)) / columns
if cardWidth >= sheetMinWidth {
break
}
columns--
}
cardWidth := width
if columns > 1 {
cardWidth = (width - sheetCardGap*(columns-1)) / columns
}
return columns, cardWidth
}
func hitDetailTab(x int) (int, bool) {
pos := 1
for i, name := range tabNames {
width := len(" ") + len("1 ") + len(name) + len(" ")
if x >= pos && x < pos+width {
return i, true
}
pos += width + 1
}
return 0, false
}
func hitDetailWindow(x int) (int, bool) {
pos := len(" window ") + 1
for i, window := range detailWindows {
width := len(" ") + len(window.Label) + len(" ")
if x >= pos && x < pos+width {
return i, true
}
pos += width + 1
}
return 0, false
}
+202
View File
@@ -0,0 +1,202 @@
package tui
import (
"context"
"testing"
"ktui/internal/komari"
)
func TestParseSGRMouseWheelAndClick(t *testing.T) {
keys := parseKeys([]byte("\x1b[<64;10;5M\x1b[<65;10;6M\x1b[<0;12;7M\x1b[<0;12;7m"))
if len(keys) != 4 {
t.Fatalf("keys len = %d, want 4: %#v", len(keys), keys)
}
want := []string{"mouse-wheel-up", "mouse-wheel-down", "mouse-left", "mouse-ignore"}
for i := range want {
if keys[i].name != want[i] {
t.Fatalf("key %d = %s, want %s", i, keys[i].name, want[i])
}
}
if keys[2].x != 12 || keys[2].y != 7 {
t.Fatalf("click position = %d,%d; want 12,7", keys[2].x, keys[2].y)
}
}
func TestParseSGRMouseSplitSequence(t *testing.T) {
keys, rest := parseKeysWithRemainder([]byte("\x1b[<0;12"))
if len(keys) != 0 {
t.Fatalf("keys = %#v, want none", keys)
}
if string(rest) != "\x1b[<0;12" {
t.Fatalf("rest = %q", rest)
}
keys, rest = parseKeysWithRemainder(append(rest, []byte(";7M")...))
if len(rest) != 0 {
t.Fatalf("rest = %q, want empty", rest)
}
if len(keys) != 1 || keys[0].name != "mouse-left" || keys[0].x != 12 || keys[0].y != 7 {
t.Fatalf("keys = %#v, want mouse-left at 12,7", keys)
}
}
func TestMouseClickLineOpensNodeDetail(t *testing.T) {
app := NewWithOptions(nil, Options{Mode: ModeLine})
app.snapshot = komari.Snapshot{
Nodes: []komari.Node{
{UUID: "n1", Name: "one"},
{UUID: "n2", Name: "two"},
{UUID: "n3", Name: "three"},
},
Status: map[string]komari.Status{},
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: 2, y: mouseHeaderRows + lineHeaderRows + 2})
if app.selected != 1 {
t.Fatalf("selected = %d, want 1", app.selected)
}
if !app.detail {
t.Fatal("clicking a node should open detail view")
}
if app.scroll != 0 {
t.Fatalf("scroll = %d, want 0", app.scroll)
}
}
func TestMouseClickDetailFooterBackReturnsToList(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.detail = true
app.scroll = 12
_, height := terminalSize()
x, _, ok := footerLabelBounds(app.footerText(), "Back")
if !ok {
t.Fatal("missing Back footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
if app.detail {
t.Fatal("clicking detail footer back area should return to list")
}
if app.scroll != 0 {
t.Fatalf("scroll = %d, want 0", app.scroll)
}
}
func TestMouseClickListFooterActions(t *testing.T) {
app := NewWithOptions(nil, Options{})
_, height := terminalSize()
clickFooterLabel := func(label string) {
t.Helper()
x, _, ok := footerLabelBounds(app.footerText(), label)
if !ok {
t.Fatalf("missing footer label %q in %q", label, app.footerText())
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
}
clickFooterLabel("s settings")
if !app.settings {
t.Fatal("clicking settings footer action should open settings")
}
app.closeSettings()
clickFooterLabel("m mode")
if app.mode != ModeLine {
t.Fatalf("mode = %s, want line", app.mode)
}
clickFooterLabel("r refresh")
select {
case <-app.refreshCh:
default:
t.Fatal("clicking refresh footer action should request refresh")
}
clickFooterLabel("a ascii")
if !app.style.ASCII {
t.Fatal("clicking ascii footer action should toggle ASCII mode")
}
clickFooterLabel("q quit")
if !app.quit {
t.Fatal("clicking quit footer action should set quit")
}
}
func TestMouseClickDetailFooterSettingsAndRefresh(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.detail = true
app.snapshot = komari.Snapshot{
Nodes: []komari.Node{{UUID: "n1", Name: "one"}},
Status: map[string]komari.Status{},
}
_, height := terminalSize()
x, _, ok := footerLabelBounds(app.footerText(), "r refresh")
if !ok {
t.Fatal("missing refresh footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
select {
case <-app.refreshCh:
default:
t.Fatal("clicking detail refresh footer action should request refresh")
}
x, _, ok = footerLabelBounds(app.footerText(), "s settings")
if !ok {
t.Fatal("missing settings footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
if !app.settings || app.detail || !app.settingsWasDetail {
t.Fatalf("settings=%t detail=%t settingsWasDetail=%t, want settings opened from detail", app.settings, app.detail, app.settingsWasDetail)
}
}
func TestMouseClickSettingsFooterActions(t *testing.T) {
app := NewWithOptions(nil, Options{Mode: ModeSheet})
app.settings = true
selectSetting(t, app, "mode")
_, height := terminalSize()
x, _, ok := footerLabelBounds(app.footerText(), "Enter toggle")
if !ok {
t.Fatal("missing Enter toggle footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
if app.mode != ModeLine {
t.Fatalf("mode = %s, want line after settings footer toggle", app.mode)
}
x, _, ok = footerLabelBounds(app.footerText(), "Esc/q back")
if !ok {
t.Fatal("missing back footer label")
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
if app.settings {
t.Fatal("clicking settings back footer action should close settings")
}
}
func TestMouseWheelSettingsSelection(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.settings = true
app.handleKey(context.Background(), keyEvent{name: "mouse-wheel-down", x: 1, y: 1})
if app.settingsSelected != 1 {
t.Fatalf("settingsSelected = %d, want 1", app.settingsSelected)
}
app.handleKey(context.Background(), keyEvent{name: "mouse-wheel-up", x: 1, y: 1})
if app.settingsSelected != 0 {
t.Fatalf("settingsSelected = %d, want 0", app.settingsSelected)
}
}
func TestSheetLayoutMinimumWidth(t *testing.T) {
columns, cardWidth := sheetLayout(100)
if columns != 2 || cardWidth != 49 {
t.Fatalf("layout = %d columns, width %d; want 2,49", columns, cardWidth)
}
}
+3 -16
View File
@@ -19,21 +19,8 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight)
}
cardHeight := 9
gap := 2
minCardWidth := 40
columns := max(1, (width+gap)/(minCardWidth+gap))
for columns > 1 {
cardWidth := (width - gap*(columns-1)) / columns
if cardWidth >= minCardWidth {
break
}
columns--
}
cardWidth := width
if columns > 1 {
cardWidth = (width - gap*(columns-1)) / columns
}
cardHeight := sheetCardHeight
columns, cardWidth := sheetLayout(width)
rowsVisible := max(1, bodyHeight/cardHeight)
selectedRow := a.selected / columns
@@ -68,7 +55,7 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
var line strings.Builder
for col, card := range rowCards {
if col > 0 {
line.WriteString(strings.Repeat(" ", gap))
line.WriteString(strings.Repeat(" ", sheetCardGap))
}
line.WriteString(card[lineIndex])
}
+2 -2
View File
@@ -30,7 +30,7 @@ func enterRawMode() (*terminalState, error) {
if err := ioctlSetTermios(fd, raw); err != nil {
return nil, err
}
fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l")
fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h")
return &terminalState{termios: old}, nil
}
@@ -39,7 +39,7 @@ func (s *terminalState) restore() {
return
}
_ = ioctlSetTermios(int(os.Stdin.Fd()), s.termios)
fmt.Print("\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m")
fmt.Print("\x1b[?1006l\x1b[?1000l\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m")
}
func installSignalRestore(state *terminalState) func() {
+2 -2
View File
@@ -59,7 +59,7 @@ func enterRawMode() (*terminalState, error) {
return nil, err
}
fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l")
fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h")
return &terminalState{
input: input,
output: output,
@@ -74,7 +74,7 @@ func (s *terminalState) restore() {
}
_ = setConsoleMode(s.input, s.inputMode)
_ = setConsoleMode(s.output, s.outputMode)
fmt.Print("\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m")
fmt.Print("\x1b[?1006l\x1b[?1000l\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m")
}
func installSignalRestore(state *terminalState) func() {
+43
View File
@@ -33,6 +33,13 @@ type Options struct {
Stdout io.Writer
}
type CheckResult struct {
CurrentVersion string
LatestVersion string
AssetName string
Available bool
}
type release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
@@ -120,6 +127,42 @@ func Run(ctx context.Context, opts Options) error {
return nil
}
func Check(ctx context.Context, opts Options) (CheckResult, error) {
if opts.APIBaseURL == "" {
opts.APIBaseURL = DefaultAPIBaseURL
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
client := &http.Client{Timeout: opts.Timeout}
rel, err := fetchRelease(ctx, client, opts.APIBaseURL, opts.TargetVersion)
if err != nil {
return CheckResult{}, err
}
if rel.TagName == "" {
return CheckResult{}, errors.New("release response does not include a tag name")
}
result := CheckResult{
CurrentVersion: opts.CurrentVersion,
LatestVersion: displayVersion(rel.TagName),
}
if sameVersion(opts.CurrentVersion, rel.TagName) {
return result, nil
}
archiveAsset, err := selectArchiveAsset(rel.Assets, runtime.GOOS, runtime.GOARCH)
if err != nil {
return CheckResult{}, err
}
result.AssetName = archiveAsset.Name
result.Available = true
return result, nil
}
func fetchRelease(ctx context.Context, client *http.Client, apiBaseURL, tag string) (release, error) {
apiBaseURL = strings.TrimRight(apiBaseURL, "/")
endpoint := apiBaseURL + "/releases/latest"
+67 -1
View File
@@ -1,6 +1,13 @@
package update
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
)
func TestSelectArchiveAsset(t *testing.T) {
assets := []asset{
@@ -47,3 +54,62 @@ func TestSameVersion(t *testing.T) {
t.Fatal("dev version should never be considered up to date")
}
}
func TestCheckReportsAvailableUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/releases/latest" {
t.Fatalf("path = %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(release{
TagName: "v0.2.0",
Assets: []asset{
{Name: "ktui_v0.2.0_" + runtime.GOOS + "_" + runtime.GOARCH + archiveExt(), BrowserDownloadURL: serverURL(r, "/download")},
},
})
}))
defer server.Close()
result, err := Check(context.Background(), Options{
APIBaseURL: server.URL + "/api",
CurrentVersion: "v0.1.0",
})
if err != nil {
t.Fatal(err)
}
if !result.Available || result.LatestVersion != "v0.2.0" || result.AssetName == "" {
t.Fatalf("result = %+v, want available v0.2.0", result)
}
}
func TestCheckIgnoresSameVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(release{TagName: "v0.1.0"})
}))
defer server.Close()
result, err := Check(context.Background(), Options{
APIBaseURL: server.URL,
CurrentVersion: "0.1.0",
})
if err != nil {
t.Fatal(err)
}
if result.Available {
t.Fatalf("result = %+v, want no update", result)
}
}
func archiveExt() string {
if runtime.GOOS == "windows" {
return ".zip"
}
return ".tar.gz"
}
func serverURL(r *http.Request, path string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + path
}