From db851fbcb1eb9106ed615667ae8107b130a1f62b Mon Sep 17 00:00:00 2001 From: starainrt Date: Sat, 26 Apr 2025 19:33:14 +0800 Subject: [PATCH] update --- bed/CHANGELOG.md | 38 + bed/LICENSE | 21 + bed/Makefile | 64 + bed/README.md | 81 + bed/buffer/buffer.go | 502 ++++++ bed/buffer/buffer_test.go | 712 ++++++++ bed/buffer/bytes.go | 66 + bed/buffer/const.go | 21 + bed/cmd/bed/bed.go | 75 + bed/cmdline/cmdline.go | 244 +++ bed/cmdline/cmdline_test.go | 832 +++++++++ bed/cmdline/command.go | 57 + bed/cmdline/completor.go | 266 +++ bed/cmdline/completor_test.go | 566 ++++++ bed/cmdline/environment.go | 18 + bed/cmdline/environment_test.go | 14 + bed/cmdline/filesystem.go | 36 + bed/cmdline/filesystem_test.go | 118 ++ bed/cmdline/parse.go | 55 + bed/editor/cmdline.go | 10 + bed/editor/editor.go | 345 ++++ bed/editor/editor_test.go | 883 +++++++++ bed/editor/key.go | 198 +++ bed/editor/manager.go | 21 + bed/editor/suspend_linux.go | 23 + bed/editor/suspend_unix.go | 23 + bed/editor/suspend_windows.go | 7 + bed/editor/ui.go | 17 + bed/event/event.go | 141 ++ bed/event/parse.go | 96 + bed/event/parse_test.go | 42 + bed/event/range.go | 45 + bed/history/history.go | 56 + bed/history/history_test.go | 99 ++ bed/key/key.go | 91 + bed/key/key_test.go | 71 + bed/layout/layout.go | 500 ++++++ bed/layout/layout_test.go | 307 ++++ bed/mode/mode.go | 14 + bed/searcher/pattern.go | 155 ++ bed/searcher/searcher.go | 143 ++ bed/searcher/searcher_test.go | 181 ++ bed/state/state.go | 45 + bed/tui/key.go | 71 + bed/tui/region.go | 20 + bed/tui/text_drawer.go | 63 + bed/tui/tui.go | 196 ++ bed/tui/tui_test.go | 415 +++++ bed/tui/tui_window.go | 225 +++ bed/window/manager.go | 701 ++++++++ bed/window/manager_test.go | 708 ++++++++ bed/window/window.go | 1070 +++++++++++ bed/window/window_test.go | 1870 ++++++++++++++++++++ gdu/.gitignore | 6 + gdu/.golangci.yml | 122 ++ gdu/.tool-versions | 1 + gdu/Dockerfile | 15 + gdu/INSTALL.md | 142 ++ gdu/LICENSE.md | 8 + gdu/Makefile | 159 ++ gdu/README.md | 308 ++++ gdu/build/build.go | 16 + gdu/cmd/gdu/app/app.go | 473 +++++ gdu/cmd/gdu/app/app_linux_test.go | 123 ++ gdu/cmd/gdu/app/app_test.go | 566 ++++++ gdu/cmd/gdu/main.go | 245 +++ gdu/codecov.yml | 10 + gdu/configuration.md | 195 ++ gdu/default.pgo | Bin 0 -> 62946 bytes gdu/docs/run-books.md | 13 + gdu/gdu.1 | 123 ++ gdu/gdu.1.md | 120 ++ gdu/gdu.png | Bin 0 -> 131657 bytes gdu/gdu.spec | 194 ++ gdu/internal/common/analyze.go | 23 + gdu/internal/common/app.go | 21 + gdu/internal/common/ignore.go | 152 ++ gdu/internal/common/ignore_test.go | 206 +++ gdu/internal/common/signal.go | 11 + gdu/internal/common/ui.go | 74 + gdu/internal/common/ui_test.go | 68 + gdu/internal/testanalyze/analyze.go | 105 ++ gdu/internal/testapp/app.go | 105 ++ gdu/internal/testdata/test.json | 7 + gdu/internal/testdata/wrong.json | 1 + gdu/internal/testdev/dev.go | 18 + gdu/internal/testdir/test_dir.go | 30 + gdu/pkg/analyze/dir_linux-openbsd.go | 32 + gdu/pkg/analyze/dir_linux_test.go | 65 + gdu/pkg/analyze/dir_other.go | 23 + gdu/pkg/analyze/dir_test.go | 243 +++ gdu/pkg/analyze/dir_unix.go | 32 + gdu/pkg/analyze/encode.go | 101 ++ gdu/pkg/analyze/encode_test.go | 68 + gdu/pkg/analyze/file.go | 256 +++ gdu/pkg/analyze/file_test.go | 337 ++++ gdu/pkg/analyze/memory.go | 63 + gdu/pkg/analyze/memory_test.go | 27 + gdu/pkg/analyze/parallel.go | 227 +++ gdu/pkg/analyze/sequential.go | 185 ++ gdu/pkg/analyze/sequential_test.go | 206 +++ gdu/pkg/analyze/sort_test.go | 193 ++ gdu/pkg/analyze/storage.go | 142 ++ gdu/pkg/analyze/stored.go | 398 +++++ gdu/pkg/analyze/stored_test.go | 310 ++++ gdu/pkg/analyze/symlink.go | 40 + gdu/pkg/analyze/symlink_test.go | 42 + gdu/pkg/analyze/top.go | 48 + gdu/pkg/analyze/top_test.go | 69 + gdu/pkg/analyze/wait.go | 49 + gdu/pkg/annex/annex.go | 65 + gdu/pkg/annex/annex_test.go | 39 + gdu/pkg/device/dev.go | 56 + gdu/pkg/device/dev_bsd.go | 75 + gdu/pkg/device/dev_bsd_test.go | 22 + gdu/pkg/device/dev_freebsd_darwin_other.go | 100 ++ gdu/pkg/device/dev_freebsd_darwin_test.go | 44 + gdu/pkg/device/dev_linux.go | 104 ++ gdu/pkg/device/dev_linux_test.go | 72 + gdu/pkg/device/dev_netbsd.go | 31 + gdu/pkg/device/dev_openbsd.go | 32 + gdu/pkg/device/dev_other.go | 22 + gdu/pkg/device/dev_test.go | 73 + gdu/pkg/fs/file.go | 133 ++ gdu/pkg/path/path.go | 26 + gdu/pkg/path/path_test.go | 15 + gdu/pkg/remove/parallel.go | 62 + gdu/pkg/remove/parallel_linux_test.go | 67 + gdu/pkg/remove/parallel_test.go | 69 + gdu/pkg/remove/remove.go | 49 + gdu/pkg/remove/remove_linux_test.go | 42 + gdu/pkg/remove/remove_test.go | 130 ++ gdu/report/export.go | 264 +++ gdu/report/export_linux_test.go | 56 + gdu/report/export_test.go | 133 ++ gdu/report/import.go | 109 ++ gdu/report/import_test.go | 111 ++ gdu/snapcraft.yaml | 30 + gdu/stdout/stdout.go | 494 ++++++ gdu/stdout/stdout_linux_test.go | 28 + gdu/stdout/stdout_test.go | 314 ++++ gdu/tui/actions.go | 407 +++++ gdu/tui/actions_linux_test.go | 25 + gdu/tui/actions_test.go | 510 ++++++ gdu/tui/background.go | 100 ++ gdu/tui/exec.go | 18 + gdu/tui/exec_other.go | 48 + gdu/tui/exec_test.go | 13 + gdu/tui/exec_windows.go | 33 + gdu/tui/export_test.go | 203 +++ gdu/tui/filter.go | 56 + gdu/tui/filter_test.go | 143 ++ gdu/tui/format.go | 184 ++ gdu/tui/format_test.go | 175 ++ gdu/tui/keys.go | 350 ++++ gdu/tui/keys_test.go | 1056 +++++++++++ gdu/tui/marked.go | 147 ++ gdu/tui/marked_test.go | 22 + gdu/tui/mouse.go | 49 + gdu/tui/mouse_test.go | 126 ++ gdu/tui/progress.go | 53 + gdu/tui/show.go | 332 ++++ gdu/tui/show_file.go | 146 ++ gdu/tui/show_file_test.go | 89 + gdu/tui/sort.go | 105 ++ gdu/tui/sort_test.go | 207 +++ gdu/tui/status.go | 84 + gdu/tui/tui.go | 473 +++++ gdu/tui/tui_test.go | 850 +++++++++ gdu/tui/utils.go | 71 + gdu/tui/utils_test.go | 31 + go.mod | 48 +- go.sum | 230 ++- hcache/formats.go | 306 ++++ hcache/main.go | 216 +++ hcache/nolinux.go | 15 + hcache/pkg/utils/process.go | 54 + hcache/pkg/utils/process_linux.go | 40 + hcache/pkg/utils/process_unix.go | 106 ++ hcache/winsize.go | 50 + main.go | 5 +- mget/wget.go | 2 + tcpkill/cmd.go | 1 + tcpkill/tcpkill.go | 8 + tcpkill/tcpkill_unix.go | 1 + tcpkill/tcpkill_windows.go | 1 + version/version.go | 2 +- 187 files changed, 29490 insertions(+), 22 deletions(-) create mode 100644 bed/CHANGELOG.md create mode 100644 bed/LICENSE create mode 100644 bed/Makefile create mode 100644 bed/README.md create mode 100644 bed/buffer/buffer.go create mode 100644 bed/buffer/buffer_test.go create mode 100644 bed/buffer/bytes.go create mode 100644 bed/buffer/const.go create mode 100644 bed/cmd/bed/bed.go create mode 100644 bed/cmdline/cmdline.go create mode 100644 bed/cmdline/cmdline_test.go create mode 100644 bed/cmdline/command.go create mode 100644 bed/cmdline/completor.go create mode 100644 bed/cmdline/completor_test.go create mode 100644 bed/cmdline/environment.go create mode 100644 bed/cmdline/environment_test.go create mode 100644 bed/cmdline/filesystem.go create mode 100644 bed/cmdline/filesystem_test.go create mode 100644 bed/cmdline/parse.go create mode 100644 bed/editor/cmdline.go create mode 100644 bed/editor/editor.go create mode 100644 bed/editor/editor_test.go create mode 100644 bed/editor/key.go create mode 100644 bed/editor/manager.go create mode 100644 bed/editor/suspend_linux.go create mode 100644 bed/editor/suspend_unix.go create mode 100644 bed/editor/suspend_windows.go create mode 100644 bed/editor/ui.go create mode 100644 bed/event/event.go create mode 100644 bed/event/parse.go create mode 100644 bed/event/parse_test.go create mode 100644 bed/event/range.go create mode 100644 bed/history/history.go create mode 100644 bed/history/history_test.go create mode 100644 bed/key/key.go create mode 100644 bed/key/key_test.go create mode 100644 bed/layout/layout.go create mode 100644 bed/layout/layout_test.go create mode 100644 bed/mode/mode.go create mode 100644 bed/searcher/pattern.go create mode 100644 bed/searcher/searcher.go create mode 100644 bed/searcher/searcher_test.go create mode 100644 bed/state/state.go create mode 100644 bed/tui/key.go create mode 100644 bed/tui/region.go create mode 100644 bed/tui/text_drawer.go create mode 100644 bed/tui/tui.go create mode 100644 bed/tui/tui_test.go create mode 100644 bed/tui/tui_window.go create mode 100644 bed/window/manager.go create mode 100644 bed/window/manager_test.go create mode 100644 bed/window/window.go create mode 100644 bed/window/window_test.go create mode 100644 gdu/.gitignore create mode 100644 gdu/.golangci.yml create mode 100644 gdu/.tool-versions create mode 100644 gdu/Dockerfile create mode 100644 gdu/INSTALL.md create mode 100644 gdu/LICENSE.md create mode 100644 gdu/Makefile create mode 100644 gdu/README.md create mode 100644 gdu/build/build.go create mode 100644 gdu/cmd/gdu/app/app.go create mode 100644 gdu/cmd/gdu/app/app_linux_test.go create mode 100644 gdu/cmd/gdu/app/app_test.go create mode 100644 gdu/cmd/gdu/main.go create mode 100644 gdu/codecov.yml create mode 100644 gdu/configuration.md create mode 100644 gdu/default.pgo create mode 100644 gdu/docs/run-books.md create mode 100644 gdu/gdu.1 create mode 100644 gdu/gdu.1.md create mode 100644 gdu/gdu.png create mode 100644 gdu/gdu.spec create mode 100644 gdu/internal/common/analyze.go create mode 100644 gdu/internal/common/app.go create mode 100644 gdu/internal/common/ignore.go create mode 100644 gdu/internal/common/ignore_test.go create mode 100644 gdu/internal/common/signal.go create mode 100644 gdu/internal/common/ui.go create mode 100644 gdu/internal/common/ui_test.go create mode 100644 gdu/internal/testanalyze/analyze.go create mode 100644 gdu/internal/testapp/app.go create mode 100644 gdu/internal/testdata/test.json create mode 100644 gdu/internal/testdata/wrong.json create mode 100644 gdu/internal/testdev/dev.go create mode 100644 gdu/internal/testdir/test_dir.go create mode 100644 gdu/pkg/analyze/dir_linux-openbsd.go create mode 100644 gdu/pkg/analyze/dir_linux_test.go create mode 100644 gdu/pkg/analyze/dir_other.go create mode 100644 gdu/pkg/analyze/dir_test.go create mode 100644 gdu/pkg/analyze/dir_unix.go create mode 100644 gdu/pkg/analyze/encode.go create mode 100644 gdu/pkg/analyze/encode_test.go create mode 100644 gdu/pkg/analyze/file.go create mode 100644 gdu/pkg/analyze/file_test.go create mode 100644 gdu/pkg/analyze/memory.go create mode 100644 gdu/pkg/analyze/memory_test.go create mode 100644 gdu/pkg/analyze/parallel.go create mode 100644 gdu/pkg/analyze/sequential.go create mode 100644 gdu/pkg/analyze/sequential_test.go create mode 100644 gdu/pkg/analyze/sort_test.go create mode 100644 gdu/pkg/analyze/storage.go create mode 100644 gdu/pkg/analyze/stored.go create mode 100644 gdu/pkg/analyze/stored_test.go create mode 100644 gdu/pkg/analyze/symlink.go create mode 100644 gdu/pkg/analyze/symlink_test.go create mode 100644 gdu/pkg/analyze/top.go create mode 100644 gdu/pkg/analyze/top_test.go create mode 100644 gdu/pkg/analyze/wait.go create mode 100644 gdu/pkg/annex/annex.go create mode 100644 gdu/pkg/annex/annex_test.go create mode 100644 gdu/pkg/device/dev.go create mode 100644 gdu/pkg/device/dev_bsd.go create mode 100644 gdu/pkg/device/dev_bsd_test.go create mode 100644 gdu/pkg/device/dev_freebsd_darwin_other.go create mode 100644 gdu/pkg/device/dev_freebsd_darwin_test.go create mode 100644 gdu/pkg/device/dev_linux.go create mode 100644 gdu/pkg/device/dev_linux_test.go create mode 100644 gdu/pkg/device/dev_netbsd.go create mode 100644 gdu/pkg/device/dev_openbsd.go create mode 100644 gdu/pkg/device/dev_other.go create mode 100644 gdu/pkg/device/dev_test.go create mode 100644 gdu/pkg/fs/file.go create mode 100644 gdu/pkg/path/path.go create mode 100644 gdu/pkg/path/path_test.go create mode 100644 gdu/pkg/remove/parallel.go create mode 100644 gdu/pkg/remove/parallel_linux_test.go create mode 100644 gdu/pkg/remove/parallel_test.go create mode 100644 gdu/pkg/remove/remove.go create mode 100644 gdu/pkg/remove/remove_linux_test.go create mode 100644 gdu/pkg/remove/remove_test.go create mode 100644 gdu/report/export.go create mode 100644 gdu/report/export_linux_test.go create mode 100644 gdu/report/export_test.go create mode 100644 gdu/report/import.go create mode 100644 gdu/report/import_test.go create mode 100644 gdu/snapcraft.yaml create mode 100644 gdu/stdout/stdout.go create mode 100644 gdu/stdout/stdout_linux_test.go create mode 100644 gdu/stdout/stdout_test.go create mode 100644 gdu/tui/actions.go create mode 100644 gdu/tui/actions_linux_test.go create mode 100644 gdu/tui/actions_test.go create mode 100644 gdu/tui/background.go create mode 100644 gdu/tui/exec.go create mode 100644 gdu/tui/exec_other.go create mode 100644 gdu/tui/exec_test.go create mode 100644 gdu/tui/exec_windows.go create mode 100644 gdu/tui/export_test.go create mode 100644 gdu/tui/filter.go create mode 100644 gdu/tui/filter_test.go create mode 100644 gdu/tui/format.go create mode 100644 gdu/tui/format_test.go create mode 100644 gdu/tui/keys.go create mode 100644 gdu/tui/keys_test.go create mode 100644 gdu/tui/marked.go create mode 100644 gdu/tui/marked_test.go create mode 100644 gdu/tui/mouse.go create mode 100644 gdu/tui/mouse_test.go create mode 100644 gdu/tui/progress.go create mode 100644 gdu/tui/show.go create mode 100644 gdu/tui/show_file.go create mode 100644 gdu/tui/show_file_test.go create mode 100644 gdu/tui/sort.go create mode 100644 gdu/tui/sort_test.go create mode 100644 gdu/tui/status.go create mode 100644 gdu/tui/tui.go create mode 100644 gdu/tui/tui_test.go create mode 100644 gdu/tui/utils.go create mode 100644 gdu/tui/utils_test.go create mode 100644 hcache/formats.go create mode 100644 hcache/main.go create mode 100644 hcache/nolinux.go create mode 100644 hcache/pkg/utils/process.go create mode 100644 hcache/pkg/utils/process_linux.go create mode 100644 hcache/pkg/utils/process_unix.go create mode 100644 hcache/winsize.go diff --git a/bed/CHANGELOG.md b/bed/CHANGELOG.md new file mode 100644 index 0000000..17ea498 --- /dev/null +++ b/bed/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog +## [v0.2.8](https://b612.me/apps/b612/bed/compare/v0.2.7..v0.2.8) (2024-12-01) +* Refactor drawing command line and completion candidates. +* Fix jump back action not to crash when the buffer is edited. + +## [v0.2.7](https://b612.me/apps/b612/bed/compare/v0.2.6..v0.2.7) (2024-10-20) +* Support environment variable expansion in the command line. +* Implement `:cd`, `:chdir`, `:pwd` commands to change the working directory. +* Improve command line completion for command name and environment variables. +* Recognize file name argument and bang for `:wq` command. + +## [v0.2.6](https://b612.me/apps/b612/bed/compare/v0.2.5..v0.2.6) (2024-10-08) +* Support reading from standard input. +* Implement command line history. + +## [v0.2.5](https://b612.me/apps/b612/bed/compare/v0.2.4..v0.2.5) (2024-05-03) +* Require Go 1.22. + +## [v0.2.4](https://b612.me/apps/b612/bed/compare/v0.2.3..v0.2.4) (2023-09-30) +* Require Go 1.21. + +## [v0.2.3](https://b612.me/apps/b612/bed/compare/v0.2.2..v0.2.3) (2022-12-25) +* Fix crash on window moving commands on the last window. + +## [v0.2.2](https://b612.me/apps/b612/bed/compare/v0.2.1..v0.2.2) (2021-09-14) +* Add `:only` command to make the current window the only one. +* Reduce memory allocations on rendering. +* Release `arm64` artifacts. + +## [v0.2.1](https://b612.me/apps/b612/bed/compare/v0.2.0..v0.2.1) (2020-12-29) +* Add `:{count}%` to go to the position by percentage in the file. +* Add `:{count}go[to]` command to go to the specific line. + +## [v0.2.0](https://b612.me/apps/b612/bed/compare/v0.1.0..v0.2.0) (2020-04-10) +* Add `:cquit` command. + +## [v0.1.0](https://b612.me/apps/b612/bed/compare/8239ec4..v0.1.0) (2020-01-25) +* Initial implementation. diff --git a/bed/LICENSE b/bed/LICENSE new file mode 100644 index 0000000..49b0fc3 --- /dev/null +++ b/bed/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017-2024 itchyny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bed/Makefile b/bed/Makefile new file mode 100644 index 0000000..17d83a2 --- /dev/null +++ b/bed/Makefile @@ -0,0 +1,64 @@ +BIN := bed +VERSION := $$(make -s show-version) +VERSION_PATH := cmd/$(BIN) +CURRENT_REVISION = $(shell git rev-parse --short HEAD) +BUILD_LDFLAGS = "-s -w -X main.revision=$(CURRENT_REVISION)" +GOBIN ?= $(shell go env GOPATH)/bin + +.PHONY: all +all: build + +.PHONY: build +build: + go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/$(BIN) + +.PHONY: install +install: + go install -ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) + +.PHONY: show-version +show-version: $(GOBIN)/gobump + @gobump show -r "$(VERSION_PATH)" + +$(GOBIN)/gobump: + @go install github.com/x-motemen/gobump/cmd/gobump@latest + +.PHONY: cross +cross: $(GOBIN)/goxz CREDITS + goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) + +$(GOBIN)/goxz: + go install github.com/Songmu/goxz/cmd/goxz@latest + +CREDITS: $(GOBIN)/gocredits go.sum + go mod tidy + gocredits -w . + +$(GOBIN)/gocredits: + go install github.com/Songmu/gocredits/cmd/gocredits@latest + +.PHONY: test +test: build + go test -v -race -timeout 30s ./... + +.PHONY: lint +lint: $(GOBIN)/staticcheck + go vet ./... + staticcheck -checks all,-ST1000 ./... + +$(GOBIN)/staticcheck: + go install honnef.co/go/tools/cmd/staticcheck@latest + +.PHONY: clean +clean: + rm -rf $(BIN) goxz CREDITS + go clean + +.PHONY: bump +bump: $(GOBIN)/gobump + test -z "$$(git status --porcelain || echo .)" + test "$$(git branch --show-current)" = "main" + @gobump up -w "$(VERSION_PATH)" + git commit -am "bump up version to $(VERSION)" + git tag "v$(VERSION)" + git push --atomic origin main tag "v$(VERSION)" diff --git a/bed/README.md b/bed/README.md new file mode 100644 index 0000000..d80fe6c --- /dev/null +++ b/bed/README.md @@ -0,0 +1,81 @@ +# bed +[![CI Status](https://b612.me/apps/b612/bed/actions/workflows/ci.yaml/badge.svg?branch=main)](https://b612.me/apps/b612/bed/actions?query=branch:main) +[![Go Report Card](https://goreportcard.com/badge/b612.me/apps/b612/bed)](https://goreportcard.com/report/b612.me/apps/b612/bed) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://b612.me/apps/b612/bed/blob/main/LICENSE) +[![release](https://img.shields.io/github/release/itchyny/bed/all.svg)](https://b612.me/apps/b612/bed/releases) +[![pkg.go.dev](https://pkg.go.dev/badge/b612.me/apps/b612/bed)](https://pkg.go.dev/b612.me/apps/b612/bed) + +Binary editor written in Go + +## Screenshot +![bed command screenshot](https://user-images.githubusercontent.com/375258/38499347-2f71306c-3c42-11e8-926e-1782b0bc73f3.png) + +## Motivation +I wanted to create a binary editor with Vim-like user interface, which runs in terminals, fast, and is portable. +I have always been interested in various binary formats and I wanted to create my own editor to handle them. +I also wanted to learn how a binary editor can handle large files and allow users to edit them interactively. + +While creating this binary editor, I leaned a lot about programming in Go language. +I spent a lot of time writing the core logic of buffer implementation of the editor. +It was a great learning experience for me and a lot of fun. + +## Installation +### Homebrew + +```sh +brew install bed +``` + +### Build from source + +```bash +go install b612.me/apps/b612/bed/cmd/bed@latest +``` + +## Features + +- Basic byte editing +- Large file support +- Command line interface +- Window splitting +- Partial writing +- Text searching +- Undo and redo + +### Commands and keyboard shortcuts +This binary editor is influenced by the Vim editor. + +- File operations + - `:edit`, `:enew`, `:new`, `:vnew`, `:only` +- Current working directory + - `:cd`, `:chdir`, `:pwd` +- Quit and save + - `:quit`, `ZQ`, `:qall`, `:write`, + `:wq`, `ZZ`, `:xit`, `:xall`, `:cquit` +- Window operations + - `:wincmd [nohjkltbpHJKL]`, `[nohjkltbpHJKL]` +- Cursor motions + - `h`, `j`, `k`, `l`, `w`, `b`, `^`, `0`, `$`, + ``, ``, ``, ``, + `G`, `gg`, `:{count}`, `:{count}goto`, `:{count}%`, + `H`, `M`, `L`, `zt`, `zz`, `z.`, `zb`, `z-`, + `` (toggle focus between hex and text views) +- Mode operations + - `i`, `I`, `a`, `A`, `v`, `r`, `R`, `` +- Inspect and edit + - `gb` (binary), `gd` (decimal), `x` (delete), `X` (delete backward), + `d` (delete selection), `y` (copy selection), `p`, `P` (paste), + `<` (left shift), `>` (right shift), `` (increment), `` (decrement) +- Undo and redo + - `:undo`, `u`, `:redo`, `` +- Search + - `/`, `?`, `n`, `N`, `` (abort) + +## Bug Tracker +Report bug at [Issues・itchyny/bed - GitHub](https://b612.me/apps/b612/bed/issues). + +## Author +itchyny () + +## License +This software is released under the MIT License, see LICENSE. diff --git a/bed/buffer/buffer.go b/bed/buffer/buffer.go new file mode 100644 index 0000000..1a40eef --- /dev/null +++ b/bed/buffer/buffer.go @@ -0,0 +1,502 @@ +package buffer + +import ( + "errors" + "io" + "math" + "slices" + "sync" +) + +// Buffer represents a buffer. +type Buffer struct { + rrs []readerRange + index int64 + mu *sync.Mutex + bytes []byte + offset int64 +} + +type readAtSeeker interface { + io.ReaderAt + io.Seeker +} + +type readerRange struct { + r readAtSeeker + min int64 + max int64 + diff int64 +} + +// NewBuffer creates a new buffer. +func NewBuffer(r readAtSeeker) *Buffer { + return &Buffer{ + rrs: []readerRange{{r: r, min: 0, max: math.MaxInt64, diff: 0}}, + index: 0, + mu: new(sync.Mutex), + } +} + +// Read reads bytes. +func (b *Buffer) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.read(p) +} + +func (b *Buffer) read(p []byte) (i int, err error) { + index := b.index + for _, rr := range b.rrs { + if b.index < rr.min { + break + } + if b.index >= rr.max { + continue + } + m := int(min(int64(len(p)-i), rr.max-b.index)) + var k int + if k, err = rr.r.ReadAt(p[i:i+m], b.index+rr.diff); err != nil && k == 0 { + break + } + err = nil + b.index += int64(m) + i += k + } + if len(b.bytes) > 0 { + j, k := max(b.offset-index, 0), max(index-b.offset, 0) + if j < int64(len(p)) && k < int64(len(b.bytes)) { + if cnt := copy(p[j:], b.bytes[k:]); i < int(j)+cnt { + i = int(j) + cnt + } + } + } + return +} + +// Seek sets the offset. +func (b *Buffer) Seek(offset int64, whence int) (int64, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.seek(offset, whence) +} + +func (b *Buffer) seek(offset int64, whence int) (int64, error) { + var index int64 + switch whence { + case io.SeekStart: + index = offset + case io.SeekCurrent: + index = b.index + offset + case io.SeekEnd: + var l int64 + var err error + if l, err = b.len(); err != nil { + return 0, err + } + index = l + offset + default: + return 0, errors.New("buffer.Buffer.Seek: invalid whence") + } + if index < 0 { + return 0, errors.New("buffer.Buffer.Seek: negative position") + } + b.index = index + return index, nil +} + +// Len returns the total size of the buffer. +func (b *Buffer) Len() (int64, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.len() +} + +func (b *Buffer) len() (int64, error) { + rr := b.rrs[len(b.rrs)-1] + l, err := rr.r.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + return max(l-rr.diff, b.offset+int64(len(b.bytes))), nil +} + +// ReadAt reads bytes at the specific offset. +func (b *Buffer) ReadAt(p []byte, offset int64) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if _, err := b.seek(offset, io.SeekStart); err != nil { + return 0, err + } + return b.read(p) +} + +// EditedIndices returns the indices of edited regions. +func (b *Buffer) EditedIndices() []int64 { + b.mu.Lock() + defer b.mu.Unlock() + eis := make([]int64, 0, len(b.rrs)) + for _, rr := range b.rrs { + switch rr.r.(type) { + case *bytesReader, constReader: + // constReader can be adjacent to another bytesReader or constReader. + if l := len(eis); l > 0 && eis[l-1] == rr.min { + eis[l-1] = rr.max + continue + } + eis = append(eis, rr.min, rr.max) + } + } + if len(b.bytes) > 0 { + eis = insertInterval(eis, b.offset, b.offset+int64(len(b.bytes))) + } + return eis +} + +func insertInterval(xs []int64, start, end int64) []int64 { + i, fi := slices.BinarySearch(xs, start) + j, fj := slices.BinarySearch(xs, end) + if i%2 == 0 { + if i == j && !fi && !fj { + return slices.Insert(xs, i, start, end) + } + xs[i] = start + i++ + } + if j%2 == 0 { + if fj { + j++ + } else { + j-- + xs[j] = end + } + } + return slices.Delete(xs, i, j) +} + +// Clone the buffer. +func (b *Buffer) Clone() *Buffer { + b.mu.Lock() + defer b.mu.Unlock() + newBuf := new(Buffer) + newBuf.rrs = make([]readerRange, len(b.rrs)) + for i, rr := range b.rrs { + newBuf.rrs[i] = readerRange{rr.r, rr.min, rr.max, rr.diff} + } + newBuf.index = b.index + newBuf.mu = new(sync.Mutex) + newBuf.bytes = slices.Clone(b.bytes) + newBuf.offset = b.offset + return newBuf +} + +// Copy a part of the buffer. +func (b *Buffer) Copy(start, end int64) *Buffer { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + newBuf := new(Buffer) + rrs := make([]readerRange, 0, len(b.rrs)+1) + index := start + for _, rr := range b.rrs { + if index < rr.min || index >= end { + break + } + if index >= rr.max { + continue + } + size := min(end-index, rr.max-index) + rrs = append(rrs, readerRange{rr.r, index - start, index - start + size, rr.diff + start}) + index += size + } + newBuf.rrs = append(rrs, readerRange{newBytesReader(nil), index - start, math.MaxInt64, -index + start}) + newBuf.cleanup() + newBuf.index = 0 + newBuf.mu = new(sync.Mutex) + return newBuf +} + +// Cut a part of the buffer. +func (b *Buffer) Cut(start, end int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + rrs := make([]readerRange, 0, len(b.rrs)+1) + var index, max int64 + for _, rr := range b.rrs { + if start >= rr.max { + rrs = append(rrs, rr) + index = rr.max + continue + } + if end <= rr.min { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + continue + } + if start >= rr.min { + max = start + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff}) + index = max + } + if end < rr.max { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - end + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff + end - index}) + index = max + } + } + if index != math.MaxInt64 { + rrs = append(rrs, readerRange{newBytesReader(nil), index, math.MaxInt64, -index}) + } + b.rrs = rrs + b.index = 0 + b.cleanup() +} + +// Paste a buffer into a buffer. +func (b *Buffer) Paste(offset int64, c *Buffer) { + b.mu.Lock() + c.mu.Lock() + defer b.mu.Unlock() + defer c.mu.Unlock() + b.flush() + rrs := make([]readerRange, 0, len(b.rrs)+len(c.rrs)+1) + var index, max int64 + for _, rr := range b.rrs { + if offset >= rr.max { + rrs = append(rrs, rr) + continue + } + if offset < rr.min { + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + continue + } + rrs = append(rrs, readerRange{rr.r, rr.min, offset, rr.diff}) + index = offset + for _, rr := range c.rrs { + if rr.max == math.MaxInt64 { + l, _ := rr.r.Seek(0, io.SeekEnd) + max = l + index + } else { + max = rr.max - rr.min + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + rr.min}) + index = max + } + if rr.max == math.MaxInt64 { + max = math.MaxInt64 + } else { + max = rr.max - offset + index + } + rrs = append(rrs, readerRange{rr.r, index, max, rr.diff - index + offset}) + index = max + } + b.rrs = rrs + b.cleanup() +} + +// Insert inserts a byte at the specific position. +func (b *Buffer) Insert(offset int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + for i, rr := range b.rrs { + if offset > rr.max { + continue + } + var r *bytesReader + var ok bool + if rr.max != math.MaxInt64 { + if r, ok = rr.r.(*bytesReader); ok { + r = r.clone() + r.insert(offset+rr.diff, c) + b.rrs[i], i = readerRange{r, rr.min, rr.max + 1, rr.diff}, i+1 + } + } + if !ok { + b.rrs = append(b.rrs, readerRange{}, readerRange{}) + copy(b.rrs[i+2:], b.rrs[i:]) + b.rrs[i], i = readerRange{rr.r, rr.min, offset, rr.diff}, i+1 + b.rrs[i], i = readerRange{newBytesReader([]byte{c}), offset, offset + 1, -offset}, i+1 + b.rrs[i].min = offset + } + for ; i < len(b.rrs); i++ { + b.rrs[i].min++ + if b.rrs[i].max != math.MaxInt64 { + b.rrs[i].max++ + } + b.rrs[i].diff-- + } + b.cleanup() + return + } + panic("buffer.Buffer.Insert: unreachable") +} + +// Replace replaces a byte at the specific position. +// This method does not overwrite the reader ranges, +// but just append the byte to the temporary byte slice +// in order to cancel the replacement with backspace key. +func (b *Buffer) Replace(offset int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + if b.offset+int64(len(b.bytes)) != offset { + b.flush() + } + if len(b.bytes) == 0 { + b.offset = offset + } + b.bytes = append(b.bytes, c) +} + +// UndoReplace removes the last byte of the replacing byte slice. +func (b *Buffer) UndoReplace(offset int64) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.bytes) > 0 && b.offset+int64(len(b.bytes))-1 == offset { + b.bytes = b.bytes[:len(b.bytes)-1] + } +} + +// ReplaceIn replaces bytes within a specific range. +func (b *Buffer) ReplaceIn(start, end int64, c byte) { + b.mu.Lock() + defer b.mu.Unlock() + rrs := make([]readerRange, 0, len(b.rrs)+1) + for _, rr := range b.rrs { + if rr.max <= start || end <= rr.min { + rrs = append(rrs, rr) + continue + } + if start > rr.min { + rrs = append(rrs, readerRange{rr.r, rr.min, start, rr.diff}) + } + if start >= rr.min { + rrs = append(rrs, readerRange{constReader(c), start, end, -start}) + } + if end < rr.max { + rrs = append(rrs, readerRange{rr.r, end, rr.max, rr.diff}) + } + } + b.rrs = rrs + b.cleanup() +} + +// Flush temporary bytes. +func (b *Buffer) Flush() { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() +} + +func (b *Buffer) flush() { + if len(b.bytes) == 0 { + return + } + rrs := make([]readerRange, 0, len(b.rrs)+1) + end := b.offset + int64(len(b.bytes)) + for _, rr := range b.rrs { + if b.offset >= rr.max || end <= rr.min { + rrs = append(rrs, rr) + continue + } + if b.offset >= rr.min { + if rr.min < b.offset { + rrs = append(rrs, readerRange{rr.r, rr.min, b.offset, rr.diff}) + } + rrs = append(rrs, readerRange{newBytesReader(b.bytes), b.offset, end, -b.offset}) + } + if rr.max == math.MaxInt64 { + l, _ := rr.r.Seek(0, io.SeekEnd) + if l-rr.diff <= end { + rrs = append(rrs, readerRange{newBytesReader(nil), end, math.MaxInt64, -end}) + continue + } + } + if end < rr.max { + rrs = append(rrs, readerRange{rr.r, end, rr.max, rr.diff}) + } + } + b.rrs = rrs + b.offset = 0 + b.bytes = nil + b.cleanup() +} + +// Delete deletes a byte at the specific position. +func (b *Buffer) Delete(offset int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.flush() + for i, rr := range b.rrs { + if offset >= rr.max { + continue + } + if r, ok := rr.r.(*bytesReader); ok { + r = r.clone() + r.delete(offset + rr.diff) + b.rrs[i] = readerRange{r, rr.min, rr.max - 1, rr.diff} + } else { + b.rrs = append(b.rrs, readerRange{}) + copy(b.rrs[i+1:], b.rrs[i:]) + b.rrs[i] = readerRange{rr.r, rr.min, offset, rr.diff} + b.rrs[i+1] = readerRange{rr.r, offset + 1, rr.max, rr.diff} + } + for i++; i < len(b.rrs); i++ { + b.rrs[i].min-- + if b.rrs[i].max != math.MaxInt64 { + b.rrs[i].max-- + } + b.rrs[i].diff++ + } + b.cleanup() + return + } + panic("buffer.Buffer.Delete: unreachable") +} + +func (b *Buffer) cleanup() { + for i := 0; i < len(b.rrs); i++ { + if rr := b.rrs[i]; rr.min == rr.max { + b.rrs = slices.Delete(b.rrs, i, i+1) + } + } + for i := len(b.rrs) - 1; i > 0; i-- { + rr1, rr2 := b.rrs[i-1], b.rrs[i] + switch r1 := rr1.r.(type) { + case constReader: + if r1 == rr2.r { + b.rrs[i-1].max = rr2.max + b.rrs = slices.Delete(b.rrs, i, i+1) + } + case *bytesReader: + if r2, ok := rr2.r.(*bytesReader); ok { + bs := make([]byte, int(rr1.max-rr1.min)+len(r2.bs)-int(rr2.min+rr2.diff)) + copy(bs, r1.bs[rr1.min+rr1.diff:rr1.max+rr1.diff]) + copy(bs[rr1.max-rr1.min:], r2.bs[rr2.min+rr2.diff:]) + b.rrs[i-1] = readerRange{newBytesReader(bs), rr1.min, rr2.max, -rr1.min} + b.rrs = slices.Delete(b.rrs, i, i+1) + } + default: + if r1 == rr2.r && rr1.diff == rr2.diff && rr1.max == rr2.min { + b.rrs[i-1].max = rr2.max + b.rrs = slices.Delete(b.rrs, i, i+1) + } + } + } +} diff --git a/bed/buffer/buffer_test.go b/bed/buffer/buffer_test.go new file mode 100644 index 0000000..d830b6c --- /dev/null +++ b/bed/buffer/buffer_test.go @@ -0,0 +1,712 @@ +package buffer + +import ( + "io" + "math" + "reflect" + "slices" + "strings" + "testing" +) + +func TestBufferEmpty(t *testing.T) { + b := NewBuffer(strings.NewReader("")) + + p := make([]byte, 10) + n, err := b.Read(p) + if err != io.EOF { + t.Errorf("err should be EOF but got: %v", err) + } + if n != 0 { + t.Errorf("n should be 0 but got: %d", n) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != 0 { + t.Errorf("l should be 0 but got: %d", l) + } +} + +func TestBuffer(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + p := make([]byte, 8) + n, err := b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "01234567"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != 16 { + t.Errorf("l should be 16 but got: %d", l) + } + + _, err = b.Seek(4, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "456789ab"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + _, err = b.Seek(-4, io.SeekCurrent) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "89abcdef"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + _, err = b.Seek(-4, io.SeekEnd) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err = b.Read(p) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 4 { + t.Errorf("n should be 4 but got: %d", n) + } + if expected := "cdefcdef"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + n, err = b.ReadAt(p, 7) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if expected := "789abcde"; string(p) != expected { + t.Errorf("p should be %q but got: %s", expected, string(p)) + } + + n, err = b.ReadAt(p, -1) + if err == nil { + t.Errorf("err should not be nil but got: %v", err) + } + if n != 0 { + t.Errorf("n should be 0 but got: %d", n) + } +} + +func TestBufferClone(t *testing.T) { + b0 := NewBuffer(strings.NewReader("0123456789abcdef")) + b1 := b0.Clone() + + bufferEqual := func(b0 *Buffer, b1 *Buffer) bool { + if b0.index != b1.index || len(b0.rrs) != len(b1.rrs) { + return false + } + for i := range len(b0.rrs) { + if b0.rrs[i].min != b1.rrs[i].min || b0.rrs[i].max != b1.rrs[i].max || + b0.rrs[i].diff != b1.rrs[i].diff { + return false + } + switch r0 := b0.rrs[i].r.(type) { + case *bytesReader: + switch r1 := b1.rrs[i].r.(type) { + case *bytesReader: + if !reflect.DeepEqual(r0.bs, r1.bs) || r0.index != r1.index { + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + case *strings.Reader: + switch r1 := b1.rrs[i].r.(type) { + case *strings.Reader: + if r0 != r1 { + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", r0, r1) + return false + } + default: + t.Logf("buffer differs: %+v, %+v", b0.rrs[i].r, b1.rrs[i].r) + return false + } + } + return true + } + + if !bufferEqual(b1, b0) { + t.Errorf("Buffer#Clone should be %+v but got %+v", b0, b1) + } + + b1.Insert(4, 0x40) + if bufferEqual(b1, b0) { + t.Errorf("Buffer should not be equal: %+v, %+v", b0, b1) + } + + b2 := b1.Clone() + if !bufferEqual(b2, b1) { + t.Errorf("Buffer#Clone should be %+v but got %+v", b1, b2) + } + + b2.Replace(4, 0x40) + b2.Flush() + if !bufferEqual(b2, b1) { + t.Errorf("Buffer should be equal: %+v, %+v", b1, b2) + } + + b2.Replace(5, 0x40) + b2.Flush() + if bufferEqual(b2, b1) { + t.Errorf("Buffer should not be equal: %+v, %+v", b1, b2) + } +} + +func TestBufferCopy(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(3, 0x41) + b.Replace(4, 0x42) + b.Replace(5, 0x43) + b.Replace(9, 0x43) + b.Replace(10, 0x44) + b.Replace(11, 0x45) + b.Replace(12, 0x46) + b.Replace(14, 0x47) + testCases := []struct { + start, end int64 + expected string + }{ + {0, 16, "012ABC678CDEFdGf"}, + {0, 15, "012ABC678CDEFdG"}, + {1, 12, "12ABC678CDE"}, + {4, 14, "BC678CDEFd"}, + {2, 10, "2ABC678C"}, + {4, 10, "BC678C"}, + {2, 7, "2ABC6"}, + {5, 10, "C678C"}, + {7, 11, "78CD"}, + {8, 10, "8C"}, + {14, 20, "Gf"}, + {9, 9, ""}, + {10, 8, ""}, + } + for _, testCase := range testCases { + got := b.Copy(testCase.start, testCase.end) + p := make([]byte, 17) + _, _ = got.Read(p) + if !strings.HasPrefix(string(p), testCase.expected+"\x00") { + t.Errorf("Copy(%d, %d) should clone %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + got.Insert(0, 0x48) + got.Insert(int64(len(testCase.expected)+1), 0x49) + p = make([]byte, 19) + _, _ = got.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "H"+testCase.expected+"I\x00") { + t.Errorf("Copy(%d, %d) should clone %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + } +} + +func TestBufferCut(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(3, 0x41) + b.Replace(4, 0x42) + b.Replace(5, 0x43) + b.Replace(9, 0x43) + b.Replace(10, 0x44) + b.Replace(11, 0x45) + b.Replace(12, 0x46) + b.Replace(14, 0x47) + testCases := []struct { + start, end int64 + expected string + }{ + {0, 0, "012ABC678CDEFdGf"}, + {0, 4, "BC678CDEFdGf"}, + {0, 7, "78CDEFdGf"}, + {0, 10, "DEFdGf"}, + {0, 16, ""}, + {0, 20, ""}, + {3, 4, "012BC678CDEFdGf"}, + {3, 6, "012678CDEFdGf"}, + {3, 11, "012EFdGf"}, + {6, 10, "012ABCDEFdGf"}, + {6, 14, "012ABCGf"}, + {6, 15, "012ABCf"}, + {6, 17, "012ABC"}, + {8, 10, "012ABC67DEFdGf"}, + {8, 10, "012ABC67DEFdGf"}, + {10, 8, "012ABC678CDEFdGf"}, + } + for _, testCase := range testCases { + got := b.Clone() + got.Cut(testCase.start, testCase.end) + p := make([]byte, 17) + _, _ = got.Read(p) + if !strings.HasPrefix(string(p), testCase.expected+"\x00") { + t.Errorf("Cut(%d, %d) should result into %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + got.Insert(0, 0x48) + got.Insert(int64(len(testCase.expected)+1), 0x49) + p = make([]byte, 19) + _, _ = got.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "H"+testCase.expected+"I\x00") { + t.Errorf("Cut(%d, %d) should result into %q but got %q", + testCase.start, testCase.end, testCase.expected, string(p)) + } + } +} + +func TestBufferPaste(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + c := b.Copy(3, 13) + b.Paste(5, c) + p := make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected := "012343456789abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + c.Replace(5, 0x41) + c.Insert(6, 0x42) + c.Insert(7, 0x43) + b.Paste(10, c) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456734567ABC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + b.Cut(11, 14) + b.Paste(13, c) + b.Replace(13, 0x44) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456737AD4567ABC9abcBC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } + b.Insert(14, 0x45) + p = make([]byte, 100) + _, _ = b.ReadAt(p, 0) + expected = "012343456737ADE4567ABC9abcBC9abc89abc56789abcdef" + if !strings.HasPrefix(string(p), expected+"\x00") { + t.Errorf("p should be %q but got: %q", expected, string(p)) + } +} + +func TestBufferInsert(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {0, 0x39, 0, "90123456", 17}, + {0, 0x38, 0, "89012345", 18}, + {4, 0x37, 0, "89017234", 19}, + {8, 0x30, 3, "17234056", 20}, + {9, 0x31, 3, "17234015", 21}, + {9, 0x32, 4, "72340215", 22}, + {23, 0x39, 19, "def9\x00\x00\x00\x00", 23}, + {23, 0x38, 19, "def89\x00\x00\x00", 24}, + } + + for _, test := range tests { + b.Insert(test.index, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if expected := len(strings.TrimRight(test.expected, "\x00")); n != expected { + t.Errorf("n should be %d but got: %d", expected, n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 2, 4, 5, 8, 11, 23, 25}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 8 { + t.Errorf("len(b.rrs) should be 8 but got: %d", len(b.rrs)) + } +} + +func TestBufferReplace(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {0, 0x39, 0, "91234567", 16}, + {0, 0x38, 0, "81234567", 16}, + {1, 0x37, 0, "87234567", 16}, + {5, 0x30, 0, "87234067", 16}, + {4, 0x31, 0, "87231067", 16}, + {3, 0x30, 0, "87201067", 16}, + {2, 0x31, 0, "87101067", 16}, + {15, 0x30, 8, "89abcde0", 16}, + {16, 0x31, 9, "9abcde01", 17}, + {2, 0x39, 0, "87901067", 17}, + {17, 0x32, 10, "abcde012", 18}, + } + + for _, test := range tests { + b.Replace(test.index, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 6, 15, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 3 { + t.Errorf("len(b.rrs) should be 3 but got: %d", len(b.rrs)) + } + + { + b.Replace(3, 0x39) + b.Replace(4, 0x38) + b.Replace(5, 0x37) + b.Replace(6, 0x36) + b.Replace(7, 0x35) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99876589"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + b.UndoReplace(7) + b.UndoReplace(6) + p = make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99876789"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + b.UndoReplace(5) + b.UndoReplace(4) + b.Flush() + b.UndoReplace(3) + b.UndoReplace(2) + p = make([]byte, 8) + if _, err := b.ReadAt(p, 2); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "99106789"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + eis := b.EditedIndices() + if expected := []int64{0, 6, 15, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } + + { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.Replace(16, 0x30) + b.Replace(10, 0x30) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 9); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "90bcdef0"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + l, _ := b.Len() + if expected := int64(17); l != expected { + t.Errorf("l should be %d but got: %d", expected, l) + } + + eis := b.EditedIndices() + if expected := []int64{10, 11, 16, math.MaxInt64}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } +} + +func TestBufferReplaceIn(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + start int64 + end int64 + b byte + offset int64 + expected string + len int64 + }{ + {1, 2, 0x39, 0, "09234567", 16}, + {0, 6, 0x38, 0, "88888867", 16}, + {1, 3, 0x37, 0, "87788867", 16}, + {5, 7, 0x30, 0, "87788007", 16}, + {2, 6, 0x31, 0, "87111107", 16}, + {3, 4, 0x30, 0, "87101107", 16}, + {14, 15, 0x30, 8, "89abcd0f", 16}, + {15, 16, 0x30, 8, "89abcd00", 16}, + {1, 5, 0x39, 0, "89999107", 16}, + } + + for _, test := range tests { + b.ReplaceIn(test.start, test.end, test.b) + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if n != 8 { + t.Errorf("n should be 8 but got: %d", n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{0, 7, 14, 16}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if expected := 7; len(b.rrs) != expected { + t.Errorf("len(b.rrs) should be %d but got: %d", expected, len(b.rrs)) + } + + { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + b.ReplaceIn(16, 17, 0x30) + b.ReplaceIn(10, 11, 0x30) + p := make([]byte, 8) + if _, err := b.ReadAt(p, 9); err != io.EOF { + t.Errorf("err should be io.EOF but got: %v", err) + } + if expected := "90bcdef0"; string(p) != expected { + t.Errorf("p should be %s but got: %s", expected, string(p)) + } + + l, _ := b.Len() + if expected := int64(16); l != expected { + t.Errorf("l should be %d but got: %d", expected, l) + } + + eis := b.EditedIndices() + if expected := []int64{10, 11, 16, 17}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + } +} + +func TestBufferDelete(t *testing.T) { + b := NewBuffer(strings.NewReader("0123456789abcdef")) + + tests := []struct { + index int64 + b byte + offset int64 + expected string + len int64 + }{ + {4, 0x00, 0, "01235678", 15}, + {3, 0x00, 0, "01256789", 14}, + {6, 0x00, 0, "0125679a", 13}, + {0, 0x00, 0, "125679ab", 12}, + {4, 0x39, 0, "1256979a", 13}, + {5, 0x38, 0, "12569879", 14}, + {3, 0x00, 0, "1259879a", 13}, + {4, 0x00, 0, "125979ab", 12}, + {3, 0x00, 0, "12579abc", 11}, + {8, 0x39, 4, "9abc9def", 12}, + {8, 0x38, 4, "9abc89de", 13}, + {8, 0x00, 4, "9abc9def", 12}, + {8, 0x00, 4, "9abcdef\x00", 11}, + } + + for _, test := range tests { + if test.b == 0x00 { + b.Delete(test.index) + } else { + b.Insert(test.index, test.b) + } + p := make([]byte, 8) + + _, err := b.Seek(test.offset, io.SeekStart) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + + n, err := b.Read(p) + if err != nil && err != io.EOF { + t.Errorf("err should be nil or io.EOF but got: %v", err) + } + if expected := len(strings.TrimRight(test.expected, "\x00")); n != expected { + t.Errorf("n should be %d but got: %d", expected, n) + } + if string(p) != test.expected { + t.Errorf("p should be %s but got: %s", test.expected, string(p)) + } + + l, err := b.Len() + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if l != test.len { + t.Errorf("l should be %d but got: %d", test.len, l) + } + } + + eis := b.EditedIndices() + if expected := []int64{}; !reflect.DeepEqual(eis, expected) { + t.Errorf("edited indices should be %v but got: %v", expected, eis) + } + + if len(b.rrs) != 4 { + t.Errorf("len(b.rrs) should be 4 but got: %d", len(b.rrs)) + } +} + +func TestInsertInterval(t *testing.T) { + tests := []struct { + intervals []int64 + newInterval []int64 + expected []int64 + }{ + {[]int64{}, []int64{10, 20}, []int64{10, 20}}, + {[]int64{10, 20}, []int64{0, 5}, []int64{0, 5, 10, 20}}, + {[]int64{10, 20}, []int64{5, 15}, []int64{5, 20}}, + {[]int64{10, 20}, []int64{15, 17}, []int64{10, 20}}, + {[]int64{10, 20}, []int64{15, 25}, []int64{10, 25}}, + {[]int64{10, 20}, []int64{25, 30}, []int64{10, 20, 25, 30}}, + {[]int64{10, 20, 30, 40}, []int64{0, 5}, []int64{0, 5, 10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 10}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 15}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 20}, []int64{5, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 25}, []int64{5, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 30}, []int64{5, 40}}, + {[]int64{10, 20, 30, 40}, []int64{5, 45}, []int64{5, 45}}, + {[]int64{10, 20, 30, 40}, []int64{10, 20}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{10, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 45}, []int64{10, 45}}, + {[]int64{10, 20, 30, 40}, []int64{15, 25}, []int64{10, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{15, 35}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{20, 25}, []int64{10, 25, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{20, 30}, []int64{10, 40}}, + {[]int64{10, 20, 30, 40}, []int64{25, 30}, []int64{10, 20, 25, 40}}, + {[]int64{10, 20, 30, 40}, []int64{30, 30}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{35, 37}, []int64{10, 20, 30, 40}}, + {[]int64{10, 20, 30, 40}, []int64{40, 50}, []int64{10, 20, 30, 50}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{45, 47}, []int64{10, 20, 30, 40, 45, 47, 50, 60, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{35, 65}, []int64{10, 20, 30, 65, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{25, 55}, []int64{10, 20, 25, 60, 70, 80}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{75, 90}, []int64{10, 20, 30, 40, 50, 60, 70, 90}}, + {[]int64{10, 20, 30, 40, 50, 60, 70, 80}, []int64{0, 100}, []int64{0, 100}}, + } + for _, test := range tests { + got := insertInterval(slices.Clone(test.intervals), test.newInterval[0], test.newInterval[1]) + if !reflect.DeepEqual(got, test.expected) { + t.Errorf("insertInterval(%+v, %d, %d) should be %+v but got: %+v", + test.intervals, test.newInterval[0], test.newInterval[1], test.expected, got) + } + } +} diff --git a/bed/buffer/bytes.go b/bed/buffer/bytes.go new file mode 100644 index 0000000..c181946 --- /dev/null +++ b/bed/buffer/bytes.go @@ -0,0 +1,66 @@ +package buffer + +import ( + "errors" + "io" + "slices" +) + +type bytesReader struct { + bs []byte + index int64 +} + +func newBytesReader(bs []byte) *bytesReader { + return &bytesReader{bs: bs, index: 0} +} + +// Read implements the io.Reader interface. +func (r *bytesReader) Read(b []byte) (n int, err error) { + if r.index >= int64(len(r.bs)) { + return 0, io.EOF + } + n = copy(b, r.bs[r.index:]) + r.index += int64(n) + return +} + +// Seek implements the io.Seeker interface. +func (r *bytesReader) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + r.index = offset + case io.SeekCurrent: + r.index += offset + case io.SeekEnd: + r.index = int64(len(r.bs)) + offset + } + return r.index, nil +} + +// ReadAt implements the io.ReaderAt interface. +func (r *bytesReader) ReadAt(b []byte, offset int64) (n int, err error) { + if offset < 0 { + return 0, errors.New("buffer.bytesReader.ReadAt: negative offset") + } + if offset >= int64(len(r.bs)) { + return 0, io.EOF + } + n = copy(b, r.bs[offset:]) + if n < len(b) { + err = io.EOF + } + return +} + +func (r *bytesReader) insert(offset int64, b byte) { + r.bs = slices.Insert(r.bs, int(offset), b) +} + +func (r *bytesReader) delete(offset int64) { + r.bs = slices.Delete(r.bs, int(offset), int(offset+1)) +} + +func (r *bytesReader) clone() *bytesReader { + return newBytesReader(slices.Clone(r.bs)) +} diff --git a/bed/buffer/const.go b/bed/buffer/const.go new file mode 100644 index 0000000..7955ec6 --- /dev/null +++ b/bed/buffer/const.go @@ -0,0 +1,21 @@ +package buffer + +type constReader byte + +// Read implements the io.Reader interface. +func (r constReader) Read(b []byte) (int, error) { + for i := range b { + b[i] = byte(r) + } + return len(b), nil +} + +// Seek implements the io.Seeker interface. +func (constReader) Seek(int64, int) (int64, error) { + return 0, nil +} + +// ReadAt implements the io.ReaderAt interface. +func (r constReader) ReadAt(b []byte, _ int64) (int, error) { + return r.Read(b) +} diff --git a/bed/cmd/bed/bed.go b/bed/cmd/bed/bed.go new file mode 100644 index 0000000..9b034af --- /dev/null +++ b/bed/cmd/bed/bed.go @@ -0,0 +1,75 @@ +package bed + +import ( + "fmt" + "os" + "runtime" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "b612.me/apps/b612/bed/cmdline" + "b612.me/apps/b612/bed/editor" + "b612.me/apps/b612/bed/tui" + "b612.me/apps/b612/bed/window" +) + +const ( + name = "bed" + version = "0.2.8" + revision = "HEAD" +) + +var Cmd = &cobra.Command{ + Use: "bed [文件路径]", + Short: "基于 Go 开发的二进制文件编辑器", + Long: `二进制文件编辑器 bed - 支持直接编辑二进制文件的命令行工具 + +支持功能: + - 十六进制查看/编辑 + - 文件差异对比 + - 多窗口操作 + - 快速跳转地址`, + Version: fmt.Sprintf("%s (修订版本: %s/%s)", version, revision, runtime.Version()), + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runEditor(args) + }, +} + +func init() { + Cmd.SetVersionTemplate(`{{printf "%s 版本信息:" .Name}}{{.Version}}` + "\n") + Cmd.Flags().BoolP("version", "v", false, "显示版本信息") +} + +func runEditor(args []string) error { + editor := editor.NewEditor( + tui.NewTui(), + window.NewManager(), + cmdline.NewCmdline(), + ) + + if err := editor.Init(); err != nil { + return fmt.Errorf("编辑器初始化失败: %w", err) + } + + switch { + case len(args) > 0 && args[0] != "-": // 处理文件参数 + if err := editor.Open(args[0]); err != nil { + return fmt.Errorf("无法打开文件: %w", err) + } + case term.IsTerminal(int(os.Stdin.Fd())): // 交互模式 + if err := editor.OpenEmpty(); err != nil { + return fmt.Errorf("创建空白文档失败: %w", err) + } + default: // 从标准输入读取 + if err := editor.Read(os.Stdin); err != nil { + return fmt.Errorf("读取输入流失败: %w", err) + } + } + + defer editor.Close() + return editor.Run() +} diff --git a/bed/cmdline/cmdline.go b/bed/cmdline/cmdline.go new file mode 100644 index 0000000..7e1036d --- /dev/null +++ b/bed/cmdline/cmdline.go @@ -0,0 +1,244 @@ +package cmdline + +import ( + "slices" + "sync" + "unicode" + + "b612.me/apps/b612/bed/event" +) + +// Cmdline implements editor.Cmdline +type Cmdline struct { + cmdline []rune + cursor int + completor *completor + typ rune + historyIndex int + history []string + histories map[bool][]string + eventCh chan<- event.Event + cmdlineCh <-chan event.Event + redrawCh chan<- struct{} + mu *sync.Mutex +} + +// NewCmdline creates a new Cmdline. +func NewCmdline() *Cmdline { + return &Cmdline{ + completor: newCompletor(&filesystem{}, &environment{}), + histories: map[bool][]string{false: {}, true: {}}, + mu: new(sync.Mutex), + } +} + +// Init initializes the Cmdline. +func (c *Cmdline) Init(eventCh chan<- event.Event, cmdlineCh <-chan event.Event, redrawCh chan<- struct{}) { + c.eventCh, c.cmdlineCh, c.redrawCh = eventCh, cmdlineCh, redrawCh +} + +// Run the cmdline. +func (c *Cmdline) Run() { + for e := range c.cmdlineCh { + c.mu.Lock() + switch e.Type { + case event.StartCmdlineCommand: + c.start(':', e.Arg) + case event.StartCmdlineSearchForward: + c.start('/', "") + case event.StartCmdlineSearchBackward: + c.start('?', "") + case event.ExitCmdline: + c.clear() + case event.CursorUp: + c.cursorUp() + case event.CursorDown: + c.cursorDown() + case event.CursorLeft: + c.cursorLeft() + case event.CursorRight: + c.cursorRight() + case event.CursorHead: + c.cursorHead() + case event.CursorEnd: + c.cursorEnd() + case event.BackspaceCmdline: + c.backspace() + case event.DeleteCmdline: + c.deleteRune() + case event.DeleteWordCmdline: + c.deleteWord() + case event.ClearToHeadCmdline: + c.clearToHead() + case event.ClearCmdline: + c.clear() + case event.Rune: + c.insert(e.Rune) + case event.CompleteForwardCmdline: + c.complete(true) + c.redrawCh <- struct{}{} + c.mu.Unlock() + continue + case event.CompleteBackCmdline: + c.complete(false) + c.redrawCh <- struct{}{} + c.mu.Unlock() + continue + case event.ExecuteCmdline: + if c.execute() { + c.mu.Unlock() + continue + } + default: + c.mu.Unlock() + continue + } + c.completor.clear() + c.mu.Unlock() + c.redrawCh <- struct{}{} + } +} + +func (c *Cmdline) cursorUp() { + if c.historyIndex--; c.historyIndex >= 0 { + c.cmdline = []rune(c.history[c.historyIndex]) + c.cursor = len(c.cmdline) + } else { + c.clear() + c.historyIndex = -1 + } +} + +func (c *Cmdline) cursorDown() { + if c.historyIndex++; c.historyIndex < len(c.history) { + c.cmdline = []rune(c.history[c.historyIndex]) + c.cursor = len(c.cmdline) + } else { + c.clear() + c.historyIndex = len(c.history) + } +} + +func (c *Cmdline) cursorLeft() { + c.cursor = max(0, c.cursor-1) +} + +func (c *Cmdline) cursorRight() { + c.cursor = min(len(c.cmdline), c.cursor+1) +} + +func (c *Cmdline) cursorHead() { + c.cursor = 0 +} + +func (c *Cmdline) cursorEnd() { + c.cursor = len(c.cmdline) +} + +func (c *Cmdline) backspace() { + if c.cursor > 0 { + c.cmdline = slices.Delete(c.cmdline, c.cursor-1, c.cursor) + c.cursor-- + return + } + if len(c.cmdline) == 0 { + c.eventCh <- event.Event{Type: event.ExitCmdline} + } +} + +func (c *Cmdline) deleteRune() { + if c.cursor < len(c.cmdline) { + c.cmdline = slices.Delete(c.cmdline, c.cursor, c.cursor+1) + } +} + +func (c *Cmdline) deleteWord() { + i := c.cursor + for i > 0 && unicode.IsSpace(c.cmdline[i-1]) { + i-- + } + if i > 0 { + isk := isKeyword(c.cmdline[i-1]) + for i > 0 && isKeyword(c.cmdline[i-1]) == isk && !unicode.IsSpace(c.cmdline[i-1]) { + i-- + } + } + c.cmdline = slices.Delete(c.cmdline, i, c.cursor) + c.cursor = i +} + +func isKeyword(c rune) bool { + return unicode.IsDigit(c) || unicode.IsLetter(c) || c == '_' +} + +func (c *Cmdline) start(typ rune, arg string) { + c.typ = typ + c.cmdline = []rune(arg) + c.cursor = len(c.cmdline) + c.history = c.histories[typ == ':'] + c.historyIndex = len(c.history) +} + +func (c *Cmdline) clear() { + c.cmdline = []rune{} + c.cursor = 0 +} + +func (c *Cmdline) clearToHead() { + c.cmdline = slices.Delete(c.cmdline, 0, c.cursor) + c.cursor = 0 +} + +func (c *Cmdline) insert(ch rune) { + if unicode.IsPrint(ch) { + c.cmdline = slices.Insert(c.cmdline, c.cursor, ch) + c.cursor++ + } +} + +func (c *Cmdline) complete(forward bool) { + c.cmdline = []rune(c.completor.complete(string(c.cmdline), forward)) + c.cursor = len(c.cmdline) +} + +func (c *Cmdline) execute() (finish bool) { + defer c.saveHistory() + switch c.typ { + case ':': + cmd, r, bang, _, _, arg, err := parse(string(c.cmdline)) + if err != nil { + c.eventCh <- event.Event{Type: event.Error, Error: err} + } else if cmd.name != "" { + c.eventCh <- event.Event{Type: cmd.eventType, Range: r, CmdName: cmd.name, Bang: bang, Arg: arg} + finish = cmd.eventType == event.QuitAll || cmd.eventType == event.QuitErr + } + case '/': + c.eventCh <- event.Event{Type: event.ExecuteSearch, Arg: string(c.cmdline), Rune: '/'} + case '?': + c.eventCh <- event.Event{Type: event.ExecuteSearch, Arg: string(c.cmdline), Rune: '?'} + default: + panic("cmdline.Cmdline.execute: unreachable") + } + return +} + +func (c *Cmdline) saveHistory() { + cmdline := string(c.cmdline) + if cmdline == "" { + return + } + for i, h := range c.history { + if h == cmdline { + c.history = slices.Delete(c.history, i, i+1) + break + } + } + c.histories[c.typ == ':'] = append(c.history, cmdline) +} + +// Get returns the current state of cmdline. +func (c *Cmdline) Get() ([]rune, int, []string, int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.cmdline, c.cursor, c.completor.results, c.completor.index +} diff --git a/bed/cmdline/cmdline_test.go b/bed/cmdline/cmdline_test.go new file mode 100644 index 0000000..a84349c --- /dev/null +++ b/bed/cmdline/cmdline_test.go @@ -0,0 +1,832 @@ +package cmdline + +import ( + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/event" +) + +func TestNewCmdline(t *testing.T) { + c := NewCmdline() + cmdline, cursor, _, _ := c.Get() + if len(cmdline) != 0 { + t.Errorf("cmdline should be empty but got %v", cmdline) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineRun(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + go c.Run() + events := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.CursorLeft}, + {Type: event.CursorRight}, + {Type: event.CursorHead}, + {Type: event.CursorEnd}, + {Type: event.BackspaceCmdline}, + {Type: event.DeleteCmdline}, + {Type: event.DeleteWordCmdline}, + {Type: event.ClearToHeadCmdline}, + {Type: event.ClearCmdline}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.ExecuteCmdline}, + {Type: event.StartCmdlineCommand}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, e := range events { + cmdlineCh <- e + } + }() + for range len(events) - 3 { + <-redrawCh + } + e := <-eventCh + if e.Type != event.Error { + t.Errorf("cmdline should emit Error event but got %v", e) + } + cmdline, cursor, _, _ := c.Get() + if expected := "te"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 2 { + t.Errorf("cursor should be 2 but got %v", cursor) + } + for range 3 { + <-redrawCh + } + cmdline, _, _, _ = c.Get() + if expected := ""; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } +} + +func TestCmdlineCursorMotion(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + _, cursor, _, _ = c.Get() + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } + + for range 10 { + c.cursorLeft() + } + _, cursor, _, _ = c.Get() + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + c.cursorRight() + _, cursor, _, _ = c.Get() + if cursor != 1 { + t.Errorf("cursor should be 1 but got %v", cursor) + } + + for range 10 { + c.cursorRight() + } + _, cursor, _, _ = c.Get() + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorHead() + _, cursor, _, _ = c.Get() + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + c.cursorEnd() + _, cursor, _, _ = c.Get() + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } +} + +func TestCmdlineCursorBackspaceDelete(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.backspace() + + cmdline, cursor, _, _ = c.Get() + if expected := "abce"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.deleteRune() + + cmdline, cursor, _, _ = c.Get() + if expected := "abc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.deleteRune() + + cmdline, cursor, _, _ = c.Get() + if expected := "abc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } + + c.cursorLeft() + c.cursorLeft() + c.backspace() + c.backspace() + + cmdline, cursor, _, _ = c.Get() + if expected := "bc"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorDeleteWord(t *testing.T) { + c := NewCmdline() + for _, ch := range "abcde" { + c.insert(ch) + } + + c.cursorLeft() + c.cursorLeft() + c.deleteWord() + + cmdline, cursor, _, _ := c.Get() + if expected := "de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } + + for _, ch := range "x0z!123 " { + c.insert(ch) + } + c.cursorLeft() + c.deleteWord() + + cmdline, cursor, _, _ = c.Get() + if expected := "x0z! de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } + + c.deleteWord() + + cmdline, cursor, _, _ = c.Get() + if expected := "x0z de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 3 { + t.Errorf("cursor should be 3 but got %v", cursor) + } +} + +func TestCmdlineCursorClear(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.clear() + + cmdline, cursor, _, _ = c.Get() + if expected := ""; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorClearToHead(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + cmdline, cursor, _, _ := c.Get() + if expected := "abcde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 5 { + t.Errorf("cursor should be 5 but got %v", cursor) + } + + c.cursorLeft() + c.cursorLeft() + c.clearToHead() + + cmdline, cursor, _, _ = c.Get() + if expected := "de"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 0 { + t.Errorf("cursor should be 0 but got %v", cursor) + } +} + +func TestCmdlineCursorInsert(t *testing.T) { + c := NewCmdline() + + for _, ch := range "abcde" { + c.insert(ch) + } + + c.cursorLeft() + c.cursorLeft() + c.backspace() + c.insert('x') + c.insert('y') + + cmdline, cursor, _, _ := c.Get() + if expected := "abxyde"; string(cmdline) != expected { + t.Errorf("cmdline should be %q but got %q", expected, string(cmdline)) + } + if cursor != 4 { + t.Errorf("cursor should be 4 but got %v", cursor) + } +} + +func TestCmdlineQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"exi", "exi[t]"}, + {"quit", "q[uit]"}, + {"q", "q[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Quit { + t.Errorf("cmdline should emit quit event with %q", cmd.cmd) + } + if e.Bang { + t.Errorf("cmdline should emit quit event without bang") + } + } +} + +func TestCmdlineForceQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"exit!", "exi[t]"}, + {"q!", "q[uit]"}, + {"quit!", "q[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Quit { + t.Errorf("cmdline should emit quit event with %q", cmd.cmd) + } + if !e.Bang { + t.Errorf("cmdline should emit quit event with bang") + } + } +} + +func TestCmdlineExecuteQuitAll(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"qall", "qa[ll]"}, + {"qa", "qa[ll]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.QuitAll { + t.Errorf("cmdline should emit QuitAll event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteQuitErr(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"cquit", "cq[uit]"}, + {"cq", "cq[uit]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.QuitErr { + t.Errorf("cmdline should emit QuitErr event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteWrite(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"w", "w[rite]"}, + {" : : write sample.txt", "w[rite]"}, + {"'<,'>write sample.txt", "w[rite]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.Write { + t.Errorf("cmdline should emit Write event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteWriteQuit(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + name string + }{ + {"wq", "wq"}, + {"x", "x[it]"}, + {"xit", "x[it]"}, + {"xa", "xa[ll]"}, + {"xall", "xa[ll]"}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + if e.CmdName != cmd.name { + t.Errorf("cmdline should report command name %q but got %q", cmd.name, e.CmdName) + } + if e.Type != event.WriteQuit { + t.Errorf("cmdline should emit WriteQuit event with %q", cmd.cmd) + } + } +} + +func TestCmdlineExecuteGoto(t *testing.T) { + c := NewCmdline() + ch := make(chan event.Event, 1) + c.Init(ch, make(chan event.Event), make(chan struct{})) + for _, cmd := range []struct { + cmd string + pos event.Position + typ event.Type + }{ + {" : : $ ", event.End{}, event.CursorGoto}, + {" :123456789 ", event.Absolute{Offset: 123456789}, event.CursorGoto}, + {" +16777216 ", event.Relative{Offset: 16777216}, event.CursorGoto}, + {" -256 ", event.Relative{Offset: -256}, event.CursorGoto}, + {" : 0x123456789abcdef ", event.Absolute{Offset: 0x123456789abcdef}, event.CursorGoto}, + {" 0xfedcba ", event.Absolute{Offset: 0xfedcba}, event.CursorGoto}, + {" +0x44ef ", event.Relative{Offset: 0x44ef}, event.CursorGoto}, + {" -0xff ", event.Relative{Offset: -0xff}, event.CursorGoto}, + {"10go", event.Absolute{Offset: 10}, event.CursorGoto}, + {"+10 got", event.Relative{Offset: 10}, event.CursorGoto}, + {"$-10 goto", event.End{Offset: -10}, event.CursorGoto}, + {"10%", event.Absolute{Offset: 10}, event.CursorGoto}, + {"+10%", event.Relative{Offset: 10}, event.CursorGoto}, + {"$-10%", event.End{Offset: -10}, event.CursorGoto}, + } { + c.clear() + c.cmdline = []rune(cmd.cmd) + c.typ = ':' + c.execute() + e := <-ch + expected := "goto" + if strings.HasSuffix(cmd.cmd, "%") { + expected = "%" + } else if strings.Contains(cmd.cmd, "go") { + expected = "go[to]" + } + if e.CmdName != expected { + t.Errorf("cmdline should report command name %q but got %q", expected, e.CmdName) + } + if !reflect.DeepEqual(e.Range.From, cmd.pos) { + t.Errorf("cmdline should report command with position %#v but got %#v", cmd.pos, e.Range.From) + } + if e.Type != cmd.typ { + t.Errorf("cmdline should emit %d but got %d with %q", cmd.typ, e.Type, cmd.cmd) + } + } +} + +func TestCmdlineComplete(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := NewCmdline() + c.completor = newCompletor(&mockFilesystem{}, nil) + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + waitCh := make(chan struct{}) + go c.Run() + go func() { + cmdlineCh <- event.Event{Type: event.StartCmdlineCommand} + cmdlineCh <- event.Event{Type: event.Rune, Rune: 'e'} + cmdlineCh <- event.Event{Type: event.Rune, Rune: ' '} + cmdlineCh <- event.Event{Type: event.Rune, Rune: '/'} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CompleteBackCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.CursorEnd} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + cmdlineCh <- event.Event{Type: event.CompleteForwardCmdline} + <-waitCh + cmdlineCh <- event.Event{Type: event.ExecuteCmdline} + }() + for range 5 { + <-redrawCh + } + cmdline, cursor, _, _ := c.Get() + if expected := "e /bin/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /tmp/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 7 { + t.Errorf("cursor should be 7 but got %v", cursor) + } + waitCh <- struct{}{} + <-redrawCh + <-redrawCh + <-redrawCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/echo"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 11 { + t.Errorf("cursor should be 11 but got %v", cursor) + } + waitCh <- struct{}{} + go func() { <-redrawCh }() + e := <-eventCh + cmdline, cursor, _, _ = c.Get() + if expected := "e /bin/echo"; string(cmdline) != expected { + t.Errorf("cmdline should be %q got %q", expected, string(cmdline)) + } + if cursor != 11 { + t.Errorf("cursor should be 11 but got %v", cursor) + } + if e.Type != event.Edit { + t.Errorf("cmdline should emit Edit event but got %v", e) + } + if expected := "/bin/echo"; e.Arg != expected { + t.Errorf("cmdline should emit event with arg %q but got %v", expected, e) + } +} + +func TestCmdlineSearch(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + waitCh := make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + defer func() { + close(eventCh) + close(cmdlineCh) + close(redrawCh) + }() + go c.Run() + events1 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 't'}, + {Type: event.CursorLeft}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 's'}, + {Type: event.ExecuteCmdline}, + } + events2 := []event.Event{ + {Type: event.StartCmdlineSearchBackward}, + {Type: event.Rune, Rune: 'x'}, + {Type: event.Rune, Rune: 'y'}, + {Type: event.Rune, Rune: 'z'}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, e := range events1 { + cmdlineCh <- e + } + <-waitCh + for _, e := range events2 { + cmdlineCh <- e + } + }() + for range len(events1) - 1 { + <-redrawCh + } + e := <-eventCh + <-redrawCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + if e.Rune != '/' { + t.Errorf("cmdline should emit search event with Rune %q but got %q", '/', e.Rune) + } + waitCh <- struct{}{} + for range len(events2) - 1 { + <-redrawCh + } + e = <-eventCh + <-redrawCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "xyz"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + if e.Rune != '?' { + t.Errorf("cmdline should emit search event with Rune %q but got %q", '?', e.Rune) + } +} + +func TestCmdlineHistory(t *testing.T) { + c := NewCmdline() + eventCh, cmdlineCh, redrawCh := make(chan event.Event), make(chan event.Event), make(chan struct{}) + c.Init(eventCh, cmdlineCh, redrawCh) + go c.Run() + events0 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events1 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.Rune, Rune: 'v'}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events2 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events3 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.CursorDown}, + {Type: event.ExecuteCmdline}, + } + events4 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events5 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.Rune, Rune: 't'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 's'}, + {Type: event.Rune, Rune: 't'}, + {Type: event.ExecuteCmdline}, + } + events6 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.CursorUp}, + {Type: event.CursorDown}, + {Type: event.Rune, Rune: 'n'}, + {Type: event.Rune, Rune: 'e'}, + {Type: event.Rune, Rune: 'w'}, + {Type: event.ExecuteCmdline}, + } + events7 := []event.Event{ + {Type: event.StartCmdlineSearchBackward}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events8 := []event.Event{ + {Type: event.StartCmdlineCommand}, + {Type: event.CursorUp}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + events9 := []event.Event{ + {Type: event.StartCmdlineSearchForward}, + {Type: event.CursorUp}, + {Type: event.ExecuteCmdline}, + } + go func() { + for _, events := range [][]event.Event{ + events0, events1, events2, events3, events4, + events5, events6, events7, events8, events9, + } { + for _, e := range events { + cmdlineCh <- e + } + } + }() + for range len(events0) - 1 { + <-redrawCh + } + e := <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e) + } + for range len(events1) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e) + } + for range len(events2) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e) + } + for range len(events3) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e.Type) + } + for range len(events4) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.New { + t.Errorf("cmdline should emit New event but got %v", e.Type) + } + for range len(events5) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events6) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "new"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events7) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + for range len(events8) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.Vnew { + t.Errorf("cmdline should emit Vnew event but got %v", e.Type) + } + for range len(events9) { + <-redrawCh + } + e = <-eventCh + if e.Type != event.ExecuteSearch { + t.Errorf("cmdline should emit ExecuteSearch event but got %v", e) + } + if expected := "test"; e.Arg != expected { + t.Errorf("cmdline should emit search event with Arg %q but got %q", expected, e.Arg) + } + <-redrawCh +} diff --git a/bed/cmdline/command.go b/bed/cmdline/command.go new file mode 100644 index 0000000..64be297 --- /dev/null +++ b/bed/cmdline/command.go @@ -0,0 +1,57 @@ +package cmdline + +import "b612.me/apps/b612/bed/event" + +type command struct { + name string + fullname string + eventType event.Type + rangeType rangeType +} + +type rangeType int + +const ( + rangeEmpty rangeType = 1 << iota + rangeCount + rangeBoth +) + +func (rt rangeType) allows(r *event.Range) bool { + switch { + case r == nil: + return rt&rangeEmpty != 0 + case r.To == nil: + return rt&rangeCount != 0 + default: + return rt&rangeBoth != 0 + } +} + +var commands = []command{ + {"e[dit]", "edit", event.Edit, rangeEmpty}, + {"ene[w]", "enew", event.Enew, rangeEmpty}, + {"new", "new", event.New, rangeEmpty}, + {"vne[w]", "vnew", event.Vnew, rangeEmpty}, + {"on[ly]", "only", event.Only, rangeEmpty}, + {"winc[md]", "wincmd", event.Wincmd, rangeEmpty}, + + {"go[to]", "goto", event.CursorGoto, rangeCount}, + {"%", "%", event.CursorGoto, rangeCount}, + + {"u[ndo]", "undo", event.Undo, rangeEmpty}, + {"red[o]", "redo", event.Redo, rangeEmpty}, + + {"pw[d]", "pwd", event.Pwd, rangeEmpty}, + {"cd", "cd", event.Chdir, rangeEmpty}, + {"chd[ir]", "chdir", event.Chdir, rangeEmpty}, + {"exi[t]", "exit", event.Quit, rangeEmpty}, + {"q[uit]", "quit", event.Quit, rangeEmpty}, + {"qa[ll]", "qall", event.QuitAll, rangeEmpty}, + {"quita[ll]", "quitall", event.QuitAll, rangeEmpty}, + {"cq[uit]", "cquit", event.QuitErr, rangeEmpty}, + {"w[rite]", "write", event.Write, rangeEmpty | rangeBoth}, + {"wq", "wq", event.WriteQuit, rangeEmpty | rangeBoth}, + {"x[it]", "xit", event.WriteQuit, rangeEmpty | rangeBoth}, + {"xa[ll]", "xall", event.WriteQuit, rangeEmpty | rangeBoth}, +} diff --git a/bed/cmdline/completor.go b/bed/cmdline/completor.go new file mode 100644 index 0000000..54f510d --- /dev/null +++ b/bed/cmdline/completor.go @@ -0,0 +1,266 @@ +package cmdline + +import ( + "os" + "path/filepath" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "b612.me/apps/b612/bed/event" +) + +type completor struct { + fs fs + env env + command bool + target string + arg string + results []string + index int +} + +func newCompletor(fs fs, env env) *completor { + return &completor{fs: fs, env: env} +} + +func (c *completor) complete(cmdline string, forward bool) string { + cmd, r, _, name, prefix, arg, _ := parse(cmdline) + if name == "" || c.command || + !hasSuffixFunc(prefix, unicode.IsSpace) && cmd.fullname != name { + cmdline = c.completeCommand(cmdline, name, prefix, r, forward) + if c.results != nil { + return cmdline + } + prefix = cmdline + } + switch cmd.eventType { + case event.Edit, event.New, event.Vnew, event.Write, event.WriteQuit: + return c.completeFilepath(cmdline, prefix, arg, forward, false) + case event.Chdir: + return c.completeFilepath(cmdline, prefix, arg, forward, true) + case event.Wincmd: + return c.completeWincmd(cmdline, prefix, arg, forward) + default: + return cmdline + } +} + +func (c *completor) completeNext(prefix string, forward bool) string { + if len(c.results) == 0 { + return c.target + } + if forward { + c.index = (c.index+2)%(len(c.results)+1) - 1 + } else { + c.index = (c.index+len(c.results)+1)%(len(c.results)+1) - 1 + } + if c.index < 0 { + return c.target + } + if len(c.results) == 1 { + defer c.clear() + } + return prefix + c.arg + c.results[c.index] +} + +func (c *completor) completeCommand( + cmdline, name, prefix string, r *event.Range, forward bool, +) string { + prefix = prefix[:len(prefix)-len(name)] + if c.results == nil { + c.command, c.target, c.index = true, cmdline, -1 + c.arg, c.results = "", listCommandNames(name, r) + } + return c.completeNext(prefix, forward) +} + +func listCommandNames(name string, r *event.Range) []string { + var targets []string + for _, cmd := range commands { + if strings.HasPrefix(cmd.fullname, name) && cmd.rangeType.allows(r) { + targets = append(targets, cmd.fullname) + } + } + slices.Sort(targets) + return targets +} + +func (c *completor) completeFilepath( + cmdline, prefix, arg string, forward, dirOnly bool, +) string { + if !hasSuffixFunc(prefix, unicode.IsSpace) { + prefix += " " + } + if c.results == nil { + c.command, c.target, c.index = false, cmdline, -1 + c.arg, c.results = c.listFileNames(arg, dirOnly) + } + return c.completeNext(prefix, forward) +} + +const separator = string(filepath.Separator) + +func (c *completor) listFileNames(arg string, dirOnly bool) (string, []string) { + var targets []string + path, simplify := c.expandPath(arg) + if strings.HasPrefix(arg, "$") && !strings.Contains(arg, separator) { + base := strings.ToLower(arg[1:]) + for _, env := range c.env.List() { + name, value, ok := strings.Cut(env, "=") + if !ok { + continue + } + if !strings.HasPrefix(strings.ToLower(name), base) { + continue + } + if !filepath.IsAbs(value) { + continue + } + fi, err := c.fs.Stat(value) + if err != nil { + continue + } + if fi.IsDir() { + name += separator + } else if dirOnly { + continue + } + targets = append(targets, "$"+name) + } + slices.Sort(targets) + return "", targets + } + if arg != "" && !strings.HasSuffix(arg, separator) && + (!strings.HasSuffix(arg, ".") || strings.HasSuffix(arg, "..")) { + if stat, err := c.fs.Stat(path); err == nil && stat.IsDir() { + return "", []string{arg + separator} + } + } + if strings.HasSuffix(arg, separator) || strings.HasSuffix(arg, separator+".") { + path += separator + } + dir, base := filepath.Dir(path), strings.ToLower(filepath.Base(path)) + if arg == "" { + base = "" + } else if strings.HasSuffix(path, separator) { + if strings.HasSuffix(arg, separator+".") { + base = "." + } else { + base = "" + } + } + f, err := c.fs.Open(dir) + if err != nil { + return arg, nil + } + defer f.Close() + fileInfos, err := f.Readdir(1024) + if err != nil { + return arg, nil + } + for _, fileInfo := range fileInfos { + name := fileInfo.Name() + if !strings.HasPrefix(strings.ToLower(name), base) { + continue + } + isDir := fileInfo.IsDir() + if !isDir && fileInfo.Mode()&os.ModeSymlink != 0 { + fileInfo, err := c.fs.Stat(filepath.Join(dir, name)) + if err != nil { + continue + } + isDir = fileInfo.IsDir() + } + if isDir { + name += separator + } else if dirOnly { + continue + } + targets = append(targets, name) + } + slices.SortFunc(targets, func(p, q string) int { + ps, pd := p[len(p)-1] == filepath.Separator, p[0] == '.' + qs, qd := q[len(q)-1] == filepath.Separator, q[0] == '.' + switch { + case ps && !qs: + return 1 + case !ps && qs: + return -1 + case pd && !qd: + return 1 + case !pd && qd: + return -1 + default: + return strings.Compare(p, q) + } + }) + if simplify != nil { + arg = simplify(dir) + separator + } else if !strings.HasPrefix(arg, "."+separator) && dir == "." { + arg = "" + } else if arg = dir; !strings.HasSuffix(arg, separator) { + arg += separator + } + return arg, targets +} + +func (c *completor) expandPath(path string) (string, func(string) string) { + switch { + case strings.HasPrefix(path, "~"): + if name, rest, _ := strings.Cut(path[1:], separator); name != "" { + user, err := c.fs.GetUser(name) + if err != nil { + return path, nil + } + return filepath.Join(user.HomeDir, rest), func(path string) string { + return filepath.Join("~"+user.Username, strings.TrimPrefix(path, user.HomeDir)) + } + } + homedir, err := c.fs.UserHomeDir() + if err != nil { + return path, nil + } + return filepath.Join(homedir, path[1:]), func(path string) string { + return filepath.Join("~", strings.TrimPrefix(path, homedir)) + } + case strings.HasPrefix(path, "$"): + name, rest, _ := strings.Cut(path[1:], separator) + value := strings.TrimRight(c.env.Get(name), separator) + if value == "" { + return path, nil + } + return filepath.Join(value, rest), func(path string) string { + return filepath.Join("$"+name, strings.TrimPrefix(path, value)) + } + default: + return path, nil + } +} + +func (c *completor) completeWincmd( + cmdline, prefix, arg string, forward bool, +) string { + if !hasSuffixFunc(prefix, unicode.IsSpace) { + prefix += " " + } + if c.results == nil { + if arg != "" { + return cmdline + } + c.command, c.target, c.arg, c.index = false, cmdline, "", -1 + c.results = strings.Split("nohjkltbpHJKL", "") + } + return c.completeNext(prefix, forward) +} + +func (c *completor) clear() { + c.command, c.target, c.arg = false, "", "" + c.results, c.index = nil, 0 +} + +func hasSuffixFunc(s string, f func(rune) bool) bool { + r, size := utf8.DecodeLastRuneInString(s) + return size > 0 && f(r) +} diff --git a/bed/cmdline/completor_test.go b/bed/cmdline/completor_test.go new file mode 100644 index 0000000..bba8f6e --- /dev/null +++ b/bed/cmdline/completor_test.go @@ -0,0 +1,566 @@ +package cmdline + +import ( + "path/filepath" + "runtime" + "slices" + "testing" +) + +func TestCompletorCompleteCommand(t *testing.T) { + c := newCompletor(nil, nil) + cmdline := c.complete("", true) + if expected := "cd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + if expected := "edit"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + if expected := "goto"; slices.Contains(c.results, expected) { + t.Errorf("completion results should not contain %q but got %v", expected, c.results) + } + if expected := "write"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "edit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + for range 4 { + cmdline = c.complete(cmdline, false) + } + if expected := ""; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + for range 3 { + cmdline = c.complete(cmdline, false) + } + if expected := "write"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete(": :\t", true) + if expected := ": :\tcd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete(": : cq", true) + if expected := ": : cquit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = c.complete("e", false) + if expected := "exit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, true) + if expected := "e"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, true) + if expected := "edit"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + cmdline = c.complete(cmdline, false) + if expected := "e"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + + c.clear() + cmdline = "p" + for _, expected := range []string{"pwd", "pwd"} { + cmdline = c.complete(cmdline, true) + if cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = "10" + for _, command := range []string{"%", "goto", ""} { + cmdline = c.complete(cmdline, true) + if expected := "10" + command; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = "10,20" + for _, command := range []string{"wq", "write", "xall", "xit", ""} { + cmdline = c.complete(cmdline, true) + if expected := "10,20" + command; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + } + + c.clear() + cmdline = c.complete("not", true) + if expected := "not"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if len(c.results) != 0 { + t.Errorf("completion results should be empty but got %v", c.results) + } +} + +func TestCompletorCompleteFilepath(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("new", true) + if expected := "new CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if expected := "README.md"; !slices.Contains(c.results, expected) { + t.Errorf("completion results should contain %q but got %v", expected, c.results) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "new .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 3 { + t.Errorf("completion index should be %d but got %d", 3, c.index) + } + + for range 4 { + cmdline = c.complete(cmdline, true) + } + if expected := "new editor" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 7 { + t.Errorf("completion index should be %d but got %d", 7, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "new"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "new CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, false) + if expected := "new"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "new README.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 2 { + t.Errorf("completion index should be %d but got %d", 2, c.index) + } + + c.clear() + cmdline = c.complete("w change", true) + if expected := "w CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("wq .", true) + if expected := "wq .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("new not", true) + if expected := "new not"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "new not"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("edit", true) + if expected := "edit CHANGELOG.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "edit"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathLeadingDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("edit .", true) + if expected := "edit .gitignore"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("edit ./r", true) + if expected := "edit ./README.md"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd ..", true) + if expected := "cd ../"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := ""; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathKeepPrefix(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete(" : : : new \tB", true) + if expected := " : : : new \tbuffer" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := " : : : new \tB"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := " : : : new \tbuild" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + for range 2 { + cmdline = c.complete(cmdline, false) + } + if expected := " : : : new \tB"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete(" : cd\u3000", true) + if expected := " : cd\u3000buffer" + string(filepath.Separator); cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathHomedir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("vnew ~/", true) + if expected := "vnew ~/example.txt"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "vnew ~/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "vnew ~/.vimrc"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + for range 3 { + cmdline = c.complete(cmdline, true) + } + if expected := "vnew ~/Library/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 4 { + t.Errorf("completion index should be %d but got %d", 4, c.index) + } + + for range 2 { + cmdline = c.complete(cmdline, true) + } + if expected := "vnew ~/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete("cd ~user/", true) + if expected := "cd ~user/Documents/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "cd ~user/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathHomedirDot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("vnew ~/.", false) + if expected := "vnew ~/.zshrc"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "vnew ~/."; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "vnew ~/."; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } +} + +func TestCompletorCompleteFilepathEnviron(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + + c := newCompletor(&mockFilesystem{}, &mockEnvironment{}) + cmdline := c.complete("e $h", true) + if expected := "e $HOME/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("e $HOME/", true) + if expected := "e $HOME/example.txt"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "e $HOME/"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd $h", true) + if expected := "cd $HOME/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + c.clear() + cmdline = c.complete("cd $HOME/", true) + if expected := "cd $HOME/Documents/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("e /", true) + if expected := "e /bin/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "e /"; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, true) + if expected := "e /tmp/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 1 { + t.Errorf("completion index should be %d but got %d", 1, c.index) + } + + cmdline = c.complete(cmdline, false) + c.clear() + cmdline = c.complete(cmdline, true) + if expected := "e /bin/cp"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteFilepathChdir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("cd ", false) + if expected := "cd editor/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if expected := "cd "; c.target != expected { + t.Errorf("completion target should be %q but got %q", expected, c.target) + } + if c.index != 3 { + t.Errorf("completion index should be %d but got %d", 3, c.index) + } + + c.clear() + cmdline = c.complete("cd ~/", false) + if expected := "cd ~/Pictures/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 2 { + t.Errorf("completion index should be %d but got %d", 2, c.index) + } + + c.clear() + cmdline = c.complete("cd /", true) + if expected := "cd /bin/"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} + +func TestCompletorCompleteWincmd(t *testing.T) { + c := newCompletor(&mockFilesystem{}, nil) + cmdline := c.complete("winc", true) + if expected := "wincmd n"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + for range 7 { + cmdline = c.complete(cmdline, true) + } + if expected := "wincmd b"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 7 { + t.Errorf("completion index should be %d but got %d", 7, c.index) + } + + for range 7 { + cmdline = c.complete(cmdline, true) + } + if expected := "wincmd n"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } + + cmdline = c.complete(cmdline, false) + if expected := "wincmd"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != -1 { + t.Errorf("completion index should be %d but got %d", -1, c.index) + } + + c.clear() + cmdline = c.complete("winc j", true) + if expected := "winc j"; cmdline != expected { + t.Errorf("cmdline should be %q but got %q", expected, cmdline) + } + if c.index != 0 { + t.Errorf("completion index should be %d but got %d", 0, c.index) + } +} diff --git a/bed/cmdline/environment.go b/bed/cmdline/environment.go new file mode 100644 index 0000000..3a51400 --- /dev/null +++ b/bed/cmdline/environment.go @@ -0,0 +1,18 @@ +package cmdline + +import "os" + +type env interface { + Get(string) string + List() []string +} + +type environment struct{} + +func (*environment) Get(key string) string { + return os.Getenv(key) +} + +func (*environment) List() []string { + return os.Environ() +} diff --git a/bed/cmdline/environment_test.go b/bed/cmdline/environment_test.go new file mode 100644 index 0000000..e65dfbb --- /dev/null +++ b/bed/cmdline/environment_test.go @@ -0,0 +1,14 @@ +package cmdline + +type mockEnvironment struct{} + +func (*mockEnvironment) Get(key string) string { + if key == "HOME" { + return mockHomeDir + } + return "" +} + +func (*mockEnvironment) List() []string { + return []string{"HOME=" + mockHomeDir} +} diff --git a/bed/cmdline/filesystem.go b/bed/cmdline/filesystem.go new file mode 100644 index 0000000..bdbfa64 --- /dev/null +++ b/bed/cmdline/filesystem.go @@ -0,0 +1,36 @@ +package cmdline + +import ( + "os" + "os/user" +) + +type fs interface { + Open(string) (file, error) + Stat(string) (os.FileInfo, error) + GetUser(string) (*user.User, error) + UserHomeDir() (string, error) +} + +type file interface { + Close() error + Readdir(int) ([]os.FileInfo, error) +} + +type filesystem struct{} + +func (*filesystem) Open(path string) (file, error) { + return os.Open(path) +} + +func (*filesystem) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +func (*filesystem) GetUser(name string) (*user.User, error) { + return user.Lookup(name) +} + +func (*filesystem) UserHomeDir() (string, error) { + return os.UserHomeDir() +} diff --git a/bed/cmdline/filesystem_test.go b/bed/cmdline/filesystem_test.go new file mode 100644 index 0000000..c8943ce --- /dev/null +++ b/bed/cmdline/filesystem_test.go @@ -0,0 +1,118 @@ +package cmdline + +import ( + "os" + "os/user" + "time" +) + +const mockHomeDir = "/home/user" + +type mockFilesystem struct{} + +func (*mockFilesystem) Open(path string) (file, error) { + return &mockFile{path}, nil +} + +func (*mockFilesystem) Stat(path string) (os.FileInfo, error) { + return &mockFileInfo{ + name: path, + isDir: path == mockHomeDir || path == "..", + }, nil +} + +func (*mockFilesystem) GetUser(name string) (*user.User, error) { + return &user.User{Username: name, HomeDir: mockHomeDir}, nil +} + +func (*mockFilesystem) UserHomeDir() (string, error) { + return mockHomeDir, nil +} + +type mockFile struct { + path string +} + +func (*mockFile) Close() error { + return nil +} + +func createFileInfoList(infos []*mockFileInfo) []os.FileInfo { + fileInfos := make([]os.FileInfo, len(infos)) + for i, info := range infos { + fileInfos[i] = info + } + return fileInfos +} + +func (f *mockFile) Readdir(_ int) ([]os.FileInfo, error) { + if f.path == "." { + return createFileInfoList([]*mockFileInfo{ + {"CHANGELOG.md", false}, + {"README.md", false}, + {"Makefile", false}, + {".gitignore", false}, + {"editor", true}, + {"cmdline", true}, + {"buffer", true}, + {"build", true}, + }), nil + } + if f.path == mockHomeDir { + return createFileInfoList([]*mockFileInfo{ + {"Documents", true}, + {"Pictures", true}, + {"Library", true}, + {".vimrc", false}, + {".zshrc", false}, + {"example.txt", false}, + }), nil + } + if f.path == "/" { + return createFileInfoList([]*mockFileInfo{ + {"bin", true}, + {"tmp", true}, + {"var", true}, + {"usr", true}, + }), nil + } + if f.path == "/bin" { + return createFileInfoList([]*mockFileInfo{ + {"cp", false}, + {"echo", false}, + {"rm", false}, + {"ls", false}, + {"kill", false}, + }), nil + } + return nil, nil +} + +type mockFileInfo struct { + name string + isDir bool +} + +func (fi *mockFileInfo) Name() string { + return fi.name +} + +func (fi *mockFileInfo) IsDir() bool { + return fi.isDir +} + +func (*mockFileInfo) Size() int64 { + return 0 +} + +func (*mockFileInfo) Mode() os.FileMode { + return os.FileMode(0x1ed) +} + +func (*mockFileInfo) ModTime() time.Time { + return time.Time{} +} + +func (*mockFileInfo) Sys() any { + return nil +} diff --git a/bed/cmdline/parse.go b/bed/cmdline/parse.go new file mode 100644 index 0000000..b55c26f --- /dev/null +++ b/bed/cmdline/parse.go @@ -0,0 +1,55 @@ +package cmdline + +import ( + "errors" + "strings" + "unicode" + + "b612.me/apps/b612/bed/event" +) + +func parse(src string) (cmd command, r *event.Range, + bang bool, name, prefix, arg string, err error) { + prefix, arg = cutPrefixFunc(src, func(r rune) bool { + return unicode.IsSpace(r) || r == ':' + }) + if arg == "" { + return + } + r, arg = event.ParseRange(arg) + name, arg = cutPrefixFunc(arg, func(r rune) bool { + return !unicode.IsSpace(r) + }) + name, bang = strings.CutSuffix(name, "!") + prefix = src[:len(src)-len(arg)] + if name == "" { + // To jump by byte offset, name should not be "go[to]". + cmd = command{name: "goto", eventType: event.CursorGoto} + return + } + for _, cmd = range commands { + if matchCommand(cmd.name, name) { + arg = strings.TrimLeftFunc(arg, unicode.IsSpace) + prefix = src[:len(src)-len(arg)] + return + } + } + cmd, err = command{}, errors.New("unknown command: "+name) + return +} + +func cutPrefixFunc(src string, f func(rune) bool) (string, string) { + for i, r := range src { + if !f(r) { + return src[:i], src[i:] + } + } + return src, "" +} + +func matchCommand(cmd, name string) bool { + prefix, rest, _ := strings.Cut(cmd, "[") + abbr, _, _ := strings.Cut(rest, "]") + return strings.HasPrefix(name, prefix) && + strings.HasPrefix(abbr, name[len(prefix):]) +} diff --git a/bed/editor/cmdline.go b/bed/editor/cmdline.go new file mode 100644 index 0000000..7ab2dc9 --- /dev/null +++ b/bed/editor/cmdline.go @@ -0,0 +1,10 @@ +package editor + +import "b612.me/apps/b612/bed/event" + +// Cmdline defines the required cmdline interface for the editor. +type Cmdline interface { + Init(chan<- event.Event, <-chan event.Event, chan<- struct{}) + Run() + Get() ([]rune, int, []string, int) +} diff --git a/bed/editor/editor.go b/bed/editor/editor.go new file mode 100644 index 0000000..cb4efad --- /dev/null +++ b/bed/editor/editor.go @@ -0,0 +1,345 @@ +package editor + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// Editor is the main struct for this command. +type Editor struct { + ui UI + wm Manager + cmdline Cmdline + mode mode.Mode + prevMode mode.Mode + searchTarget string + searchMode rune + prevEventType event.Type + buffer *buffer.Buffer + err error + errtyp int + cmdEventCh chan event.Event + wmEventCh chan event.Event + uiEventCh chan event.Event + redrawCh chan struct{} + cmdlineCh chan event.Event + quitCh chan struct{} + mu *sync.Mutex +} + +// NewEditor creates a new editor. +func NewEditor(ui UI, wm Manager, cmdline Cmdline) *Editor { + return &Editor{ + ui: ui, + wm: wm, + cmdline: cmdline, + mode: mode.Normal, + prevMode: mode.Normal, + } +} + +// Init initializes the editor. +func (e *Editor) Init() error { + e.cmdEventCh = make(chan event.Event) + e.wmEventCh = make(chan event.Event) + e.uiEventCh = make(chan event.Event) + e.redrawCh = make(chan struct{}) + e.cmdlineCh = make(chan event.Event) + e.cmdline.Init(e.cmdEventCh, e.cmdlineCh, e.redrawCh) + e.quitCh = make(chan struct{}) + e.wm.Init(e.wmEventCh, e.redrawCh) + e.mu = new(sync.Mutex) + return nil +} + +func (e *Editor) listen() error { + var wg sync.WaitGroup + errCh := make(chan error, 1) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-e.redrawCh: + _ = e.redraw() + case <-e.quitCh: + return + } + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case ev := <-e.wmEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case <-e.quitCh: + return + } + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case ev := <-e.cmdEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case ev := <-e.uiEventCh: + if redraw, finish, err := e.emit(ev); redraw { + e.redrawCh <- struct{}{} + } else if finish { + close(e.quitCh) + errCh <- err + } + case <-e.quitCh: + return + } + } + }() + wg.Wait() + select { + case err := <-errCh: + return err + default: + return nil + } +} + +type quitErr struct { + code int +} + +func (err *quitErr) Error() string { + return "exit with " + strconv.Itoa(err.code) +} + +func (err *quitErr) ExitCode() int { + return err.code +} + +func (e *Editor) emit(ev event.Event) (redraw, finish bool, err error) { + e.mu.Lock() + if ev.Type != event.Redraw { + e.prevEventType = ev.Type + } + switch ev.Type { + case event.QuitAll: + if ev.Arg != "" { + e.err, e.errtyp = errors.New("too many arguments for "+ev.CmdName), state.MessageError + redraw = true + } else { + finish = true + } + case event.QuitErr: + args := strings.Fields(ev.Arg) + if len(args) > 1 { + e.err, e.errtyp = errors.New("too many arguments for "+ev.CmdName), state.MessageError + redraw = true + } else if len(args) > 0 { + n, er := strconv.Atoi(args[0]) + if er != nil { + e.err, e.errtyp = fmt.Errorf("invalid argument for %s: %w", ev.CmdName, er), state.MessageError + redraw = true + } else { + err = &quitErr{n} + finish = true + } + } else { + err = &quitErr{1} + finish = true + } + case event.Suspend: + e.mu.Unlock() + if err := suspend(e); err != nil { + e.mu.Lock() + e.err, e.errtyp = err, state.MessageError + e.mu.Unlock() + } + redraw = true + return + case event.Info: + e.err, e.errtyp = ev.Error, state.MessageInfo + redraw = true + case event.Error: + e.err, e.errtyp = ev.Error, state.MessageError + redraw = true + case event.Redraw: + width, height := e.ui.Size() + e.wm.Resize(width, height-1) + redraw = true + case event.Copied: + e.mode, e.prevMode = mode.Normal, e.mode + if ev.Buffer != nil { + e.buffer = ev.Buffer + if l, err := e.buffer.Len(); err != nil { + e.err, e.errtyp = err, state.MessageError + } else { + e.err, e.errtyp = fmt.Errorf("%[1]d (0x%[1]x) bytes %[2]s", l, ev.Arg), state.MessageInfo + } + } + redraw = true + case event.Pasted: + e.err, e.errtyp = fmt.Errorf("%[1]d (0x%[1]x) bytes pasted", ev.Count), state.MessageInfo + redraw = true + default: + switch ev.Type { + case event.StartInsert, event.StartInsertHead, event.StartAppend, event.StartAppendEnd: + e.mode, e.prevMode = mode.Insert, e.mode + case event.StartReplaceByte, event.StartReplace: + e.mode, e.prevMode = mode.Replace, e.mode + case event.ExitInsert: + e.mode, e.prevMode = mode.Normal, e.mode + case event.StartVisual: + e.mode, e.prevMode = mode.Visual, e.mode + case event.ExitVisual: + e.mode, e.prevMode = mode.Normal, e.mode + case event.StartCmdlineCommand: + if e.mode == mode.Visual { + ev.Arg = "'<,'>" + } else if ev.Count > 0 { + ev.Arg = ".,.+" + strconv.FormatInt(ev.Count-1, 10) + } + e.mode, e.prevMode = mode.Cmdline, e.mode + e.err = nil + case event.StartCmdlineSearchForward: + e.mode, e.prevMode = mode.Search, e.mode + e.err = nil + e.searchMode = '/' + case event.StartCmdlineSearchBackward: + e.mode, e.prevMode = mode.Search, e.mode + e.err = nil + e.searchMode = '?' + case event.ExitCmdline: + e.mode, e.prevMode = mode.Normal, e.mode + case event.ExecuteCmdline: + m := mode.Normal + if e.mode == mode.Search { + m = e.prevMode + } + e.mode, e.prevMode = m, e.mode + case event.ExecuteSearch: + e.searchTarget, e.searchMode = ev.Arg, ev.Rune + case event.NextSearch: + ev.Arg, ev.Rune, e.err = e.searchTarget, e.searchMode, nil + case event.PreviousSearch: + ev.Arg, ev.Rune, e.err = e.searchTarget, e.searchMode, nil + case event.Paste, event.PastePrev: + if e.buffer == nil { + e.mu.Unlock() + return + } + ev.Buffer = e.buffer + } + if e.mode == mode.Cmdline || e.mode == mode.Search || + ev.Type == event.ExitCmdline || ev.Type == event.ExecuteCmdline { + e.mu.Unlock() + e.cmdlineCh <- ev + } else { + if event.ScrollUp <= ev.Type && ev.Type <= event.SwitchFocus { + e.prevMode, e.err = e.mode, nil + } + ev.Mode = e.mode + width, height := e.ui.Size() + e.wm.Resize(width, height-1) + e.mu.Unlock() + e.wm.Emit(ev) + } + return + } + e.mu.Unlock() + return +} + +// Open opens a new file. +func (e *Editor) Open(name string) error { + return e.wm.Open(name) +} + +// OpenEmpty creates a new window. +func (e *Editor) OpenEmpty() error { + return e.wm.Open("") +} + +// Read [io.Reader] and creates a new window. +func (e *Editor) Read(r io.Reader) error { + return e.wm.Read(r) +} + +// Run the editor. +func (e *Editor) Run() error { + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + go e.cmdline.Run() + return e.listen() +} + +func (e *Editor) redraw() (err error) { + e.mu.Lock() + defer e.mu.Unlock() + var s state.State + var windowIndex int + s.WindowStates, s.Layout, windowIndex, err = e.wm.State() + if err != nil { + return err + } + if s.WindowStates[windowIndex] == nil { + return errors.New("index out of windows") + } + s.WindowStates[windowIndex].Mode = e.mode + s.Mode, s.PrevMode, s.Error, s.ErrorType = e.mode, e.prevMode, e.err, e.errtyp + if s.Mode != mode.Visual && s.PrevMode != mode.Visual { + for _, ws := range s.WindowStates { + ws.VisualStart = -1 + } + } + s.Cmdline, s.CmdlineCursor, s.CompletionResults, s.CompletionIndex = e.cmdline.Get() + if e.mode == mode.Search || e.prevEventType == event.ExecuteSearch { + s.SearchMode = e.searchMode + } else if e.prevEventType == event.NextSearch { + s.SearchMode, s.Cmdline = e.searchMode, []rune(e.searchTarget) + } else if e.prevEventType == event.PreviousSearch { + if e.searchMode == '/' { + s.SearchMode, s.Cmdline = '?', []rune(e.searchTarget) + } else { + s.SearchMode, s.Cmdline = '/', []rune(e.searchTarget) + } + } + return e.ui.Redraw(s) +} + +// Close terminates the editor. +func (e *Editor) Close() error { + close(e.cmdEventCh) + close(e.wmEventCh) + close(e.uiEventCh) + close(e.redrawCh) + close(e.cmdlineCh) + e.wm.Close() + return e.ui.Close() +} diff --git a/bed/editor/editor_test.go b/bed/editor/editor_test.go new file mode 100644 index 0000000..71ed9eb --- /dev/null +++ b/bed/editor/editor_test.go @@ -0,0 +1,883 @@ +package editor + +import ( + "fmt" + "os" + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/cmdline" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" + "b612.me/apps/b612/bed/window" +) + +type testUI struct { + eventCh chan<- event.Event + initCh chan struct{} + redrawCh chan struct{} +} + +func newTestUI() *testUI { + return &testUI{ + initCh: make(chan struct{}), + redrawCh: make(chan struct{}), + } +} + +func (ui *testUI) Init(eventCh chan<- event.Event) error { + ui.eventCh = eventCh + go func() { defer close(ui.initCh); <-ui.redrawCh }() + return nil +} + +func (*testUI) Run(map[mode.Mode]*key.Manager) {} + +func (*testUI) Size() (int, int) { return 90, 20 } + +func (ui *testUI) Redraw(state.State) error { + ui.redrawCh <- struct{}{} + return nil +} + +func (*testUI) Close() error { return nil } + +func (ui *testUI) Emit(e event.Event) { + <-ui.initCh + ui.eventCh <- e + switch e.Type { + case event.ExecuteCmdline, event.NextSearch, event.PreviousSearch: + <-ui.redrawCh + } + <-ui.redrawCh +} + +func createTemp(dir, str string) (*os.File, error) { + f, err := os.CreateTemp(dir, "") + if err != nil { + return nil, err + } + if str != "" { + if _, err = f.WriteString(str); err != nil { + return nil, err + } + } + if err = f.Close(); err != nil { + return nil, err + } + if str == "" { + if err = os.Remove(f.Name()); err != nil { + return nil, err + } + } + return f, nil +} + +func TestEditorOpenEmptyWriteQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.Increment, Count: 13}) + ui.Emit(event.Event{Type: event.Decrement, Count: 6}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name()}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "1 (0x1) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x07"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorOpenWriteQuit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartInsert}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'f'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorLeft}) + ui.Emit(event.Event{Type: event.Decrement}) + ui.Emit(event.Event{Type: event.StartInsertHead}) + ui.Emit(event.Event{Type: event.Rune, Rune: '1'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorEnd}) + ui.Emit(event.Event{Type: event.Delete}) + ui.Emit(event.Event{Type: event.WriteQuit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x12\x48\xff"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorOpenQuitBang(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartInsert}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.Quit}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err, expected := editor.err, "you have unsaved changes in [No Name], "+ + "add ! to force :quit"; err == nil || !strings.HasSuffix(err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorOpenWriteQuitBang(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "ab") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.StartAppendEnd}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.WriteQuit, Arg: f.Name() + ".out", Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "ab"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorReadWriteQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + r := strings.NewReader("Hello, world!") + if err := editor.Read(r); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.WriteQuit, Arg: f.Name()}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorWritePartial(t *testing.T) { + str := "Hello, world! こんにちは、世界!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + for _, testCase := range []struct { + cmdRange string + count int + expected string + }{ + {"", 41, str}, + {"-10,$+10", 41, str}, + {"10,25", 16, str[10:26]}, + {".+3+3+3+5+5 , .+0xa-0x6", 16, str[4:20]}, + {"$-20,.+28", 9, str[20:29]}, + } { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + fout, err := createTemp(t.TempDir(), "") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func(name string) { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + for _, c := range testCase.cmdRange + "w " + name { + ui.Emit(event.Event{Type: event.Rune, Rune: c}) + } + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }(fout.Name()) + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := fmt.Sprintf("%[1]d (0x%[1]x) bytes written", testCase.count); editor.err == nil || + !strings.Contains(editor.err.Error(), expected) { + t.Errorf("err should be contain %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(fout.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if string(bs) != testCase.expected { + t.Errorf("file contents should be %q but got %q", testCase.expected, string(bs)) + } + } +} + +func TestEditorWriteVisualSelection(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 4}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'w'}) + ui.Emit(event.Event{Type: event.Rune, Rune: ' '}) + for _, ch := range f.Name() + ".out" { + ui.Emit(event.Event{Type: event.Rune, Rune: ch}) + } + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "6 (0x6) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "o, wor"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorWriteUndo(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "abc") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Undo}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "2 (0x2) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "bc"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorSearch(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "abcdefabcdefabcdef") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineSearchForward}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'e'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'f'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Nop}) // wait for redraw + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.PreviousSearch}) + ui.Emit(event.Event{Type: event.NextSearch}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.StartCmdlineSearchBackward}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'b'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.Nop}) // wait for redraw + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.PreviousSearch}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "14 (0xe) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "abcdfacdfacdef"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCmdlineCursorGoto(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: '6'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out1"}) + ui.Emit(event.Event{Type: event.Undo}) + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '0'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '%'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + ui.Emit(event.Event{Type: event.DeletePrevByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out2"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out1") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello,world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + bs, err = os.ReadFile(f.Name() + ".out2") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, wrld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCmdlineQuit(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'u'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'i'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 't'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorCmdlineQuitAll(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'l'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'l'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorCmdlineQuitErr(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.StartCmdlineCommand}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'q'}) + ui.Emit(event.Event{Type: event.Rune, Rune: ' '}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.ExecuteCmdline}) + }() + if err, expected := editor.Run(), (&quitErr{42}); !reflect.DeepEqual(expected, err) { + t.Errorf("err should be %v but got: %v", expected, err) + } + if err := editor.err; err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorReplace(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'a'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'b'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'c'}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'd'}) + ui.Emit(event.Event{Type: event.Rune, Rune: 'e'}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorLeft, Count: 5}) + ui.Emit(event.Event{Type: event.StartReplaceByte}) + ui.Emit(event.Event{Type: event.SwitchFocus}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '2'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '3'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '4'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '5'}) + ui.Emit(event.Event{Type: event.Backspace}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorEnd}) + ui.Emit(event.Event{Type: event.StartReplace}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '6'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '7'}) + ui.Emit(event.Event{Type: event.Rune, Rune: '8'}) + ui.Emit(event.Event{Type: event.Backspace}) + ui.Emit(event.Event{Type: event.ExitInsert}) + ui.Emit(event.Event{Type: event.CursorHead}) + ui.Emit(event.Event{Type: event.DeleteByte}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "13 (0xd) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "earcrsterldvw"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorCopyCutPaste(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.CursorNext, Count: 2}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.Copy}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 3}) + ui.Emit(event.Event{Type: event.Paste}) + ui.Emit(event.Event{Type: event.CursorPrev, Count: 2}) + ui.Emit(event.Event{Type: event.StartVisual}) + ui.Emit(event.Event{Type: event.CursorPrev, Count: 5}) + ui.Emit(event.Event{Type: event.Cut}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 5}) + ui.Emit(event.Event{Type: event.PastePrev}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "19 (0x13) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hell w woo,llo,rld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorShowBinary(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShowBinary}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "01001000"; editor.err == nil || editor.err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorShowDecimal(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShowDecimal}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "72"; editor.err == nil || editor.err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} + +func TestEditorShift(t *testing.T) { + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + f, err := createTemp(t.TempDir(), "Hello, world!") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.ShiftLeft, Count: 1}) + ui.Emit(event.Event{Type: event.CursorNext, Count: 7}) + ui.Emit(event.Event{Type: event.ShiftRight, Count: 3}) + ui.Emit(event.Event{Type: event.Write, Arg: f.Name() + ".out"}) + ui.Emit(event.Event{Type: event.Quit, Bang: true}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "13 (0xd) bytes written"; editor.err == nil || + !strings.HasSuffix(editor.err.Error(), expected) { + t.Errorf("err should end with %q but got: %v", expected, editor.err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(f.Name() + ".out") + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "\x90ello, \x0eorld!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } +} + +func TestEditorChdir(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + ui := newTestUI() + editor := NewEditor(ui, window.NewManager(), cmdline.NewCmdline()) + if err := editor.Init(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := editor.OpenEmpty(); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + ui.Emit(event.Event{Type: event.Pwd}) + ui.Emit(event.Event{Type: event.Chdir, Arg: "../"}) + ui.Emit(event.Event{Type: event.Chdir, Arg: "-"}) + ui.Emit(event.Event{Type: event.Quit}) + }() + if err := editor.Run(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if err := editor.err; err == nil || err.Error() != dir { + t.Errorf("err should be %q but got: %v", dir, err) + } + if editor.errtyp != state.MessageInfo { + t.Errorf("errtyp should be MessageInfo but got: %v", editor.errtyp) + } + if err := editor.Close(); err != nil { + t.Errorf("err should be nil but got: %v", err) + } +} diff --git a/bed/editor/key.go b/bed/editor/key.go new file mode 100644 index 0000000..dc40660 --- /dev/null +++ b/bed/editor/key.go @@ -0,0 +1,198 @@ +package editor + +import ( + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" +) + +func defaultKeyManagers() map[mode.Mode]*key.Manager { + kms := make(map[mode.Mode]*key.Manager) + km := defaultNormalAndVisual() + km.Register(event.Quit, "c-w", "q") + km.Register(event.Quit, "c-w", "c-q") + km.Register(event.Quit, "c-w", "c") + km.RegisterBang(event.Quit, "Z", "Q") + km.Register(event.WriteQuit, "Z", "Z") + km.Register(event.Suspend, "c-z") + + km.Register(event.JumpTo, "\x1d") + km.Register(event.JumpBack, "c-t") + km.Register(event.DeleteByte, "x") + km.Register(event.DeleteByte, "delete") + km.Register(event.DeletePrevByte, "X") + km.Register(event.Increment, "c-a") + km.Register(event.Increment, "+") + km.Register(event.Decrement, "c-x") + km.Register(event.Decrement, "-") + km.Register(event.ShiftLeft, "<") + km.Register(event.ShiftRight, ">") + km.Register(event.ShowBinary, "g", "b") + km.Register(event.ShowDecimal, "g", "d") + + km.Register(event.Paste, "p") + km.Register(event.PastePrev, "P") + + km.Register(event.StartInsert, "i") + km.Register(event.StartInsertHead, "I") + km.Register(event.StartAppend, "a") + km.Register(event.StartAppendEnd, "A") + km.Register(event.StartReplace, "R") + + km.Register(event.Undo, "u") + km.Register(event.Redo, "c-r") + + km.Register(event.StartVisual, "v") + + km.Register(event.New, "c-w", "n") + km.Register(event.New, "c-w", "c-n") + km.Register(event.Only, "c-w", "o") + km.Register(event.Only, "c-w", "c-o") + km.Register(event.Alternative, "\x1e") + km.Register(event.FocusWindowDown, "c-w", "down") + km.Register(event.FocusWindowDown, "c-w", "c-j") + km.Register(event.FocusWindowDown, "c-w", "j") + km.Register(event.FocusWindowUp, "c-w", "up") + km.Register(event.FocusWindowUp, "c-w", "c-k") + km.Register(event.FocusWindowUp, "c-w", "k") + km.Register(event.FocusWindowLeft, "c-w", "left") + km.Register(event.FocusWindowLeft, "c-w", "c-h") + km.Register(event.FocusWindowLeft, "c-w", "backspace") + km.Register(event.FocusWindowLeft, "c-w", "h") + km.Register(event.FocusWindowRight, "c-w", "right") + km.Register(event.FocusWindowRight, "c-w", "c-l") + km.Register(event.FocusWindowRight, "c-w", "l") + km.Register(event.FocusWindowTopLeft, "c-w", "t") + km.Register(event.FocusWindowTopLeft, "c-w", "c-t") + km.Register(event.FocusWindowBottomRight, "c-w", "b") + km.Register(event.FocusWindowBottomRight, "c-w", "c-b") + km.Register(event.FocusWindowPrevious, "c-w", "p") + km.Register(event.FocusWindowPrevious, "c-w", "c-p") + km.Register(event.MoveWindowTop, "c-w", "K") + km.Register(event.MoveWindowBottom, "c-w", "J") + km.Register(event.MoveWindowLeft, "c-w", "H") + km.Register(event.MoveWindowRight, "c-w", "L") + kms[mode.Normal] = km + + km = key.NewManager(false) + km.Register(event.ExitInsert, "escape") + km.Register(event.ExitInsert, "c-c") + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.CursorUp, "c-p") + km.Register(event.CursorDown, "c-n") + km.Register(event.CursorPrev, "c-b") + km.Register(event.CursorNext, "c-f") + km.Register(event.PageUp, "pgup") + km.Register(event.PageDown, "pgdn") + km.Register(event.PageTop, "home") + km.Register(event.PageEnd, "end") + km.Register(event.Backspace, "backspace") + km.Register(event.Backspace, "backspace2") + km.Register(event.Delete, "delete") + km.Register(event.SwitchFocus, "tab") + km.Register(event.SwitchFocus, "backtab") + kms[mode.Insert] = km + kms[mode.Replace] = km + + km = defaultNormalAndVisual() + km.Register(event.ExitVisual, "escape") + km.Register(event.ExitVisual, "c-c") + km.Register(event.ExitVisual, "v") + km.Register(event.SwitchVisualEnd, "o") + km.Register(event.SwitchVisualEnd, "O") + + km.Register(event.Copy, "y") + km.Register(event.Cut, "x") + km.Register(event.Cut, "d") + km.Register(event.Cut, "delete") + kms[mode.Visual] = km + + km = key.NewManager(false) + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.CursorUp, "c-p") + km.Register(event.CursorDown, "c-n") + km.Register(event.CursorLeft, "c-b") + km.Register(event.CursorRight, "c-f") + km.Register(event.CursorHead, "home") + km.Register(event.CursorHead, "c-a") + km.Register(event.CursorEnd, "end") + km.Register(event.CursorEnd, "c-e") + km.Register(event.BackspaceCmdline, "c-h") + km.Register(event.BackspaceCmdline, "backspace") + km.Register(event.BackspaceCmdline, "backspace2") + km.Register(event.DeleteCmdline, "delete") + km.Register(event.DeleteWordCmdline, "c-w") + km.Register(event.ClearToHeadCmdline, "c-u") + km.Register(event.ClearCmdline, "c-k") + km.Register(event.ExitCmdline, "escape") + km.Register(event.ExitCmdline, "c-c") + km.Register(event.CompleteForwardCmdline, "tab") + km.Register(event.CompleteBackCmdline, "backtab") + km.Register(event.ExecuteCmdline, "enter") + km.Register(event.ExecuteCmdline, "c-j") + km.Register(event.ExecuteCmdline, "c-m") + kms[mode.Cmdline] = km + kms[mode.Search] = km + return kms +} + +func defaultNormalAndVisual() *key.Manager { + km := key.NewManager(true) + km.Register(event.CursorUp, "up") + km.Register(event.CursorDown, "down") + km.Register(event.CursorLeft, "left") + km.Register(event.CursorRight, "right") + km.Register(event.PageUp, "pgup") + km.Register(event.PageDown, "pgdn") + km.Register(event.PageTop, "home") + km.Register(event.PageEnd, "end") + km.Register(event.CursorUp, "k") + km.Register(event.CursorDown, "j") + km.Register(event.CursorLeft, "h") + km.Register(event.CursorRight, "l") + km.Register(event.CursorPrev, "b") + km.Register(event.CursorPrev, "backspace") + km.Register(event.CursorPrev, "backspace2") + km.Register(event.CursorNext, "w") + km.Register(event.CursorNext, " ") + km.Register(event.CursorHead, "0") + km.Register(event.CursorHead, "^") + km.Register(event.CursorEnd, "$") + km.Register(event.ScrollUp, "c-y") + km.Register(event.ScrollDown, "c-e") + km.Register(event.ScrollTop, "z", "t") + km.Register(event.ScrollTopHead, "z", "enter") + km.Register(event.ScrollMiddle, "z", "z") + km.Register(event.ScrollMiddleHead, "z", ".") + km.Register(event.ScrollBottom, "z", "b") + km.Register(event.ScrollBottomHead, "z", "-") + km.Register(event.WindowTop, "H") + km.Register(event.WindowMiddle, "M") + km.Register(event.WindowBottom, "L") + + km.Register(event.PageUp, "c-b") + km.Register(event.PageDown, "c-f") + km.Register(event.PageUpHalf, "c-u") + km.Register(event.PageDownHalf, "c-d") + km.Register(event.PageTop, "g", "g") + km.Register(event.PageEnd, "G") + + km.Register(event.SwitchFocus, "tab") + km.Register(event.SwitchFocus, "backtab") + + km.Register(event.StartCmdlineSearchForward, "/") + km.Register(event.StartCmdlineSearchBackward, "?") + km.Register(event.NextSearch, "n") + km.Register(event.PreviousSearch, "N") + km.Register(event.AbortSearch, "c-c") + + km.Register(event.StartCmdlineCommand, ":") + km.Register(event.StartReplaceByte, "r") + return km +} diff --git a/bed/editor/manager.go b/bed/editor/manager.go new file mode 100644 index 0000000..1ef47a2 --- /dev/null +++ b/bed/editor/manager.go @@ -0,0 +1,21 @@ +package editor + +import ( + "io" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/state" +) + +// Manager defines the required window manager interface for the editor. +type Manager interface { + Init(chan<- event.Event, chan<- struct{}) + Open(string) error + Read(io.Reader) error + SetSize(int, int) + Resize(int, int) + Emit(event.Event) + State() (map[int]*state.WindowState, layout.Layout, int, error) + Close() +} diff --git a/bed/editor/suspend_linux.go b/bed/editor/suspend_linux.go new file mode 100644 index 0000000..456ed33 --- /dev/null +++ b/bed/editor/suspend_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package editor + +import "syscall" + +func suspend(e *Editor) error { + if err := e.ui.Close(); err != nil { + return err + } + pid, tid := syscall.Getpid(), syscall.Gettid() + if err := syscall.Tgkill(pid, tid, syscall.SIGSTOP); err != nil { + return err + } + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + return nil +} diff --git a/bed/editor/suspend_unix.go b/bed/editor/suspend_unix.go new file mode 100644 index 0000000..9cebca0 --- /dev/null +++ b/bed/editor/suspend_unix.go @@ -0,0 +1,23 @@ +//go:build !windows && !linux + +package editor + +import "syscall" + +func suspend(e *Editor) error { + if err := e.ui.Close(); err != nil { + return err + } + pid := syscall.Getpid() + if err := syscall.Kill(pid, syscall.SIGSTOP); err != nil { + return err + } + if err := e.ui.Init(e.uiEventCh); err != nil { + return err + } + if err := e.redraw(); err != nil { + return err + } + go e.ui.Run(defaultKeyManagers()) + return nil +} diff --git a/bed/editor/suspend_windows.go b/bed/editor/suspend_windows.go new file mode 100644 index 0000000..5b0001e --- /dev/null +++ b/bed/editor/suspend_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package editor + +func suspend(_ *Editor) error { + return nil +} diff --git a/bed/editor/ui.go b/bed/editor/ui.go new file mode 100644 index 0000000..a5497ac --- /dev/null +++ b/bed/editor/ui.go @@ -0,0 +1,17 @@ +package editor + +import ( + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// UI defines the required user interface for the editor. +type UI interface { + Init(chan<- event.Event) error + Run(map[mode.Mode]*key.Manager) + Size() (int, int) + Redraw(state.State) error + Close() error +} diff --git a/bed/event/event.go b/bed/event/event.go new file mode 100644 index 0000000..e9fbd95 --- /dev/null +++ b/bed/event/event.go @@ -0,0 +1,141 @@ +package event + +import ( + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/mode" +) + +// Event represents the event emitted by UI. +type Event struct { + Type Type + Range *Range + Count int64 + Rune rune + CmdName string + Bang bool + Arg string + Error error + Mode mode.Mode + Buffer *buffer.Buffer +} + +// Type ... +type Type int + +// Event types +const ( + Nop Type = iota + Redraw + + CursorUp + CursorDown + CursorLeft + CursorRight + CursorPrev + CursorNext + CursorHead + CursorEnd + CursorGoto + ScrollUp + ScrollDown + ScrollTop + ScrollTopHead + ScrollMiddle + ScrollMiddleHead + ScrollBottom + ScrollBottomHead + PageUp + PageDown + PageUpHalf + PageDownHalf + PageTop + PageEnd + WindowTop + WindowMiddle + WindowBottom + JumpTo + JumpBack + + DeleteByte + DeletePrevByte + Increment + Decrement + ShiftLeft + ShiftRight + SwitchFocus + ShowBinary + ShowDecimal + + StartInsert + StartInsertHead + StartAppend + StartAppendEnd + StartReplaceByte + StartReplace + + ExitInsert + Backspace + Delete + Rune + + Undo + Redo + + StartVisual + SwitchVisualEnd + ExitVisual + + Copy + Cut + Copied + Paste + PastePrev + Pasted + + StartCmdlineCommand + StartCmdlineSearchForward + StartCmdlineSearchBackward + BackspaceCmdline + DeleteCmdline + DeleteWordCmdline + ClearToHeadCmdline + ClearCmdline + ExitCmdline + CompleteForwardCmdline + CompleteBackCmdline + ExecuteCmdline + ExecuteSearch + NextSearch + PreviousSearch + AbortSearch + + Edit + Enew + New + Vnew + Only + Alternative + Wincmd + FocusWindowUp + FocusWindowDown + FocusWindowLeft + FocusWindowRight + FocusWindowTopLeft + FocusWindowBottomRight + FocusWindowPrevious + MoveWindowTop + MoveWindowBottom + MoveWindowLeft + MoveWindowRight + + Pwd + Chdir + Suspend + Quit + QuitAll + QuitErr + Write + WriteQuit + Info + Error +) diff --git a/bed/event/parse.go b/bed/event/parse.go new file mode 100644 index 0000000..fcdb946 --- /dev/null +++ b/bed/event/parse.go @@ -0,0 +1,96 @@ +package event + +import ( + "strings" + "unicode" +) + +// ParseRange parses a Range. +func ParseRange(src string) (*Range, string) { + var from, to Position + from, src = parsePosition(src) + if from == nil { + return nil, src + } + var ok bool + if src, ok = strings.CutPrefix(src, ","); !ok { + return &Range{From: from}, src + } + to, src = parsePosition(src) + return &Range{From: from, To: to}, src +} + +func parsePosition(src string) (Position, string) { + var pos Position + var offset int64 + src = strings.TrimLeftFunc(src, unicode.IsSpace) + if src == "" { + return nil, src + } + switch src[0] { + case '.': + src = src[1:] + fallthrough + case '-', '+': + pos = Relative{} + case '$': + pos = End{} + src = src[1:] + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + offset, src = parseNum(src) + pos = Absolute{offset} + case '\'': + if len(src) == 1 { + return nil, src + } + switch src[1] { + case '<': + pos = VisualStart{} + case '>': + pos = VisualEnd{} + default: + return nil, src + } + src = src[2:] + default: + return nil, src + } + for src != "" { + src = strings.TrimLeftFunc(src, unicode.IsSpace) + if src == "" { + break + } + sign := int64(1) + switch src[0] { + case '-': + sign = -1 + fallthrough + case '+': + offset, src = parseNum(src[1:]) + pos = pos.add(sign * offset) + default: + return pos, src + } + } + return pos, src +} + +func parseNum(src string) (int64, string) { + offset, radix, ishex := int64(0), int64(10), false + if src, ishex = strings.CutPrefix(src, "0x"); ishex { + radix = 16 + } + for src != "" { + c := src[0] + switch { + case '0' <= c && c <= '9': + offset = offset*radix + int64(c-'0') + case ('A' <= c && c <= 'F' || 'a' <= c && c <= 'f') && ishex: + offset = offset*radix + int64(c|('a'-'A')-'a'+10) + default: + return offset, src + } + src = src[1:] + } + return offset, src +} diff --git a/bed/event/parse_test.go b/bed/event/parse_test.go new file mode 100644 index 0000000..71e2c7e --- /dev/null +++ b/bed/event/parse_test.go @@ -0,0 +1,42 @@ +package event + +import ( + "reflect" + "testing" +) + +func TestParseRange(t *testing.T) { + testCases := []struct { + target string + expected *Range + rest string + }{ + {"", nil, ""}, + {"e", nil, "e"}, + {" ", nil, ""}, + {"$", &Range{End{}, nil}, ""}, + {" $-72 , $-36 ", &Range{End{-72}, End{-36}}, ""}, + {"32", &Range{Absolute{32}, nil}, ""}, + {"+32", &Range{Relative{32}, nil}, ""}, + {"-32", &Range{Relative{-32}, nil}, ""}, + {"1024,4096", &Range{Absolute{1024}, Absolute{4096}}, ""}, + {"1+2+3+4+5+6+7+8+9,0xa+0xb+0xc+0xD+0xE+0xF", &Range{Absolute{45}, Absolute{75}}, ""}, + {"10d", &Range{Absolute{10}, nil}, "d"}, + {"0x12G", &Range{Absolute{0x12}, nil}, "G"}, + {"0x10fag", &Range{Absolute{0x10fa}, nil}, "g"}, + {".-100,.+100", &Range{Relative{-100}, Relative{100}}, ""}, + {"'", nil, "'"}, + {"' ", nil, "' "}, + {"'<", &Range{VisualStart{}, nil}, ""}, + {"'>", &Range{VisualEnd{}, nil}, ""}, + {" '< , '> write", &Range{VisualStart{}, VisualEnd{}}, "write"}, + {" '<+0x10 , '>-10w", &Range{VisualStart{0x10}, VisualEnd{-10}}, "w"}, + } + for _, testCase := range testCases { + got, rest := ParseRange(testCase.target) + if !reflect.DeepEqual(got, testCase.expected) || rest != testCase.rest { + t.Errorf("ParseRange(%q) should return\n\t%#v, %q\nbut got\n\t%#v, %q", + testCase.target, testCase.expected, testCase.rest, got, rest) + } + } +} diff --git a/bed/event/range.go b/bed/event/range.go new file mode 100644 index 0000000..697408f --- /dev/null +++ b/bed/event/range.go @@ -0,0 +1,45 @@ +package event + +// Range of event +type Range struct { + From Position + To Position +} + +// Position ... +type Position interface{ add(int64) Position } + +// Absolute is the absolute position of the buffer. +type Absolute struct{ Offset int64 } + +func (p Absolute) add(offset int64) Position { + return Absolute{p.Offset + offset} +} + +// Relative is the relative position of the buffer. +type Relative struct{ Offset int64 } + +func (p Relative) add(offset int64) Position { + return Relative{p.Offset + offset} +} + +// End is the end of the buffer. +type End struct{ Offset int64 } + +func (p End) add(offset int64) Position { + return End{p.Offset + offset} +} + +// VisualStart is the start position of visual selection. +type VisualStart struct{ Offset int64 } + +func (p VisualStart) add(offset int64) Position { + return VisualStart{p.Offset + offset} +} + +// VisualEnd is the end position of visual selection. +type VisualEnd struct{ Offset int64 } + +func (p VisualEnd) add(offset int64) Position { + return VisualEnd{p.Offset + offset} +} diff --git a/bed/history/history.go b/bed/history/history.go new file mode 100644 index 0000000..97ff170 --- /dev/null +++ b/bed/history/history.go @@ -0,0 +1,56 @@ +package history + +import "b612.me/apps/b612/bed/buffer" + +// History manages the buffer history. +type History struct { + entries []*historyEntry + index int +} + +type historyEntry struct { + buffer *buffer.Buffer + offset int64 + cursor int64 + tick uint64 +} + +// NewHistory creates a new history manager. +func NewHistory() *History { + return &History{index: -1} +} + +// Push a new buffer to the history. +func (h *History) Push(buffer *buffer.Buffer, offset, cursor int64, tick uint64) { + newEntry := &historyEntry{buffer.Clone(), offset, cursor, tick} + if len(h.entries)-1 > h.index { + h.index++ + h.entries[h.index] = newEntry + h.entries = h.entries[:h.index+1] + } else { + h.entries = append(h.entries, newEntry) + h.index++ + } +} + +// Undo the history. +func (h *History) Undo() (*buffer.Buffer, int, int64, int64, uint64) { + if h.index < 0 { + return nil, h.index, 0, 0, 0 + } + if h.index > 0 { + h.index-- + } + e := h.entries[h.index] + return e.buffer.Clone(), h.index, e.offset, e.cursor, e.tick +} + +// Redo the history. +func (h *History) Redo() (*buffer.Buffer, int64, int64, uint64) { + if h.index == len(h.entries)-1 || h.index < 0 { + return nil, 0, 0, 0 + } + h.index++ + e := h.entries[h.index] + return e.buffer.Clone(), e.offset, e.cursor, e.tick +} diff --git a/bed/history/history_test.go b/bed/history/history_test.go new file mode 100644 index 0000000..73bf21a --- /dev/null +++ b/bed/history/history_test.go @@ -0,0 +1,99 @@ +package history + +import ( + "strings" + "testing" + + "b612.me/apps/b612/bed/buffer" +) + +func TestHistoryUndo(t *testing.T) { + history := NewHistory() + b, index, offset, cursor, tick := history.Undo() + if b != nil { + t.Errorf("history.Undo should return nil buffer but got %v", b) + } + if index != -1 { + t.Errorf("history.Undo should return index -1 but got %d", index) + } + if offset != 0 { + t.Errorf("history.Undo should return offset 0 but got %d", offset) + } + if cursor != 0 { + t.Errorf("history.Undo should return cursor 0 but got %d", cursor) + } + if tick != 0 { + t.Errorf("history.Undo should return tick 0 but got %d", tick) + } + + buffer1 := buffer.NewBuffer(strings.NewReader("test1")) + history.Push(buffer1, 2, 1, 1) + + buffer2 := buffer.NewBuffer(strings.NewReader("test2")) + history.Push(buffer2, 3, 2, 2) + + buf := make([]byte, 8) + b, index, offset, cursor, tick = history.Undo() + if b == nil { + t.Fatalf("history.Undo should return buffer but got nil") + } + _, err := b.Read(buf) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "test1\x00\x00\x00"; string(buf) != expected { + t.Errorf("buf should be %q but got %q", expected, string(buf)) + } + if index != 0 { + t.Errorf("history.Undo should return index 0 but got %d", index) + } + if offset != 2 { + t.Errorf("history.Undo should return offset 2 but got %d", offset) + } + if cursor != 1 { + t.Errorf("history.Undo should return cursor 1 but got %d", cursor) + } + if tick != 1 { + t.Errorf("history.Undo should return tick 1 but got %d", tick) + } + + buf = make([]byte, 8) + b, offset, cursor, tick = history.Redo() + if b == nil { + t.Fatalf("history.Redo should return buffer but got nil") + } + _, err = b.Read(buf) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "test2\x00\x00\x00"; string(buf) != expected { + t.Errorf("buf should be %q but got %q", expected, string(buf)) + } + if offset != 3 { + t.Errorf("history.Redo should return offset 3 but got %d", offset) + } + if cursor != 2 { + t.Errorf("history.Redo should return cursor 2 but got %d", cursor) + } + if tick != 2 { + t.Errorf("history.Redo should return cursor 2 but got %d", tick) + } + + history.Undo() + buffer3 := buffer.NewBuffer(strings.NewReader("test2")) + history.Push(buffer3, 3, 2, 3) + + b, offset, cursor, tick = history.Redo() + if b != nil { + t.Errorf("history.Redo should return nil buffer but got %v", b) + } + if offset != 0 { + t.Errorf("history.Redo should return offset 0 but got %d", offset) + } + if cursor != 0 { + t.Errorf("history.Redo should return cursor 0 but got %d", cursor) + } + if tick != 0 { + t.Errorf("history.Redo should return tick 0 but got %d", tick) + } +} diff --git a/bed/key/key.go b/bed/key/key.go new file mode 100644 index 0000000..5802e52 --- /dev/null +++ b/bed/key/key.go @@ -0,0 +1,91 @@ +package key + +import ( + "strconv" + + "b612.me/apps/b612/bed/event" +) + +// Key represents one keyboard stroke. +type Key string + +type keyEvent struct { + keys []Key + event event.Type + bang bool +} + +const ( + keysEq = iota + keysPending + keysNeq +) + +func (ke keyEvent) cmp(ks []Key) int { + if len(ke.keys) < len(ks) { + return keysNeq + } + for i, k := range ke.keys { + if i >= len(ks) { + return keysPending + } + if k != ks[i] { + return keysNeq + } + } + return keysEq +} + +// Manager holds the key mappings and current key sequence. +type Manager struct { + keys []Key + events []keyEvent + count bool +} + +// NewManager creates a new Manager. +func NewManager(count bool) *Manager { + return &Manager{count: count} +} + +// Register adds a new key mapping. +func (km *Manager) Register(eventType event.Type, keys ...Key) { + km.events = append(km.events, keyEvent{keys, eventType, false}) +} + +// RegisterBang adds a new key mapping with bang. +func (km *Manager) RegisterBang(eventType event.Type, keys ...Key) { + km.events = append(km.events, keyEvent{keys, eventType, true}) +} + +// Press checks the new key down event. +func (km *Manager) Press(k Key) event.Event { + km.keys = append(km.keys, k) + for i := range len(km.keys) { + keys := km.keys[i:] + var count int64 + if km.count { + numStr := "" + for j, k := range keys { + if len(k) == 1 && ('1' <= k[0] && k[0] <= '9' || k[0] == '0' && j > 0) { + numStr += string(k) + } else { + break + } + } + keys = keys[len(numStr):] + count, _ = strconv.ParseInt(numStr, 10, 64) + } + for _, ke := range km.events { + switch ke.cmp(keys) { + case keysPending: + return event.Event{Type: event.Nop} + case keysEq: + km.keys = nil + return event.Event{Type: ke.event, Count: count, Bang: ke.bang} + } + } + } + km.keys = nil + return event.Event{Type: event.Nop} +} diff --git a/bed/key/key_test.go b/bed/key/key_test.go new file mode 100644 index 0000000..3a04ecf --- /dev/null +++ b/bed/key/key_test.go @@ -0,0 +1,71 @@ +package key + +import ( + "testing" + + "b612.me/apps/b612/bed/event" +) + +func TestKeyManagerPress(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k") + e := km.Press("k") + if e.Type != event.CursorUp { + t.Errorf("pressing k should emit event.CursorUp but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.Nop { + t.Errorf("pressing j should be nop but got: %d", e.Type) + } +} + +func TestKeyManagerPressMulti(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k", "k", "j") + km.Register(event.CursorDown, "k", "j", "j") + km.Register(event.CursorDown, "j", "k", "k") + e := km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k twice should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k three times should be nop but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.CursorUp { + t.Errorf("pressing kkj should emit event.CursorUp but got: %d", e.Type) + } +} + +func TestKeyManagerPressCount(t *testing.T) { + km := NewManager(true) + km.Register(event.CursorUp, "k", "j") + e := km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("3") + if e.Type != event.Nop { + t.Errorf("pressing 3 should be nop but got: %d", e.Type) + } + e = km.Press("7") + if e.Type != event.Nop { + t.Errorf("pressing 7 should be nop but got: %d", e.Type) + } + e = km.Press("k") + if e.Type != event.Nop { + t.Errorf("pressing k should be nop but got: %d", e.Type) + } + e = km.Press("j") + if e.Type != event.CursorUp { + t.Errorf("pressing 37kj should emit event.CursorUp but got: %d", e.Type) + } + if e.Count != 37 { + t.Errorf("pressing 37kj should emit event.CursorUp with count 37 but got: %d", e.Count) + } +} diff --git a/bed/layout/layout.go b/bed/layout/layout.go new file mode 100644 index 0000000..1df376c --- /dev/null +++ b/bed/layout/layout.go @@ -0,0 +1,500 @@ +package layout + +// Layout represents the window layout. +type Layout interface { + isLayout() + Collect() map[int]Window + Replace(int) Layout + Resize(int, int, int, int) Layout + LeftMargin() int + TopMargin() int + Width() int + Height() int + SplitTop(int) Layout + SplitBottom(int) Layout + SplitLeft(int) Layout + SplitRight(int) Layout + Count() (int, int) + Activate(int) Layout + ActivateFirst() Layout + ActiveWindow() Window + Lookup(func(Window) bool) Window + Close() Layout +} + +// Window holds the window index and it is active or not. +type Window struct { + Index int + Active bool + left int + top int + width int + height int +} + +// NewLayout creates a new Layout from a window index. +func NewLayout(index int) Layout { + return Window{Index: index, Active: true} +} + +func (Window) isLayout() {} + +// Collect returns all the Window. +func (l Window) Collect() map[int]Window { + return map[int]Window{l.Index: l} +} + +// Replace the active window with new window index. +func (l Window) Replace(index int) Layout { + if l.Active { + // revive:disable-next-line:modifies-value-receiver + l.Index = index + } + return l +} + +// Resize recalculates the position. +func (l Window) Resize(left, top, width, height int) Layout { + // revive:disable-next-line:modifies-value-receiver + l.left, l.top, l.width, l.height = left, top, width, height + return l +} + +// LeftMargin returns the left margin. +func (l Window) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Window) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Window) Width() int { + return l.width +} + +// Height returns the height. +func (l Window) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Window) SplitTop(index int) Layout { + if !l.Active { + return l + } + return Horizontal{ + Top: Window{Index: index, Active: true}, + Bottom: Window{Index: l.Index, Active: false}, + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Window) SplitBottom(index int) Layout { + if !l.Active { + return l + } + return Horizontal{ + Top: Window{Index: l.Index, Active: false}, + Bottom: Window{Index: index, Active: true}, + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Window) SplitLeft(index int) Layout { + if !l.Active { + return l + } + return Vertical{ + Left: Window{Index: index, Active: true}, + Right: Window{Index: l.Index, Active: false}, + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Window) SplitRight(index int) Layout { + if !l.Active { + return l + } + return Vertical{ + Left: Window{Index: l.Index, Active: false}, + Right: Window{Index: index, Active: true}, + } +} + +// Count returns the width and height counts. +func (Window) Count() (int, int) { + return 1, 1 +} + +// Activate the specific window layout. +func (l Window) Activate(i int) Layout { + // revive:disable-next-line:modifies-value-receiver + l.Active = l.Index == i + return l +} + +// ActivateFirst the first layout. +func (l Window) ActivateFirst() Layout { + // revive:disable-next-line:modifies-value-receiver + l.Active = true + return l +} + +// ActiveWindow returns the active window. +func (l Window) ActiveWindow() Window { + if l.Active { + return l + } + return Window{Index: -1} +} + +// Lookup search for the window meets the condition. +func (l Window) Lookup(cond func(Window) bool) Window { + if cond(l) { + return l + } + return Window{Index: -1} +} + +// Close the active layout. +func (l Window) Close() Layout { + if l.Active { + panic("Active Window should not be closed") + } + return l +} + +// Horizontal holds two layout horizontally. +type Horizontal struct { + Top Layout + Bottom Layout + left int + top int + width int + height int +} + +func (Horizontal) isLayout() {} + +// Collect returns all the Window. +func (l Horizontal) Collect() map[int]Window { + m := l.Top.Collect() + for i, l := range l.Bottom.Collect() { + m[i] = l + } + return m +} + +// Replace the active window with new window index. +func (l Horizontal) Replace(index int) Layout { + return Horizontal{ + Top: l.Top.Replace(index), + Bottom: l.Bottom.Replace(index), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// Resize recalculates the position. +func (l Horizontal) Resize(left, top, width, height int) Layout { + _, h1 := l.Top.Count() + _, h2 := l.Bottom.Count() + topHeight := height * h1 / (h1 + h2) + return Horizontal{ + Top: l.Top.Resize(left, top, width, topHeight), + Bottom: l.Bottom.Resize(left, top+topHeight, width, height-topHeight), + left: left, + top: top, + width: width, + height: height, + } +} + +// LeftMargin returns the left margin. +func (l Horizontal) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Horizontal) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Horizontal) Width() int { + return l.width +} + +// Height returns the height. +func (l Horizontal) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Horizontal) SplitTop(index int) Layout { + return Horizontal{ + Top: l.Top.SplitTop(index), + Bottom: l.Bottom.SplitTop(index), + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Horizontal) SplitBottom(index int) Layout { + return Horizontal{ + Top: l.Top.SplitBottom(index), + Bottom: l.Bottom.SplitBottom(index), + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Horizontal) SplitLeft(index int) Layout { + return Horizontal{ + Top: l.Top.SplitLeft(index), + Bottom: l.Bottom.SplitLeft(index), + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Horizontal) SplitRight(index int) Layout { + return Horizontal{ + Top: l.Top.SplitRight(index), + Bottom: l.Bottom.SplitRight(index), + } +} + +// Count returns the width and height counts. +func (l Horizontal) Count() (int, int) { + w1, h1 := l.Top.Count() + w2, h2 := l.Bottom.Count() + return max(w1, w2), h1 + h2 +} + +// Activate the specific window layout. +func (l Horizontal) Activate(i int) Layout { + return Horizontal{ + Top: l.Top.Activate(i), + Bottom: l.Bottom.Activate(i), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActivateFirst the first layout. +func (l Horizontal) ActivateFirst() Layout { + return Horizontal{ + Top: l.Top.ActivateFirst(), + Bottom: l.Bottom, + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActiveWindow returns the active window. +func (l Horizontal) ActiveWindow() Window { + if layout := l.Top.ActiveWindow(); layout.Index >= 0 { + return layout + } + return l.Bottom.ActiveWindow() +} + +// Lookup search for the window meets the condition. +func (l Horizontal) Lookup(cond func(Window) bool) Window { + if layout := l.Top.Lookup(cond); layout.Index >= 0 { + return layout + } + return l.Bottom.Lookup(cond) +} + +// Close the active layout. +func (l Horizontal) Close() Layout { + if m, ok := l.Top.(Window); ok { + if m.Active { + return l.Bottom.ActivateFirst() + } + } + if m, ok := l.Bottom.(Window); ok { + if m.Active { + return l.Top.ActivateFirst() + } + } + return Horizontal{ + Top: l.Top.Close(), + Bottom: l.Bottom.Close(), + } +} + +// Vertical holds two layout vertically. +type Vertical struct { + Left Layout + Right Layout + left int + top int + width int + height int +} + +func (Vertical) isLayout() {} + +// Collect returns all the Window. +func (l Vertical) Collect() map[int]Window { + m := l.Left.Collect() + for i, l := range l.Right.Collect() { + m[i] = l + } + return m +} + +// Replace the active window with new window index. +func (l Vertical) Replace(index int) Layout { + return Vertical{ + Left: l.Left.Replace(index), + Right: l.Right.Replace(index), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// Resize recalculates the position. +func (l Vertical) Resize(left, top, width, height int) Layout { + w1, _ := l.Left.Count() + w2, _ := l.Right.Count() + leftWidth := width * w1 / (w1 + w2) + return Vertical{ + Left: l.Left.Resize(left, top, leftWidth, height), + Right: l.Right.Resize( + min(left+leftWidth+1, left+width), top, + max(width-leftWidth-1, 0), height), + left: left, + top: top, + width: width, + height: height, + } +} + +// LeftMargin returns the left margin. +func (l Vertical) LeftMargin() int { + return l.left +} + +// TopMargin returns the top margin. +func (l Vertical) TopMargin() int { + return l.top +} + +// Width returns the width. +func (l Vertical) Width() int { + return l.width +} + +// Height returns the height. +func (l Vertical) Height() int { + return l.height +} + +// SplitTop splits the layout and opens a new window to the top. +func (l Vertical) SplitTop(index int) Layout { + return Vertical{ + Left: l.Left.SplitTop(index), + Right: l.Right.SplitTop(index), + } +} + +// SplitBottom splits the layout and opens a new window to the bottom. +func (l Vertical) SplitBottom(index int) Layout { + return Vertical{ + Left: l.Left.SplitBottom(index), + Right: l.Right.SplitBottom(index), + } +} + +// SplitLeft splits the layout and opens a new window to the left. +func (l Vertical) SplitLeft(index int) Layout { + return Vertical{ + Left: l.Left.SplitLeft(index), + Right: l.Right.SplitLeft(index), + } +} + +// SplitRight splits the layout and opens a new window to the right. +func (l Vertical) SplitRight(index int) Layout { + return Vertical{ + Left: l.Left.SplitRight(index), + Right: l.Right.SplitRight(index), + } +} + +// Count returns the width and height counts. +func (l Vertical) Count() (int, int) { + w1, h1 := l.Left.Count() + w2, h2 := l.Right.Count() + return w1 + w2, max(h1, h2) +} + +// Activate the specific window layout. +func (l Vertical) Activate(i int) Layout { + return Vertical{ + Left: l.Left.Activate(i), + Right: l.Right.Activate(i), + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActivateFirst the first layout. +func (l Vertical) ActivateFirst() Layout { + return Vertical{ + Left: l.Left.ActivateFirst(), + Right: l.Right, + left: l.left, + top: l.top, + width: l.width, + height: l.height, + } +} + +// ActiveWindow returns the active window. +func (l Vertical) ActiveWindow() Window { + if layout := l.Left.ActiveWindow(); layout.Index >= 0 { + return layout + } + return l.Right.ActiveWindow() +} + +// Lookup search for the window meets the condition. +func (l Vertical) Lookup(cond func(Window) bool) Window { + if layout := l.Left.Lookup(cond); layout.Index >= 0 { + return layout + } + return l.Right.Lookup(cond) +} + +// Close the active layout. +func (l Vertical) Close() Layout { + if m, ok := l.Left.(Window); ok { + if m.Active { + return l.Right.ActivateFirst() + } + } + if m, ok := l.Right.(Window); ok { + if m.Active { + return l.Left.ActivateFirst() + } + } + return Vertical{ + Left: l.Left.Close(), + Right: l.Right.Close(), + } +} diff --git a/bed/layout/layout_test.go b/bed/layout/layout_test.go new file mode 100644 index 0000000..181dbcf --- /dev/null +++ b/bed/layout/layout_test.go @@ -0,0 +1,307 @@ +package layout + +import ( + "reflect" + "testing" +) + +func TestLayout(t *testing.T) { + layout := NewLayout(0) + + layout = layout.SplitTop(1) + layout = layout.SplitLeft(2) + layout = layout.SplitBottom(3) + layout = layout.SplitRight(4) + + var expected Layout + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false}, + Bottom: Vertical{ + Left: Window{Index: 3, Active: false}, + Right: Window{Index: 4, Active: true}, + }, + }, + Right: Window{Index: 1, Active: false}, + }, + Bottom: Window{Index: 0, Active: false}, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + w, h := layout.Count() + if w != 3 { + t.Errorf("layout width be %d but got %d", 3, w) + } + if h != 3 { + t.Errorf("layout height be %d but got %d", 3, h) + } + + layout = layout.Resize(0, 0, 15, 15) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 10, height: 5}, + Bottom: Vertical{ + Left: Window{Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5}, + Right: Window{Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5}, + left: 0, + top: 5, + width: 10, + height: 5, + }, + left: 0, + top: 0, + width: 10, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + expectedWindow := Window{Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10} + got := layout.Lookup(func(l Window) bool { return l.Index == 1 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 1) should be %+v but got %+v", expectedWindow, got) + } + + if got.LeftMargin() != 11 { + t.Errorf("LeftMargin() should be %d but got %d", 11, got.LeftMargin()) + } + if got.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but got %d", 0, got.TopMargin()) + } + if got.Width() != 4 { + t.Errorf("Width() should be %d but got %d", 4, got.Width()) + } + if got.Height() != 10 { + t.Errorf("Height() should be %d but got %d", 10, got.Height()) + } + + expectedWindow = Window{Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5} + got = layout.Lookup(func(l Window) bool { return l.Index == 3 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 3) should be %+v but got %+v", expectedWindow, got) + } + + if got.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but got %d", 0, got.LeftMargin()) + } + if got.TopMargin() != 5 { + t.Errorf("TopMargin() should be %d but got %d", 5, got.TopMargin()) + } + if got.Width() != 5 { + t.Errorf("Width() should be %d but got %d", 5, got.Width()) + } + if got.Height() != 5 { + t.Errorf("Height() should be %d but got %d", 5, got.Height()) + } + + expectedWindow = Window{Index: -1} + got = layout.Lookup(func(l Window) bool { return l.Index == 5 }) + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("Lookup(Index == 5) should be %+v but got %+v", expectedWindow, got) + } + + expectedWindow = Window{Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5} + got = layout.ActiveWindow() + if !reflect.DeepEqual(got, expectedWindow) { + t.Errorf("ActiveWindow() should be %+v but got %+v", expectedWindow, got) + } + + expectedMap := map[int]Window{ + 0: {Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + 1: {Index: 1, Active: false, left: 11, top: 0, width: 4, height: 10}, + 2: {Index: 2, Active: false, left: 0, top: 0, width: 10, height: 5}, + 3: {Index: 3, Active: false, left: 0, top: 5, width: 5, height: 5}, + 4: {Index: 4, Active: true, left: 6, top: 5, width: 4, height: 5}, + } + + if !reflect.DeepEqual(layout.Collect(), expectedMap) { + t.Errorf("Collect should be %+v but got %+v", expectedMap, layout.Collect()) + } + + layout = layout.Close().Resize(0, 0, 15, 15) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 3, Active: true, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + if layout.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but layout %d", 0, layout.LeftMargin()) + } + if layout.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but layout %d", 0, layout.TopMargin()) + } + if layout.Width() != 15 { + t.Errorf("Width() should be %d but layout %d", 15, layout.Width()) + } + if layout.Height() != 15 { + t.Errorf("Height() should be %d but layout %d", 15, layout.Height()) + } + + expectedMap = map[int]Window{ + 0: {Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + 1: {Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + 2: {Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + 3: {Index: 3, Active: true, left: 0, top: 5, width: 7, height: 5}, + } + + if !reflect.DeepEqual(layout.Collect(), expectedMap) { + t.Errorf("Collect should be %+v but got %+v", expectedMap, layout.Collect()) + } + + w, h = layout.Count() + if w != 2 { + t.Errorf("layout width be %d but got %d", 3, w) + } + if h != 3 { + t.Errorf("layout height be %d but got %d", 3, h) + } + + layout = layout.Replace(5) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 5, Active: true, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: false, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + layout = layout.Activate(1) + + expected = Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 0, top: 0, width: 7, height: 5}, + Bottom: Window{Index: 5, Active: false, left: 0, top: 5, width: 7, height: 5}, + left: 0, + top: 0, + width: 7, + height: 10, + }, + Right: Window{Index: 1, Active: true, left: 8, top: 0, width: 7, height: 10}, + left: 0, + top: 0, + width: 15, + height: 10, + }, + Bottom: Window{Index: 0, Active: false, left: 0, top: 10, width: 15, height: 5}, + left: 0, + top: 0, + width: 15, + height: 15, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + layout = Vertical{ + Left: Window{Index: 6, Active: false}, + Right: layout, + }.SplitLeft(7).SplitTop(8).Resize(0, 0, 15, 10) + + expected = Vertical{ + Left: Window{Index: 6, Active: false, left: 0, top: 0, width: 3, height: 10}, + Right: Horizontal{ + Top: Vertical{ + Left: Horizontal{ + Top: Window{Index: 2, Active: false, left: 4, top: 0, width: 3, height: 3}, + Bottom: Window{Index: 5, Active: false, left: 4, top: 3, width: 3, height: 3}, + left: 4, top: 0, width: 3, height: 6, + }, + Right: Vertical{ + Left: Horizontal{ + Top: Window{Index: 8, Active: true, left: 8, top: 0, width: 3, height: 3}, + Bottom: Window{Index: 7, Active: false, left: 8, top: 3, width: 3, height: 3}, + left: 8, top: 0, width: 3, height: 6, + }, + Right: Window{Index: 1, Active: false, left: 12, top: 0, width: 3, height: 6}, + left: 8, top: 0, width: 7, height: 6, + }, + left: 4, top: 0, width: 11, height: 6, + }, + Bottom: Window{Index: 0, Active: false, left: 4, top: 6, width: 11, height: 4}, + left: 4, top: 0, width: 11, height: 10, + }, + left: 0, top: 0, width: 15, height: 10, + } + + if !reflect.DeepEqual(layout, expected) { + t.Errorf("layout should be %#v but got %#v", expected, layout) + } + + if layout.LeftMargin() != 0 { + t.Errorf("LeftMargin() should be %d but layout %d", 0, layout.LeftMargin()) + } + if layout.TopMargin() != 0 { + t.Errorf("TopMargin() should be %d but layout %d", 0, layout.TopMargin()) + } + if layout.Width() != 15 { + t.Errorf("Width() should be %d but layout %d", 15, layout.Width()) + } + if layout.Height() != 10 { + t.Errorf("Height() should be %d but layout %d", 10, layout.Height()) + } +} diff --git a/bed/mode/mode.go b/bed/mode/mode.go new file mode 100644 index 0000000..d16ee7e --- /dev/null +++ b/bed/mode/mode.go @@ -0,0 +1,14 @@ +package mode + +// Mode ... +type Mode int + +// Modes +const ( + Normal Mode = iota + Insert + Replace + Visual + Cmdline + Search +) diff --git a/bed/searcher/pattern.go b/bed/searcher/pattern.go new file mode 100644 index 0000000..91d45c8 --- /dev/null +++ b/bed/searcher/pattern.go @@ -0,0 +1,155 @@ +package searcher + +import ( + "errors" + "unicode/utf8" +) + +func patternToTarget(pattern string) ([]byte, error) { + if len(pattern) > 3 && pattern[0] == '0' { + switch pattern[1] { + case 'x', 'X': + return decodeHexLiteral(pattern) + case 'b', 'B': + return decodeBinLiteral(pattern) + } + } + return unescapePattern(pattern), nil +} + +func decodeHexLiteral(pattern string) ([]byte, error) { + bs := make([]byte, 0, len(pattern)/2+1) + var c byte + var lower bool + for i := 2; i < len(pattern); i++ { + if !isHex(pattern[i]) { + return nil, errors.New("invalid hex pattern: " + pattern) + } + c = c<<4 | hexToDigit(pattern[i]) + if lower { + bs = append(bs, c) + c = 0 + } + lower = !lower + } + if lower { + bs = append(bs, c<<4) + } + return bs, nil +} + +func decodeBinLiteral(pattern string) ([]byte, error) { + bs := make([]byte, 0, len(pattern)/16+1) + var c byte + var bits int + for i := 2; i < len(pattern); i++ { + if !isBin(pattern[i]) { + return nil, errors.New("invalid bin pattern: " + pattern) + } + c = c<<1 | hexToDigit(pattern[i]) + bits++ + if bits == 8 { + bits = 0 + bs = append(bs, c) + c = 0 + } + } + if bits > 0 { + bs = append(bs, c<= 0 { + return base + int64(i), nil + } + return -1, nil +} + +func (s *Searcher) backward() (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + target, err := patternToTarget(s.pattern) + if err != nil { + return -1, err + } + base := max(0, s.cursor-int64(loadSize)) + size := int(min(int64(loadSize), s.cursor)) + n, err := s.r.ReadAt(s.bytes[:size], base) + if err != nil && err != io.EOF { + return -1, err + } + if n == 0 { + return -1, errNotFound(s.pattern) + } + if s.cursor == int64(n) { + s.cursor = 0 + } else { + s.cursor = base + int64(len(target)-1) + } + i := bytes.LastIndex(s.bytes[:n], target) + if i >= 0 { + return base + int64(i), nil + } + return -1, nil +} + +func (s *Searcher) loop(f func() (int64, error), ch chan<- any) { + if s.loopCh != nil { + close(s.loopCh) + } + loopCh := make(chan struct{}) + s.loopCh = loopCh + go func() { + defer close(ch) + for { + select { + case <-loopCh: + return + case <-time.After(10 * time.Millisecond): + idx, err := f() + if err != nil { + ch <- err + return + } + if idx >= 0 { + ch <- idx + return + } + } + } + }() +} + +// Abort the searching. +func (s *Searcher) Abort() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.loopCh != nil { + close(s.loopCh) + s.loopCh = nil + return errors.New("search is aborted") + } + return nil +} diff --git a/bed/searcher/searcher_test.go b/bed/searcher/searcher_test.go new file mode 100644 index 0000000..10c8d99 --- /dev/null +++ b/bed/searcher/searcher_test.go @@ -0,0 +1,181 @@ +package searcher + +import ( + "strings" + "testing" +) + +func TestSearcher(t *testing.T) { + testCases := []struct { + name string + str string + cursor int64 + pattern string + forward bool + expected int64 + err error + }{ + { + name: "search forward", + str: "abcde", + cursor: 0, + pattern: "cd", + forward: true, + expected: 2, + }, + { + name: "search forward but not found", + str: "abcde", + cursor: 2, + pattern: "cd", + forward: true, + err: errNotFound("cd"), + }, + { + name: "search backward", + str: "abcde", + cursor: 4, + pattern: "bc", + forward: false, + expected: 1, + }, + { + name: "search backward but not found", + str: "abcde", + cursor: 0, + pattern: "ba", + forward: true, + err: errNotFound("ba"), + }, + { + name: "search large target forward", + str: strings.Repeat(" ", 10*1024*1024+100) + "abcde", + cursor: 102, + pattern: "bcd", + forward: true, + expected: 10*1024*1024 + 101, + }, + { + name: "search large target forward but not found", + str: strings.Repeat(" ", 10*1024*1024+100) + "abcde", + cursor: 102, + pattern: "cba", + forward: true, + err: errNotFound("cba"), + }, + { + name: "search large target backward", + str: "abcde" + strings.Repeat(" ", 10*1024*1024), + cursor: 10*1024*1024 + 2, + pattern: "bcd", + forward: false, + expected: 1, + }, + { + name: "search large target backward but not found", + str: "abcde" + strings.Repeat(" ", 10*1024*1024), + cursor: 10*1024*1024 + 2, + pattern: "cba", + forward: false, + err: errNotFound("cba"), + }, + { + name: "search hex", + str: "\x13\x24\x35\x46\x57\x68", + cursor: 0, + pattern: `\x35\x46\x57`, + forward: true, + expected: 2, + }, + { + name: "search nul", + str: "\x06\x07\x08\x00\x09\x10\x11", + cursor: 0, + pattern: `\0`, + forward: true, + expected: 3, + }, + { + name: "search bell and bs", + str: "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x09\x0a", + cursor: 0, + pattern: `\a\b\v`, + forward: true, + expected: 7, + }, + { + name: "search tab", + str: "\x06\x07\x08\x09\x10\x11", + cursor: 0, + pattern: `\t`, + forward: true, + expected: 3, + }, + { + name: "search escape character", + str: `ab\cd\\e`, + cursor: 0, + pattern: `\\\`, + forward: true, + expected: 5, + }, + { + name: "search unicode", + str: "\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf", + cursor: 0, + pattern: `\u3061\u306F`, + forward: true, + expected: 9, + }, + { + name: "search unicode in supplementary multilingual plane", + str: "\U0001F604\U0001F606\U0001F60E\U0001F60D\U0001F642", + cursor: 0, + pattern: `\U0001F60E\U0001F60D`, + forward: true, + expected: 8, + }, + { + name: "search hex literal", + str: "\x16\x27\x38\x49\x50\x61", + cursor: 0, + pattern: `0x38495`, + forward: true, + expected: 2, + }, + { + name: "search bin literal", + str: "\x16\x27\x38\x48\x50\x61", + cursor: 0, + pattern: `0b0011100001001`, + forward: true, + expected: 2, + }, + { + name: "search text starting with 0", + str: "432101234", + cursor: 0, + pattern: `0123`, + forward: true, + expected: 4, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + s := NewSearcher(strings.NewReader(testCase.str)) + ch := s.Search(testCase.cursor, testCase.pattern, testCase.forward) + switch x := (<-ch).(type) { + case error: + if testCase.err == nil { + t.Error(x) + } else if x != testCase.err { + t.Errorf("Error should be %v but got %v", testCase.err, x) + } + case int64: + if x != testCase.expected { + t.Errorf("Search result should be %d but got %d", testCase.expected, x) + } + } + }) + } +} diff --git a/bed/state/state.go b/bed/state/state.go new file mode 100644 index 0000000..f60d63b --- /dev/null +++ b/bed/state/state.go @@ -0,0 +1,45 @@ +package state + +import ( + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" +) + +// State holds the state of the editor to display the user interface. +type State struct { + Mode mode.Mode + PrevMode mode.Mode + WindowStates map[int]*WindowState + Layout layout.Layout + Cmdline []rune + CmdlineCursor int + CompletionResults []string + CompletionIndex int + SearchMode rune + Error error + ErrorType int +} + +// WindowState holds the state of one window. +type WindowState struct { + Name string + Modified bool + Width int + Offset int64 + Cursor int64 + Bytes []byte + Size int + Length int64 + Mode mode.Mode + Pending bool + PendingByte byte + VisualStart int64 + EditedIndices []int64 + FocusText bool +} + +// Message types +const ( + MessageInfo = iota + MessageError +) diff --git a/bed/tui/key.go b/bed/tui/key.go new file mode 100644 index 0000000..3716fe1 --- /dev/null +++ b/bed/tui/key.go @@ -0,0 +1,71 @@ +package tui + +import ( + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/key" +) + +func eventToKey(event *tcell.EventKey) key.Key { + if key, ok := keyMap[event.Key()]; ok { + return key + } + return key.Key(event.Rune()) +} + +var keyMap = map[tcell.Key]key.Key{ + tcell.KeyF1: key.Key("f1"), + tcell.KeyF2: key.Key("f2"), + tcell.KeyF3: key.Key("f3"), + tcell.KeyF4: key.Key("f4"), + tcell.KeyF5: key.Key("f5"), + tcell.KeyF6: key.Key("f6"), + tcell.KeyF7: key.Key("f7"), + tcell.KeyF8: key.Key("f8"), + tcell.KeyF9: key.Key("f9"), + tcell.KeyF10: key.Key("f10"), + tcell.KeyF11: key.Key("f11"), + tcell.KeyF12: key.Key("f12"), + + tcell.KeyInsert: key.Key("insert"), + tcell.KeyDelete: key.Key("delete"), + tcell.KeyHome: key.Key("home"), + tcell.KeyEnd: key.Key("end"), + tcell.KeyPgUp: key.Key("pgup"), + tcell.KeyPgDn: key.Key("pgdn"), + + tcell.KeyUp: key.Key("up"), + tcell.KeyDown: key.Key("down"), + tcell.KeyLeft: key.Key("left"), + tcell.KeyRight: key.Key("right"), + + tcell.KeyCtrlA: key.Key("c-a"), + tcell.KeyCtrlB: key.Key("c-b"), + tcell.KeyCtrlC: key.Key("c-c"), + tcell.KeyCtrlD: key.Key("c-d"), + tcell.KeyCtrlE: key.Key("c-e"), + tcell.KeyCtrlF: key.Key("c-f"), + tcell.KeyCtrlG: key.Key("c-g"), + tcell.KeyBackspace: key.Key("backspace"), + tcell.KeyTab: key.Key("tab"), + tcell.KeyBacktab: key.Key("backtab"), + tcell.KeyCtrlJ: key.Key("c-j"), + tcell.KeyCtrlK: key.Key("c-k"), + tcell.KeyCtrlL: key.Key("c-l"), + tcell.KeyEnter: key.Key("enter"), + tcell.KeyCtrlN: key.Key("c-n"), + tcell.KeyCtrlO: key.Key("c-o"), + tcell.KeyCtrlP: key.Key("c-p"), + tcell.KeyCtrlQ: key.Key("c-q"), + tcell.KeyCtrlR: key.Key("c-r"), + tcell.KeyCtrlS: key.Key("c-s"), + tcell.KeyCtrlT: key.Key("c-t"), + tcell.KeyCtrlU: key.Key("c-u"), + tcell.KeyCtrlV: key.Key("c-v"), + tcell.KeyCtrlW: key.Key("c-w"), + tcell.KeyCtrlX: key.Key("c-x"), + tcell.KeyCtrlY: key.Key("c-y"), + tcell.KeyCtrlZ: key.Key("c-z"), + tcell.KeyEsc: key.Key("escape"), + tcell.KeyBackspace2: key.Key("backspace2"), +} diff --git a/bed/tui/region.go b/bed/tui/region.go new file mode 100644 index 0000000..df8badd --- /dev/null +++ b/bed/tui/region.go @@ -0,0 +1,20 @@ +package tui + +import "b612.me/apps/b612/bed/layout" + +type region struct { + left, top, height, width int +} + +func fromLayout(l layout.Layout) region { + return region{ + left: l.LeftMargin(), + top: l.TopMargin(), + height: l.Height(), + width: l.Width(), + } +} + +func (r region) valid() bool { + return 0 <= r.left && 0 <= r.top && 0 < r.height && 0 < r.width +} diff --git a/bed/tui/text_drawer.go b/bed/tui/text_drawer.go new file mode 100644 index 0000000..4e61b13 --- /dev/null +++ b/bed/tui/text_drawer.go @@ -0,0 +1,63 @@ +package tui + +import ( + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" +) + +type textDrawer struct { + top, left, offset int + region region + screen tcell.Screen +} + +func (d *textDrawer) setString(str string, style tcell.Style) { + top := d.region.top + d.top + left := d.region.left + d.left + d.offset + right := d.region.left + d.region.width + for _, c := range str { + w := runewidth.RuneWidth(c) + if left+w > right { + break + } + if left+w == right && c != ' ' { + if int(style)&int(tcell.AttrReverse) != 0 { + d.screen.SetContent(left, top, ' ', nil, style) + } + break + } + d.screen.SetContent(left, top, c, nil, style) + left += w + } +} + +func (d *textDrawer) setByte(b byte, style tcell.Style) { + top := d.region.top + d.top + left := d.region.left + d.left + d.offset + d.screen.SetContent(left, top, rune(b), nil, style) +} + +func (d *textDrawer) setTop(top int) *textDrawer { + d.top = top + return d +} + +func (d *textDrawer) addTop(diff int) *textDrawer { + d.top += diff + return d +} + +func (d *textDrawer) setLeft(left int) *textDrawer { + d.left = left + return d +} + +func (d *textDrawer) addLeft(diff int) *textDrawer { + d.left += diff + return d +} + +func (d *textDrawer) setOffset(offset int) *textDrawer { + d.offset = offset + return d +} diff --git a/bed/tui/tui.go b/bed/tui/tui.go new file mode 100644 index 0000000..c714b64 --- /dev/null +++ b/bed/tui/tui.go @@ -0,0 +1,196 @@ +package tui + +import ( + "bytes" + "strings" + "sync" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +// Tui implements UI +type Tui struct { + eventCh chan<- event.Event + mode mode.Mode + screen tcell.Screen + waitCh chan struct{} + mu *sync.Mutex +} + +// NewTui creates a new Tui. +func NewTui() *Tui { + return &Tui{mu: new(sync.Mutex)} +} + +// Init initializes the Tui. +func (ui *Tui) Init(eventCh chan<- event.Event) (err error) { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.eventCh = eventCh + ui.mode = mode.Normal + if ui.screen, err = tcell.NewScreen(); err != nil { + return + } + ui.waitCh = make(chan struct{}) + return ui.screen.Init() +} + +// Run the Tui. +func (ui *Tui) Run(kms map[mode.Mode]*key.Manager) { + for { + e := ui.screen.PollEvent() + switch ev := e.(type) { + case *tcell.EventKey: + var e event.Event + if km, ok := kms[ui.getMode()]; ok { + e = km.Press(eventToKey(ev)) + } + if e.Type != event.Nop { + ui.eventCh <- e + } else { + ui.eventCh <- event.Event{Type: event.Rune, Rune: ev.Rune()} + } + case *tcell.EventResize: + if ui.eventCh != nil { + ui.eventCh <- event.Event{Type: event.Redraw} + } + case nil: + close(ui.waitCh) + return + } + } +} + +func (ui *Tui) getMode() mode.Mode { + ui.mu.Lock() + defer ui.mu.Unlock() + return ui.mode +} + +// Size returns the size for the screen. +func (ui *Tui) Size() (int, int) { + return ui.screen.Size() +} + +// Redraw redraws the state. +func (ui *Tui) Redraw(s state.State) error { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.mode = s.Mode + ui.screen.Clear() + ui.drawWindows(s.WindowStates, s.Layout) + ui.drawCmdline(s) + ui.screen.Show() + return nil +} + +func (ui *Tui) setLine(line, offset int, str string, style tcell.Style) { + for _, c := range str { + ui.screen.SetContent(offset, line, c, nil, style) + offset += runewidth.RuneWidth(c) + } +} + +func (ui *Tui) drawWindows(windowStates map[int]*state.WindowState, l layout.Layout) { + switch l := l.(type) { + case layout.Window: + r := fromLayout(l) + if ws, ok := windowStates[l.Index]; ok && r.valid() { + ui.newTuiWindow(r).drawWindow(ws, + l.Active && ui.mode != mode.Cmdline && ui.mode != mode.Search) + } + case layout.Horizontal: + ui.drawWindows(windowStates, l.Top) + ui.drawWindows(windowStates, l.Bottom) + case layout.Vertical: + ui.drawWindows(windowStates, l.Left) + ui.drawWindows(windowStates, l.Right) + ui.drawVerticalSplit(fromLayout(l.Left)) + } +} + +func (ui *Tui) newTuiWindow(region region) *tuiWindow { + return &tuiWindow{region: region, screen: ui.screen} +} + +func (ui *Tui) drawVerticalSplit(region region) { + for i := range region.height { + ui.setLine(region.top+i, region.left+region.width, "|", tcell.StyleDefault.Reverse(true)) + } +} + +func (ui *Tui) drawCmdline(s state.State) { + var cmdline string + style := tcell.StyleDefault + width, height := ui.Size() + switch { + case s.Error != nil: + cmdline = s.Error.Error() + if s.ErrorType == state.MessageInfo { + style = style.Foreground(tcell.ColorYellow) + } else { + style = style.Foreground(tcell.ColorRed) + } + case s.Mode == mode.Cmdline: + if len(s.CompletionResults) > 0 { + ui.drawCompletionResults(s.CompletionResults, s.CompletionIndex, width, height) + } + ui.screen.ShowCursor(1+runewidth.StringWidth(string(s.Cmdline[:s.CmdlineCursor])), height-1) + fallthrough + case s.PrevMode == mode.Cmdline && len(s.Cmdline) > 0: + cmdline = ":" + string(s.Cmdline) + case s.Mode == mode.Search: + ui.screen.ShowCursor(1+runewidth.StringWidth(string(s.Cmdline[:s.CmdlineCursor])), height-1) + fallthrough + case s.SearchMode != '\x00': + cmdline = string(s.SearchMode) + string(s.Cmdline) + default: + return + } + ui.setLine(height-1, 0, cmdline, style) +} + +func (ui *Tui) drawCompletionResults(results []string, index, width, height int) { + var line bytes.Buffer + var left, right int + for i, result := range results { + size := runewidth.StringWidth(result) + 2 + if i <= index { + left, right = right, right+size + if right > width { + line.Reset() + left, right = 0, size + } + } else if right < width { + right += size + } else { + break + } + line.WriteString(" ") + line.WriteString(result) + line.WriteString(" ") + } + line.WriteString(strings.Repeat(" ", max(width-right, 0))) + ui.setLine(height-2, 0, line.String(), tcell.StyleDefault.Reverse(true)) + if index >= 0 { + ui.setLine(height-2, left, " "+results[index]+" ", + tcell.StyleDefault.Foreground(tcell.ColorGrey).Reverse(true)) + } +} + +// Close terminates the Tui. +func (ui *Tui) Close() error { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.eventCh = nil + ui.screen.Fini() + <-ui.waitCh + return nil +} diff --git a/bed/tui/tui_test.go b/bed/tui/tui_test.go new file mode 100644 index 0000000..d354d15 --- /dev/null +++ b/bed/tui/tui_test.go @@ -0,0 +1,415 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/key" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +func (ui *Tui) initForTest(eventCh chan<- event.Event, screen tcell.SimulationScreen) (err error) { + ui.eventCh = eventCh + ui.mode = mode.Normal + ui.screen = screen + ui.waitCh = make(chan struct{}) + return ui.screen.Init() +} + +func mockKeyManager() map[mode.Mode]*key.Manager { + kms := make(map[mode.Mode]*key.Manager) + km := key.NewManager(true) + km.Register(event.Quit, "Z", "Q") + km.Register(event.CursorDown, "j") + kms[mode.Normal] = km + return kms +} + +func getContents(screen tcell.SimulationScreen) string { + width, _ := screen.Size() + cells, _, _ := screen.GetContents() + var runes []rune + for i, cell := range cells { + runes = append(runes, cell.Runes...) + if (i+1)%width == 0 { + runes = append(runes, '\n') + } + } + return string(runes) +} + +func shouldContain(t *testing.T, screen tcell.SimulationScreen, expected []string) { + got := getContents(screen) + for _, str := range expected { + if !strings.Contains(got, str) { + t.Errorf("screen should contain %q but got\n%v", str, got) + } + } +} + +func TestTuiRun(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + go ui.Run(mockKeyManager()) + + screen.InjectKey(tcell.KeyRune, 'Z', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, 'Q', tcell.ModNone) + e := <-eventCh + if e.Type != event.Rune { + t.Errorf("pressing Z should emit event.Rune but got: %+v", e) + } + e = <-eventCh + if e.Type != event.Quit { + t.Errorf("pressing ZQ should emit event.Quit but got: %+v", e) + } + screen.InjectKey(tcell.KeyRune, '7', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, '0', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, '9', tcell.ModNone) + screen.InjectKey(tcell.KeyRune, 'j', tcell.ModNone) + e = <-eventCh + e = <-eventCh + e = <-eventCh + e = <-eventCh + if e.Type != event.CursorDown { + t.Errorf("pressing 709j should emit event.CursorDown but got: %+v", e) + } + if e.Count != 709 { + t.Errorf("pressing 709j should emit event with count %d but got: %+v", 709, e) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiEmpty(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte(strings.Repeat("\x00", 16*(height-1))), + Size: 16 * (height - 1), + Length: 0, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000010 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000020 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " 000100 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " [No name] : 0x00 : '\\x00' 0/0 : 0x000000/0x000000 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiScrollBar(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(90, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "", + Modified: true, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte(strings.Repeat("a", 16*(height-1))), + Size: 16 * (height - 1), + Length: int64(16 * (height - 1) * 3), + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa # ", + " 000050 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa # ", + " 000060 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa | ", + " 000100 | 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 | aaaaaaaaaaaaaaaa | ", + " [No name] : + : 0x61 : 'a' 0/912 : 0x000000/0x000390 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiHorizontalSplit(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(110, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "test0", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 0." + strings.Repeat("\x00", 110*10)), + Size: 110 * 10, + Length: 600, + Mode: mode.Normal, + }, + 1: { + Name: "test1", + Modified: false, + Width: 16, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 1." + strings.Repeat(" ", 110*10)), + Size: 110 * 10, + Length: 800, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).SplitBottom(1).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " 000000 | 54 65 73 74 20 77 69 6e 64 6f 77 20 30 2e 00 00 | Test window 0... #", + " 000010 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................ #", + " test0 : 0x54 : 'T' 0/600 : 0x000000/0x000258 : 0.00%", + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f | ", + " 000000 | 54 65 73 74 20 77 69 6e 64 6f 77 20 31 2e 20 20 | Test window 1. #", + " 000010 | 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | #", + " test1 : 0x54 : 'T' 0/800 : 0x000000/0x000320 : 0.00%", + }) + + x, y, visible := screen.GetCursor() + if x != 10 || y != 10 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 10, 10, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiVerticalSplit(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(110, 20) + width, height := screen.Size() + go ui.Run(mockKeyManager()) + + s := state.State{ + WindowStates: map[int]*state.WindowState{ + 0: { + Name: "test0", + Modified: false, + Width: 8, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 0." + strings.Repeat("\x00", 55*19)), + Size: 55 * 19, + Length: 600, + Mode: mode.Normal, + }, + 1: { + Name: "test1", + Modified: false, + Width: 8, + Offset: 0, + Cursor: 0, + Bytes: []byte("Test window 1." + strings.Repeat(" ", 54*19)), + Size: 54 * 19, + Length: 800, + Mode: mode.Normal, + }, + }, + Layout: layout.NewLayout(0).SplitRight(1).Resize(0, 0, width, height-1), + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " | 0 1 2 3 4 5 6 7 | | | 0 1 2 3 4 5 6 7 |", + " 000000 | 54 65 73 74 20 77 69 6e | Test win # | 000000 | 54 65 73 74 20 77 69 6e | Test win #", + " 000008 | 64 6f 77 20 30 2e 00 00 | dow 0... # | 000008 | 64 6f 77 20 31 2e 20 20 | dow 1. #", + " 000010 | 00 00 00 00 00 00 00 00 | ........ # | 000010 | 20 20 20 20 20 20 20 20 | #", + " test0 : 0x54 : 'T' 0/600 : 0x000000/0x000258 : 0.00% | test1 : 0x54 : 'T' 0/800 : 0x000000/0x000320 : 0.00", + }) + + x, y, visible := screen.GetCursor() + if x != 66 || y != 1 { + t.Errorf("cursor position should be (%d, %d) but got (%d, %d)", 66, 1, x, y) + } + if visible != true { + t.Errorf("cursor should be visible but got %v", visible) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiCmdline(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(20, 15) + getCmdline := func() string { + cells, _, _ := screen.GetContents() + var runes []rune + for _, cell := range cells[20*14:] { + runes = append(runes, cell.Runes...) + } + return string(runes) + } + go ui.Run(mockKeyManager()) + + s := state.State{ + Mode: mode.Cmdline, + Cmdline: []rune("vnew test"), + CmdlineCursor: 9, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + got, expected := getCmdline(), ":vnew test " + if !strings.HasPrefix(got, expected) { + t.Errorf("cmdline should start with %q but got %q", expected, got) + } + + s = state.State{ + Mode: mode.Normal, + Error: errors.New("error"), + Cmdline: []rune("vnew test"), + CmdlineCursor: 9, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + got, expected = getCmdline(), "error " + if !strings.HasPrefix(got, expected) { + t.Errorf("cmdline should start with %q but got %q", expected, got) + } + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} + +func TestTuiCmdlineCompletionCandidates(t *testing.T) { + ui := NewTui() + eventCh := make(chan event.Event) + screen := tcell.NewSimulationScreen("") + if err := ui.initForTest(eventCh, screen); err != nil { + t.Fatal(err) + } + screen.SetSize(20, 15) + go ui.Run(mockKeyManager()) + + s := state.State{ + Mode: mode.Cmdline, + Cmdline: []rune("new test2"), + CmdlineCursor: 9, + CompletionResults: []string{"test1", "test2", "test3", "test9/", "/bin/ls"}, + CompletionIndex: 1, + } + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " test1 test2 test3", + ":new test2", + }) + + s.CompletionIndex += 2 + s.Cmdline = []rune("new test9/") + if err := ui.Redraw(s); err != nil { + t.Errorf("ui.Redraw should return nil but got: %v", err) + } + + shouldContain(t, screen, []string{ + " test3 test9/ /bin", + ":new test9/", + }) + if err := ui.Close(); err != nil { + t.Errorf("ui.Close should return nil but got %v", err) + } +} diff --git a/bed/tui/tui_window.go b/bed/tui/tui_window.go new file mode 100644 index 0000000..83ff07f --- /dev/null +++ b/bed/tui/tui_window.go @@ -0,0 +1,225 @@ +package tui + +import ( + "cmp" + "fmt" + + "github.com/gdamore/tcell" + + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/state" +) + +type tuiWindow struct { + region region + screen tcell.Screen +} + +func (ui *tuiWindow) getTextDrawer() *textDrawer { + return &textDrawer{region: ui.region, screen: ui.screen} +} + +func (ui *tuiWindow) setCursor(line, offset int) { + ui.screen.ShowCursor(ui.region.left+offset, ui.region.top+line) +} + +func offsetStyleWidth(s *state.WindowState) int { + threshold := int64(0xfffff) + for i := range 10 { + if s.Length <= threshold { + return 6 + i + } + threshold = (threshold << 4) | 0x0f + } + return 16 +} + +func (ui *tuiWindow) drawWindow(s *state.WindowState, active bool) { + height, width := ui.region.height-2, s.Width + cursorPos := int(s.Cursor - s.Offset) + cursorLine := cursorPos / width + offsetStyleWidth := offsetStyleWidth(s) + eis := s.EditedIndices + for 0 < len(eis) && eis[1] <= s.Offset { + eis = eis[2:] + } + editedColor := tcell.ColorLightSeaGreen + d := ui.getTextDrawer() + var k int + for i := range height { + d.addTop(1).setLeft(0).setOffset(0) + d.setString( + fmt.Sprintf(" %0*x", offsetStyleWidth, s.Offset+int64(i*width)), + tcell.StyleDefault.Bold(i == cursorLine), + ) + d.setLeft(offsetStyleWidth + 3) + for j := range width { + b, style := byte(0), tcell.StyleDefault + if s.Pending && i*width+j == cursorPos { + b, style = s.PendingByte, tcell.StyleDefault.Foreground(editedColor) + if s.Mode != mode.Replace { + k-- + } + } else if k >= s.Size { + if k == cursorPos { + d.setOffset(3*j+1).setByte(' ', tcell.StyleDefault.Underline(!active || s.FocusText)) + d.setOffset(3*width+j+3).setByte(' ', tcell.StyleDefault.Underline(!active || !s.FocusText)) + } + k++ + continue + } else { + b = s.Bytes[k] + pos := int64(k) + s.Offset + if 0 < len(eis) && eis[0] <= pos && pos < eis[1] { + style = tcell.StyleDefault.Foreground(editedColor) + } else if 0 < len(eis) && eis[1] <= pos { + eis = eis[2:] + } + if s.VisualStart >= 0 && s.Cursor < s.Length && + (s.VisualStart <= pos && pos <= s.Cursor || + s.Cursor <= pos && pos <= s.VisualStart) { + style = style.Underline(true) + } + } + style1, style2 := style, style + if i*width+j == cursorPos { + style1 = style1.Reverse(active && !s.FocusText).Bold( + !active || s.FocusText).Underline(!active || s.FocusText) + style2 = style2.Reverse(active && s.FocusText).Bold( + !active || !s.FocusText).Underline(!active || !s.FocusText) + } + d.setOffset(3*j+1).setByte(hex[b>>4], style1) + d.setOffset(3*j+2).setByte(hex[b&0x0f], style1) + d.setOffset(3*width+j+3).setByte(prettyByte(b), style2) + k++ + } + d.setOffset(-2).setByte(' ', tcell.StyleDefault) + d.setOffset(-1).setByte('|', tcell.StyleDefault) + d.setOffset(0).setByte(' ', tcell.StyleDefault) + d.addLeft(3*width).setByte(' ', tcell.StyleDefault) + d.setOffset(1).setByte('|', tcell.StyleDefault) + d.setOffset(2).setByte(' ', tcell.StyleDefault) + } + i := int(s.Cursor % int64(width)) + if active { + if s.FocusText { + ui.setCursor(cursorLine+1, 3*width+i+6+offsetStyleWidth) + } else if s.Pending { + ui.setCursor(cursorLine+1, 3*i+5+offsetStyleWidth) + } else { + ui.setCursor(cursorLine+1, 3*i+4+offsetStyleWidth) + } + } + ui.drawHeader(s, offsetStyleWidth) + ui.drawScrollBar(s, height, 4*width+7+offsetStyleWidth) + ui.drawFooter(s, offsetStyleWidth) +} + +const hex = "0123456789abcdef" + +func (ui *tuiWindow) drawHeader(s *state.WindowState, offsetStyleWidth int) { + style := tcell.StyleDefault.Underline(true) + d := ui.getTextDrawer().setLeft(-1) + cursor := int(s.Cursor % int64(s.Width)) + for range offsetStyleWidth + 2 { + d.addLeft(1).setByte(' ', style) + } + d.addLeft(1).setByte('|', style) + for i := range s.Width { + d.addLeft(1).setByte(' ', style) + d.addLeft(1).setByte(" 123456789abcdef"[i>>4], style.Bold(cursor == i)) + d.addLeft(1).setByte(hex[i&0x0f], style.Bold(cursor == i)) + } + d.addLeft(1).setByte(' ', style) + d.addLeft(1).setByte('|', style) + for range s.Width + 3 { + d.addLeft(1).setByte(' ', style) + } +} + +func (ui *tuiWindow) drawScrollBar(s *state.WindowState, height, left int) { + stateSize := s.Size + if s.Cursor+1 == s.Length && s.Cursor == s.Offset+int64(s.Size) { + stateSize++ + } + total := int64((stateSize + s.Width - 1) / s.Width) + length := max((s.Length+int64(s.Width)-1)/int64(s.Width), 1) + size := max(total*total/length, 1) + pad := (total*total + length - length*size - 1) / max(total-size+1, 1) + top := (s.Offset / int64(s.Width) * total) / (length - pad) + d := ui.getTextDrawer().setLeft(left) + for i := range height { + var b byte + if int(top) <= i && i < int(top+size) { + b = '#' + } else { + b = '|' + } + d.addTop(1).setByte(b, tcell.StyleDefault) + } +} + +func (ui *tuiWindow) drawFooter(s *state.WindowState, offsetStyleWidth int) { + var modified string + if s.Modified { + modified = " : +" + } + b := s.Bytes[int(s.Cursor-s.Offset)] + left := fmt.Sprintf(" %s%s%s : 0x%02x : '%s'", + prettyMode(s.Mode), cmp.Or(s.Name, "[No name]"), modified, b, prettyRune(b)) + right := fmt.Sprintf("%[1]d/%[2]d : 0x%0[3]*[1]x/0x%0[3]*[2]x : %.2[4]f%% ", + s.Cursor, s.Length, offsetStyleWidth, float64(s.Cursor*100)/float64(max(s.Length, 1))) + line := fmt.Sprintf("%s %*s", left, max(ui.region.width-len(left)-2, 0), right) + ui.getTextDrawer().setTop(ui.region.height-1).setString(line, tcell.StyleDefault.Reverse(true)) +} + +func prettyByte(b byte) byte { + switch { + case 0x20 <= b && b < 0x7f: + return b + default: + return 0x2e + } +} + +func prettyRune(b byte) string { + switch b { + case 0x07: + return "\\a" + case 0x08: + return "\\b" + case 0x09: + return "\\t" + case 0x0a: + return "\\n" + case 0x0b: + return "\\v" + case 0x0c: + return "\\f" + case 0x0d: + return "\\r" + case 0x27: + return "\\'" + default: + if b < 0x20 { + return fmt.Sprintf("\\x%02x", b) + } else if b < 0x7f { + return string(rune(b)) + } else { + return fmt.Sprintf("\\u%04x", b) + } + } +} + +func prettyMode(m mode.Mode) string { + switch m { + case mode.Insert: + return "[INSERT] " + case mode.Replace: + return "[REPLACE] " + case mode.Visual: + return "[VISUAL] " + default: + return "" + } +} diff --git a/bed/window/manager.go b/bed/window/manager.go new file mode 100644 index 0000000..4c7de65 --- /dev/null +++ b/bed/window/manager.go @@ -0,0 +1,701 @@ +package window + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/bits" + "math/rand" + "os" + "os/exec" + "os/signal" + "os/user" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/state" +) + +// Manager manages the windows and files. +type Manager struct { + width int + height int + windows []*window + layout layout.Layout + mu *sync.Mutex + windowIndex int + prevWindowIndex int + prevDir string + files map[string]file + eventCh chan<- event.Event + redrawCh chan<- struct{} +} + +type file struct { + path string + file *os.File + perm os.FileMode +} + +// NewManager creates a new Manager. +func NewManager() *Manager { + return &Manager{} +} + +// Init initializes the Manager. +func (m *Manager) Init(eventCh chan<- event.Event, redrawCh chan<- struct{}) { + m.eventCh, m.redrawCh = eventCh, redrawCh + m.mu, m.files = new(sync.Mutex), make(map[string]file) +} + +// Open a new window. +func (m *Manager) Open(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open(name) + if err != nil { + return err + } + return m.init(window) +} + +// Read opens a new window from [io.Reader]. +func (m *Manager) Read(r io.Reader) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.read(r) +} + +func (m *Manager) init(window *window) error { + m.addWindow(window) + m.layout = layout.NewLayout(m.windowIndex).Resize(0, 0, m.width, m.height) + return nil +} + +func (m *Manager) addWindow(window *window) { + for i, w := range m.windows { + if w == window { + m.windowIndex, m.prevWindowIndex = i, m.windowIndex + return + } + } + m.windows = append(m.windows, window) + m.windowIndex, m.prevWindowIndex = len(m.windows)-1, m.windowIndex +} + +func (m *Manager) open(name string) (*window, error) { + if name == "" { + window, err := newWindow(bytes.NewReader(nil), "", "", m.eventCh, m.redrawCh) + if err != nil { + return nil, err + } + return window, nil + } + if name == "#" { + return m.windows[m.prevWindowIndex], nil + } + if strings.HasPrefix(name, "#") { + index, err := strconv.Atoi(name[1:]) + if err != nil || index <= 0 || len(m.windows) < index { + return nil, errors.New("invalid window index: " + name) + } + return m.windows[index-1], nil + } + name, err := expandBacktick(name) + if err != nil { + return nil, err + } + path, err := expandPath(name) + if err != nil { + return nil, err + } + r, err := m.openFile(path, name) + if err != nil { + return nil, err + } + return newWindow(r, path, filepath.Base(path), m.eventCh, m.redrawCh) +} + +func (m *Manager) openFile(path, name string) (readAtSeeker, error) { + fi, err := os.Stat(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + return bytes.NewReader(nil), nil + } else if fi.IsDir() { + return nil, errors.New(name + " is a directory") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + m.addFile(path, f, fi) + return f, nil +} + +func expandBacktick(name string) (string, error) { + if len(name) <= 2 || name[0] != '`' || name[len(name)-1] != '`' { + return name, nil + } + name = strings.TrimSpace(name[1 : len(name)-1]) + xs := strings.Fields(name) + if len(xs) < 1 { + return name, nil + } + out, err := exec.Command(xs[0], xs[1:]...).Output() + if err != nil { + return name, err + } + return strings.TrimSpace(string(out)), nil +} + +func expandPath(path string) (string, error) { + switch { + case strings.HasPrefix(path, "~"): + if name, rest, _ := strings.Cut(path[1:], string(filepath.Separator)); name != "" { + user, err := user.Lookup(name) + if err != nil { + return path, nil + } + return filepath.Join(user.HomeDir, rest), nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, path[1:]), nil + case strings.HasPrefix(path, "$"): + name, rest, _ := strings.Cut(path[1:], string(filepath.Separator)) + value := os.Getenv(name) + if value == "" { + return path, nil + } + return filepath.Join(value, rest), nil + default: + return filepath.Abs(path) + } +} + +func (m *Manager) read(r io.Reader) error { + bs, err := func() ([]byte, error) { + r, stop := newReader(r) + defer stop() + return io.ReadAll(r) + }() + if err != nil { + return err + } + window, err := newWindow(bytes.NewReader(bs), "", "", m.eventCh, m.redrawCh) + if err != nil { + return err + } + return m.init(window) +} + +type reader struct { + io.Reader + abort chan os.Signal +} + +func newReader(r io.Reader) (*reader, func()) { + done := make(chan struct{}) + abort := make(chan os.Signal, 1) + signal.Notify(abort, os.Interrupt) + go func() { + select { + case <-time.After(time.Second): + fmt.Fprint(os.Stderr, "Reading stdin took more than 1 second, press to abort...") + case <-done: + } + }() + return &reader{r, abort}, func() { + signal.Stop(abort) + close(abort) + close(done) + } +} + +func (r *reader) Read(p []byte) (int, error) { + select { + case <-r.abort: + return 0, io.EOF + default: + } + return r.Reader.Read(p) +} + +// SetSize sets the size of the screen. +func (m *Manager) SetSize(width, height int) { + m.width, m.height = width, height +} + +// Resize sets the size of the screen. +func (m *Manager) Resize(width, height int) { + if m.width != width || m.height != height { + m.mu.Lock() + defer m.mu.Unlock() + m.width, m.height = width, height + m.layout = m.layout.Resize(0, 0, width, height) + } +} + +// Emit an event to the current window. +func (m *Manager) Emit(e event.Event) { + switch e.Type { + case event.Edit: + if err := m.edit(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Enew: + if err := m.enew(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.New: + if err := m.newWindow(e, false); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Vnew: + if err := m.newWindow(e, true); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Only: + if err := m.only(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Alternative: + m.alternative(e) + m.eventCh <- event.Event{Type: event.Redraw} + case event.Wincmd: + if e.Arg == "" { + m.eventCh <- event.Event{Type: event.Error, + Error: errors.New("an argument is required for " + e.CmdName)} + } else if err := m.wincmd(e.Arg); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowDown: + if err := m.wincmd("j"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowUp: + if err := m.wincmd("k"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowLeft: + if err := m.wincmd("h"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowRight: + if err := m.wincmd("l"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowTopLeft: + if err := m.wincmd("t"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowBottomRight: + if err := m.wincmd("b"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.FocusWindowPrevious: + if err := m.wincmd("p"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowTop: + if err := m.wincmd("K"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowBottom: + if err := m.wincmd("J"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowLeft: + if err := m.wincmd("H"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.MoveWindowRight: + if err := m.wincmd("L"); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Redraw} + } + case event.Pwd: + if e.Arg != "" { + m.eventCh <- event.Event{Type: event.Error, Error: errors.New("too many arguments for " + e.CmdName)} + break + } + fallthrough + case event.Chdir: + if dir, err := m.chdir(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Info, Error: errors.New(dir)} + } + case event.Quit: + if err := m.quit(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } + case event.Write: + if name, n, err := m.write(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else { + m.eventCh <- event.Event{Type: event.Info, + Error: fmt.Errorf("%s: %[2]d (0x%[2]x) bytes written", name, n)} + } + case event.WriteQuit: + if _, _, err := m.write(e); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } else if err := m.quit(event.Event{Bang: e.Bang}); err != nil { + m.eventCh <- event.Event{Type: event.Error, Error: err} + } + default: + m.windows[m.windowIndex].emit(e) + } +} + +func (m *Manager) edit(e event.Event) error { + m.mu.Lock() + defer m.mu.Unlock() + name := e.Arg + if name == "" { + name = m.windows[m.windowIndex].path + } + window, err := m.open(name) + if err != nil { + return err + } + m.addWindow(window) + m.layout = m.layout.Replace(m.windowIndex) + return nil +} + +func (m *Manager) enew(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open("") + if err != nil { + return err + } + m.addWindow(window) + m.layout = m.layout.Replace(m.windowIndex) + return nil +} + +func (m *Manager) newWindow(e event.Event, vertical bool) error { + m.mu.Lock() + defer m.mu.Unlock() + window, err := m.open(e.Arg) + if err != nil { + return err + } + m.addWindow(window) + if vertical { + m.layout = m.layout.SplitLeft(m.windowIndex).Resize(0, 0, m.width, m.height) + } else { + m.layout = m.layout.SplitTop(m.windowIndex).Resize(0, 0, m.width, m.height) + } + return nil +} + +func (m *Manager) only(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + if !e.Bang { + for windowIndex, w := range m.layout.Collect() { + if window := m.windows[windowIndex]; !w.Active && window.changedTick != window.savedChangedTick { + return errors.New("you have unsaved changes in " + window.getName() + ", add ! to force :only") + } + } + } + m.layout = layout.NewLayout(m.windowIndex).Resize(0, 0, m.width, m.height) + return nil +} + +func (m *Manager) alternative(e event.Event) { + m.mu.Lock() + defer m.mu.Unlock() + if e.Count == 0 { + m.windowIndex, m.prevWindowIndex = m.prevWindowIndex, m.windowIndex + } else if 0 < e.Count && e.Count <= int64(len(m.windows)) { + m.windowIndex, m.prevWindowIndex = int(e.Count)-1, m.windowIndex + } + m.layout = m.layout.Replace(m.windowIndex) +} + +func (m *Manager) wincmd(arg string) error { + switch arg { + case "n": + return m.newWindow(event.Event{}, false) + case "o": + return m.only(event.Event{}) + case "l": + m.focus(func(x, y layout.Window) bool { + return x.LeftMargin()+x.Width()+1 == y.LeftMargin() && + y.TopMargin() <= x.TopMargin() && + x.TopMargin() < y.TopMargin()+y.Height() + }) + case "h": + m.focus(func(x, y layout.Window) bool { + return y.LeftMargin()+y.Width()+1 == x.LeftMargin() && + y.TopMargin() <= x.TopMargin() && + x.TopMargin() < y.TopMargin()+y.Height() + }) + case "k": + m.focus(func(x, y layout.Window) bool { + return y.TopMargin()+y.Height() == x.TopMargin() && + y.LeftMargin() <= x.LeftMargin() && + x.LeftMargin() < y.LeftMargin()+y.Width() + }) + case "j": + m.focus(func(x, y layout.Window) bool { + return x.TopMargin()+x.Height() == y.TopMargin() && + y.LeftMargin() <= x.LeftMargin() && + x.LeftMargin() < y.LeftMargin()+y.Width() + }) + case "t": + m.focus(func(_, y layout.Window) bool { + return y.LeftMargin() == 0 && y.TopMargin() == 0 + }) + case "b": + m.focus(func(_, y layout.Window) bool { + return m.layout.LeftMargin()+m.layout.Width() == y.LeftMargin()+y.Width() && + m.layout.TopMargin()+m.layout.Height() == y.TopMargin()+y.Height() + }) + case "p": + m.focus(func(_, y layout.Window) bool { + return y.Index == m.prevWindowIndex + }) + case "K": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Horizontal{Top: x, Bottom: y} + }) + case "J": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Horizontal{Top: y, Bottom: x} + }) + case "H": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Vertical{Left: x, Right: y} + }) + case "L": + m.move(func(x layout.Window, y layout.Layout) layout.Layout { + return layout.Vertical{Left: y, Right: x} + }) + default: + return errors.New("Invalid argument for wincmd: " + arg) + } + return nil +} + +func (m *Manager) focus(search func(layout.Window, layout.Window) bool) { + m.mu.Lock() + defer m.mu.Unlock() + activeWindow := m.layout.ActiveWindow() + newWindow := m.layout.Lookup(func(l layout.Window) bool { + return search(activeWindow, l) + }) + if newWindow.Index >= 0 { + m.windowIndex, m.prevWindowIndex = newWindow.Index, m.windowIndex + m.layout = m.layout.Activate(m.windowIndex) + } +} + +func (m *Manager) move(modifier func(layout.Window, layout.Layout) layout.Layout) { + m.mu.Lock() + defer m.mu.Unlock() + w, h := m.layout.Count() + if w != 1 || h != 1 { + activeWindow := m.layout.ActiveWindow() + m.layout = modifier(activeWindow, m.layout.Close()).Activate( + activeWindow.Index).Resize(0, 0, m.width, m.height) + } +} + +func (m *Manager) chdir(e event.Event) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if e.Arg == "-" && m.prevDir == "" { + return "", errors.New("no previous working directory") + } + dir, err := os.Getwd() + if err != nil { + return "", err + } + if e.Arg == "" { + return dir, nil + } + if e.Arg != "-" { + dir, m.prevDir = e.Arg, dir + } else { + dir, m.prevDir = m.prevDir, dir + } + if dir, err = expandPath(dir); err != nil { + return "", err + } + if err = os.Chdir(dir); err != nil { + return "", err + } + return os.Getwd() +} + +func (m *Manager) quit(e event.Event) error { + if e.Arg != "" { + return errors.New("too many arguments for " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window := m.windows[m.windowIndex] + if window.changedTick != window.savedChangedTick && !e.Bang { + return errors.New("you have unsaved changes in " + window.getName() + ", add ! to force :quit") + } + w, h := m.layout.Count() + if w == 1 && h == 1 { + m.eventCh <- event.Event{Type: event.QuitAll} + } else { + m.layout = m.layout.Close().Resize(0, 0, m.width, m.height) + m.windowIndex, m.prevWindowIndex = m.layout.ActiveWindow().Index, m.windowIndex + m.eventCh <- event.Event{Type: event.Redraw} + } + return nil +} + +func (m *Manager) write(e event.Event) (string, int64, error) { + if e.Range != nil && e.Arg == "" { + return "", 0, errors.New("cannot overwrite partially with " + e.CmdName) + } + m.mu.Lock() + defer m.mu.Unlock() + window := m.windows[m.windowIndex] + var path string + name := e.Arg + if name == "" { + if window.name == "" { + return "", 0, errors.New("no file name") + } + path, name = window.path, window.name + } else { + var err error + path, err = expandPath(name) + if err != nil { + return "", 0, err + } + } + if runtime.GOOS == "windows" && m.opened(path) { + return "", 0, errors.New("cannot overwrite the original file on Windows") + } + if window.path == "" && window.name == "" { + window.setPathName(path, filepath.Base(path)) + } + tmpf, err := os.OpenFile( + path+"-"+strconv.FormatUint(rand.Uint64(), 36), + os.O_RDWR|os.O_CREATE|os.O_EXCL, m.filePerm(path), + ) //#nosec G404 + if err != nil { + return "", 0, err + } + defer os.Remove(tmpf.Name()) + n, err := window.writeTo(e.Range, tmpf) + if err != nil { + _ = tmpf.Close() + return "", 0, err + } + if err = tmpf.Close(); err != nil { + return "", 0, err + } + if window.path == path { + window.savedChangedTick = window.changedTick + } + return name, n, os.Rename(tmpf.Name(), path) +} + +func (m *Manager) addFile(path string, f *os.File, fi os.FileInfo) { + m.files[path] = file{path: path, file: f, perm: fi.Mode().Perm()} +} + +func (m *Manager) opened(path string) bool { + _, ok := m.files[path] + return ok +} + +func (m *Manager) filePerm(path string) os.FileMode { + if f, ok := m.files[path]; ok { + return f.perm + } + return os.FileMode(0o644) +} + +// State returns the state of the windows. +func (m *Manager) State() (map[int]*state.WindowState, layout.Layout, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + layouts := m.layout.Collect() + states := make(map[int]*state.WindowState, len(m.windows)) + for i, window := range m.windows { + if l, ok := layouts[i]; ok { + var err error + if states[i], err = window.state( + hexWindowWidth(l.Width()), max(l.Height()-2, 1), + ); err != nil { + return nil, m.layout, 0, err + } + } + } + return states, m.layout, m.windowIndex, nil +} + +func hexWindowWidth(width int) int { + width = min(max((width-18)/4, 4), 256) + return width & (0b11 << (bits.Len(uint(width)) - 2)) +} + +// Close the Manager. +func (m *Manager) Close() { + for _, f := range m.files { + _ = f.file.Close() + } +} diff --git a/bed/window/manager_test.go b/bed/window/manager_test.go new file mode 100644 index 0000000..f2e0d03 --- /dev/null +++ b/bed/window/manager_test.go @@ -0,0 +1,708 @@ +package window + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/layout" + "b612.me/apps/b612/bed/mode" +) + +func createTemp(dir, contents string) (*os.File, error) { + f, err := os.CreateTemp(dir, "") + if err != nil { + return nil, err + } + if _, err = f.WriteString(contents); err != nil { + return nil, err + } + if err = f.Close(); err != nil { + return nil, err + } + return f, nil +} + +func TestManagerOpenEmpty(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + ev := <-eventCh + if ev.Type != event.Error { + t.Errorf("event type should be %d but got: %d", event.Error, ev.Type) + } + if expected := "no file name"; ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := ""; ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 0 { + t.Errorf("size should be %d but got %d", 0, ws.Size) + } + if ws.Length != int64(0) { + t.Errorf("Length should be %d but got %d", int64(0), ws.Length) + } + if expected := "\x00"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Write}) + <-waitCh + wm.Close() +} + +func TestManagerOpenStates(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + str := "Hello, world! こんにちは、世界!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(f.Name()); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 41 { + t.Errorf("size should be %d but got %d", 41, ws.Size) + } + if ws.Length != int64(41) { + t.Errorf("Length should be %d but got %d", int64(41), ws.Length) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOpenNonExistsWrite(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 16 { + <-redrawCh + } + if ev := <-eventCh; ev.Type != event.QuitAll { + t.Errorf("event type should be %d but got: %d", event.QuitAll, ev.Type) + } + }() + wm.SetSize(110, 20) + fname := filepath.Join(t.TempDir(), "test") + if err := wm.Open(fname); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, _, _ = wm.State() + str := "Hello, world!" + wm.Emit(event.Event{Type: event.StartInsert}) + wm.Emit(event.Event{Type: event.SwitchFocus}) + for _, c := range str { + wm.Emit(event.Event{Type: event.Rune, Rune: c, Mode: mode.Insert}) + } + wm.Emit(event.Event{Type: event.ExitInsert}) + wm.Emit(event.Event{Type: event.WriteQuit}) + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(fname); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 13 { + t.Errorf("size should be %d but got %d", 13, ws.Size) + } + if ws.Length != int64(13) { + t.Errorf("Length should be %d but got %d", int64(13), ws.Length) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + bs, err := os.ReadFile(fname) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if string(bs) != str { + t.Errorf("file contents should be %q but got %q", str, string(bs)) + } + <-waitCh + wm.Close() +} + +func TestManagerOpenExpandBacktick(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + cmd, name := "`which ls`", "ls" + if runtime.GOOS == "windows" { + cmd, name = "`where ping`", "PING.EXE" + } + if err := wm.Open(cmd); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if ws.Name != name { + t.Errorf("name should be %q but got %q", name, ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size == 0 { + t.Errorf("size should not be %d but got %d", 0, ws.Size) + } + if ws.Length == 0 { + t.Errorf("length should not be %d but got %d", 0, ws.Length) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOpenExpandHomedir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + str := "Hello, world!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + home := os.Getenv("HOME") + t.Cleanup(func() { + if err := os.Setenv("HOME", home); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + }) + if err := os.Setenv("HOME", filepath.Dir(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + for i, prefix := range []string{"~/", "$HOME/"} { + if err := wm.Open(prefix + filepath.Base(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if windowIndex != i { + t.Errorf("windowIndex should be %d but got %d", i, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if expected := filepath.Base(f.Name()); ws.Name != expected { + t.Errorf("name should be %q but got %q", expected, ws.Name) + } + if !strings.HasPrefix(string(ws.Bytes), str) { + t.Errorf("Bytes should start with %q but got %q", str, string(ws.Bytes)) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + } + wm.Close() +} + +func TestManagerOpenChdirWrite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skip on Windows") + } + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + f, err := createTemp(t.TempDir(), "Hello") + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + ev := <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + dir, err := filepath.EvalSymlinks(filepath.Dir(f.Name())) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := dir; ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + ev = <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + if expected := filepath.Dir(dir); ev.Error.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + for range 11 { + <-redrawCh + } + ev = <-eventCh + if ev.Type != event.Info { + t.Errorf("event type should be %d but got: %d", event.Info, ev.Type) + } + if expected := "13 (0xd) bytes written"; !strings.HasSuffix(ev.Error.Error(), expected) { + t.Errorf("err should be %q but got: %v", expected, ev.Error) + } + }() + wm.SetSize(110, 20) + wm.Emit(event.Event{Type: event.Chdir, Arg: filepath.Dir(f.Name())}) + if err := wm.Open(filepath.Base(f.Name())); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + wm.Emit(event.Event{Type: event.Chdir, Arg: "../"}) + wm.Emit(event.Event{Type: event.StartAppendEnd}) + wm.Emit(event.Event{Type: event.SwitchFocus}) + for _, c := range ", world!" { + wm.Emit(event.Event{Type: event.Rune, Rune: c, Mode: mode.Insert}) + } + wm.Emit(event.Event{Type: event.ExitInsert}) + wm.Emit(event.Event{Type: event.Write}) + bs, err := os.ReadFile(f.Name()) + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if expected := "Hello, world!"; string(bs) != expected { + t.Errorf("file contents should be %q but got %q", expected, string(bs)) + } + <-waitCh + wm.Close() +} + +func TestManagerOpenDirectory(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + dir := t.TempDir() + if err := wm.Open(dir); err != nil { + if expected := dir + " is a directory"; err.Error() != expected { + t.Errorf("err should be %q but got: %v", expected, err) + } + } else { + t.Errorf("err should not be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerRead(t *testing.T) { + wm := NewManager() + wm.Init(nil, nil) + wm.SetSize(110, 20) + r := strings.NewReader("Hello, world!") + if err := wm.Read(r); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + windowStates, _, windowIndex, err := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + ws, ok := windowStates[windowIndex] + if !ok { + t.Fatalf("windowStates should contain %d but got: %v", windowIndex, windowStates) + } + if ws.Name != "" { + t.Errorf("name should be %q but got %q", "", ws.Name) + } + if ws.Width != 16 { + t.Errorf("width should be %d but got %d", 16, ws.Width) + } + if ws.Size != 13 { + t.Errorf("size should be %d but got %d", 13, ws.Size) + } + if ws.Length != int64(13) { + t.Errorf("Length should be %d but got %d", int64(13), ws.Length) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + wm.Close() +} + +func TestManagerOnly(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 4 { + <-eventCh + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Vnew}) + wm.Emit(event.Event{Type: event.Vnew}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Resize(110, 20) + + _, got, _, _ := wm.State() + expected := layout.NewLayout(0).SplitLeft(1).SplitLeft(2). + Activate(1).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.Only}) + wm.Resize(110, 20) + _, got, _, _ = wm.State() + expected = layout.NewLayout(1).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + <-waitCh + wm.Close() +} + +func TestManagerAlternative(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 9 { + <-eventCh + } + }() + wm.SetSize(110, 20) + + if err := os.Chdir(os.TempDir()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open("bed-test-manager-alternative-1"); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + if err := wm.Open("bed-test-manager-alternative-2"); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ := wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + if err := wm.Open("bed-test-manager-alternative-3"); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ = wm.State() + if expected := 2; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 0; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 2; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + if err := wm.Open("bed-test-manager-alternative-4"); err != nil { + t.Errorf("err should be nil but got: %v", err) + } + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative, Count: 2}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative, Count: 4}) + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Alternative}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#2"}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#4"}) + _, _, windowIndex, _ = wm.State() + if expected := 3; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + wm.Emit(event.Event{Type: event.Edit, Arg: "#"}) + _, _, windowIndex, _ = wm.State() + if expected := 1; windowIndex != expected { + t.Errorf("windowIndex should be %d but got %d", expected, windowIndex) + } + + <-waitCh + wm.Close() +} + +func TestManagerWincmd(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + for range 17 { + <-eventCh + } + }() + wm.SetSize(110, 20) + if err := wm.Open(""); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.Wincmd, Arg: "n"}) + wm.Emit(event.Event{Type: event.MoveWindowLeft}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Emit(event.Event{Type: event.FocusWindowBottomRight}) + wm.Emit(event.Event{Type: event.MoveWindowRight}) + wm.Emit(event.Event{Type: event.FocusWindowLeft}) + wm.Emit(event.Event{Type: event.MoveWindowTop}) + wm.Resize(110, 20) + + _, got, _, _ := wm.State() + expected := layout.NewLayout(2).SplitBottom(0).SplitLeft(1). + SplitLeft(3).Activate(2).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.FocusWindowDown}) + wm.Emit(event.Event{Type: event.FocusWindowRight}) + wm.Emit(event.Event{Type: event.Quit}) + _, got, _, _ = wm.State() + expected = layout.NewLayout(2).SplitBottom(0).SplitLeft(3).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.Wincmd, Arg: "o"}) + _, got, _, _ = wm.State() + expected = layout.NewLayout(3).Resize(0, 0, 110, 20) + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + wm.Emit(event.Event{Type: event.MoveWindowLeft}) + wm.Emit(event.Event{Type: event.MoveWindowRight}) + wm.Emit(event.Event{Type: event.MoveWindowTop}) + wm.Emit(event.Event{Type: event.MoveWindowBottom}) + wm.Resize(110, 20) + _, got, _, _ = wm.State() + if !reflect.DeepEqual(got, expected) { + t.Errorf("layout should be %#v but got %#v", expected, got) + } + + <-waitCh + wm.Close() +} + +func TestManagerCopyCutPaste(t *testing.T) { + wm := NewManager() + eventCh, redrawCh, waitCh := make(chan event.Event), make(chan struct{}), make(chan struct{}) + wm.Init(eventCh, redrawCh) + str := "Hello, world!" + f, err := createTemp(t.TempDir(), str) + if err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + wm.SetSize(110, 20) + if err := wm.Open(f.Name()); err != nil { + t.Fatalf("err should be nil but got: %v", err) + } + _, _, _, _ = wm.State() + go func() { + defer func() { + close(eventCh) + close(redrawCh) + close(waitCh) + }() + <-redrawCh + <-redrawCh + <-redrawCh + waitCh <- struct{}{} + ev := <-eventCh + if ev.Type != event.Copied { + t.Errorf("event type should be %d but got: %d", event.Copied, ev.Type) + } + if ev.Buffer == nil { + t.Errorf("Buffer should not be nil but got: %#v", ev) + } + if expected := "yanked"; ev.Arg != expected { + t.Errorf("Arg should be %q but got: %q", expected, ev.Arg) + } + p := make([]byte, 20) + _, _ = ev.Buffer.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "lo, worl") { + t.Errorf("buffer string should be %q but got: %q", "", string(p)) + } + waitCh <- struct{}{} + <-redrawCh + <-redrawCh + waitCh <- struct{}{} + ev = <-eventCh + if ev.Type != event.Copied { + t.Errorf("event type should be %d but got: %d", event.Copied, ev.Type) + } + if ev.Buffer == nil { + t.Errorf("Buffer should not be nil but got: %#v", ev) + } + if expected := "deleted"; ev.Arg != expected { + t.Errorf("Arg should be %q but got: %q", expected, ev.Arg) + } + p = make([]byte, 20) + _, _ = ev.Buffer.ReadAt(p, 0) + if !strings.HasPrefix(string(p), "lo, wo") { + t.Errorf("buffer string should be %q but got: %q", "", string(p)) + } + windowStates, _, windowIndex, _ := wm.State() + ws, ok := windowStates[windowIndex] + if !ok { + t.Errorf("windowStates should contain %d but got: %v", windowIndex, windowStates) + return + } + if ws.Length != int64(7) { + t.Errorf("Length should be %d but got %d", int64(7), ws.Length) + } + if expected := "Helrld!"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + waitCh <- struct{}{} + <-redrawCh + waitCh <- struct{}{} + ev = <-eventCh + if ev.Type != event.Pasted { + t.Errorf("event type should be %d but got: %d", event.Pasted, ev.Type) + } + if ev.Count != 18 { + t.Errorf("Count should be %d but got: %d", 18, ev.Count) + } + windowStates, _, _, _ = wm.State() + ws = windowStates[0] + if ws.Length != int64(25) { + t.Errorf("Length should be %d but got %d", int64(25), ws.Length) + } + if expected := "Hefoobarfoobarfoobarlrld!"; !strings.HasPrefix(string(ws.Bytes), expected) { + t.Errorf("Bytes should start with %q but got %q", expected, string(ws.Bytes)) + } + }() + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Normal, Count: 3}) + wm.Emit(event.Event{Type: event.StartVisual}) + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Visual, Count: 7}) + <-waitCh + wm.Emit(event.Event{Type: event.Copy}) + <-waitCh + wm.Emit(event.Event{Type: event.StartVisual}) + wm.Emit(event.Event{Type: event.CursorNext, Mode: mode.Visual, Count: 5}) + <-waitCh + wm.Emit(event.Event{Type: event.Cut}) + <-waitCh + wm.Emit(event.Event{Type: event.CursorPrev, Mode: mode.Normal, Count: 2}) + <-waitCh + wm.Emit(event.Event{Type: event.Paste, Buffer: buffer.NewBuffer(strings.NewReader("foobar")), Count: 3}) + <-waitCh + wm.Close() +} diff --git a/bed/window/window.go b/bed/window/window.go new file mode 100644 index 0000000..4c75fd7 --- /dev/null +++ b/bed/window/window.go @@ -0,0 +1,1070 @@ +package window + +import ( + "cmp" + "errors" + "fmt" + "io" + "strconv" + "sync" + "unicode" + "unicode/utf8" + + "b612.me/apps/b612/bed/buffer" + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/history" + "b612.me/apps/b612/bed/mode" + "b612.me/apps/b612/bed/searcher" + "b612.me/apps/b612/bed/state" +) + +type window struct { + buffer *buffer.Buffer + changedTick uint64 + prevChanged bool + maxChangedTick uint64 + savedChangedTick uint64 + history *history.History + searcher *searcher.Searcher + searchTick uint64 + path string + name string + height int64 + width int64 + offset int64 + cursor int64 + length int64 + stack []position + append bool + replaceByte bool + extending bool + pending bool + pendingByte byte + visualStart int64 + focusText bool + buf []byte + buf1 [1]byte + redrawCh chan<- struct{} + eventCh chan<- event.Event + mu *sync.Mutex +} + +type position struct { + cursor int64 + offset int64 +} + +type readAtSeeker interface { + io.ReaderAt + io.Seeker +} + +func newWindow( + r readAtSeeker, path, name string, + eventCh chan<- event.Event, redrawCh chan<- struct{}, +) (*window, error) { + buffer := buffer.NewBuffer(r) + length, err := buffer.Len() + if err != nil { + return nil, err + } + history := history.NewHistory() + history.Push(buffer, 0, 0, 0) + return &window{ + buffer: buffer, + history: history, + searcher: searcher.NewSearcher(r), + path: path, + name: name, + length: length, + visualStart: -1, + redrawCh: redrawCh, + eventCh: eventCh, + mu: new(sync.Mutex), + }, nil +} + +func (w *window) setSize(width, height int) { + w.width, w.height = int64(width), int64(height) + w.offset = w.offset / w.width * w.width + if w.cursor < w.offset { + w.offset = w.cursor / w.width * w.width + } else if w.cursor >= w.offset+w.height*w.width { + w.offset = (w.cursor - w.height*w.width + w.width) / w.width * w.width + } + w.offset = min( + w.offset, + max(w.length-1-w.height*w.width+w.width, 0)/w.width*w.width, + ) +} + +func (w *window) emit(e event.Event) { + var newEvent event.Event + w.mu.Lock() + offset, cursor, changedTick := w.offset, w.cursor, w.changedTick + switch e.Type { + case event.CursorUp: + w.cursorUp(e.Count) + case event.CursorDown: + w.cursorDown(e.Count) + case event.CursorLeft: + w.cursorLeft(e.Count) + case event.CursorRight: + w.cursorRight(e.Mode, e.Count) + case event.CursorPrev: + w.cursorPrev(e.Count) + case event.CursorNext: + w.cursorNext(e.Mode, e.Count) + case event.CursorHead: + w.cursorHead(e.Count) + case event.CursorEnd: + w.cursorEnd(e.Count) + case event.CursorGoto: + w.cursorGoto(e) + case event.ScrollUp: + w.scrollUp(e.Count) + case event.ScrollDown: + w.scrollDown(e.Count) + case event.ScrollTop: + w.scrollTop(e.Count) + case event.ScrollTopHead: + w.scrollTopHead(e.Count) + case event.ScrollMiddle: + w.scrollMiddle(e.Count) + case event.ScrollMiddleHead: + w.scrollMiddleHead(e.Count) + case event.ScrollBottom: + w.scrollBottom(e.Count) + case event.ScrollBottomHead: + w.scrollBottomHead(e.Count) + case event.PageUp: + w.pageUp() + case event.PageDown: + w.pageDown() + case event.PageUpHalf: + w.pageUpHalf() + case event.PageDownHalf: + w.pageDownHalf() + case event.PageTop: + w.pageTop() + case event.PageEnd: + w.pageEnd() + case event.WindowTop: + w.windowTop(e.Count) + case event.WindowMiddle: + w.windowMiddle() + case event.WindowBottom: + w.windowBottom(e.Count) + case event.JumpTo: + w.jumpTo() + case event.JumpBack: + w.jumpBack() + + case event.DeleteByte: + newEvent = event.Event{Type: event.Copied, Buffer: w.deleteBytes(e.Count), Arg: "deleted"} + case event.DeletePrevByte: + newEvent = event.Event{Type: event.Copied, Buffer: w.deletePrevBytes(e.Count), Arg: "deleted"} + case event.Increment: + w.increment(e.Count) + case event.Decrement: + w.decrement(e.Count) + case event.ShiftLeft: + w.shiftLeft(e.Count) + case event.ShiftRight: + w.shiftRight(e.Count) + case event.ShowBinary: + if str := w.showBinary(); str != "" { + newEvent = event.Event{Type: event.Info, Error: errors.New(str)} + } + case event.ShowDecimal: + if str := w.showDecimal(); str != "" { + newEvent = event.Event{Type: event.Info, Error: errors.New(str)} + } + + case event.StartInsert: + w.startInsert() + case event.StartInsertHead: + w.startInsertHead() + case event.StartAppend: + w.startAppend() + case event.StartAppendEnd: + w.startAppendEnd() + case event.StartReplaceByte: + w.startReplaceByte() + case event.StartReplace: + w.startReplace() + case event.ExitInsert: + w.exitInsert() + case event.Rune: + if w.insertRune(e.Mode, e.Rune) { + newEvent = event.Event{Type: event.ExitInsert} + } + case event.Backspace: + w.backspace(e.Mode) + case event.Delete: + w.deleteByte() + case event.StartVisual: + w.startVisual() + case event.SwitchVisualEnd: + w.switchVisualEnd() + case event.ExitVisual: + w.exitVisual() + case event.SwitchFocus: + w.focusText = !w.focusText + if w.pending { + w.pending = false + w.pendingByte = '\x00' + } + case event.Undo: + if e.Mode != mode.Normal { + panic("event.Undo should be emitted under normal mode") + } + w.undo(e.Count) + case event.Redo: + if e.Mode != mode.Normal { + panic("event.Undo should be emitted under normal mode") + } + w.redo(e.Count) + case event.Copy: + newEvent = event.Event{Type: event.Copied, Buffer: w.copy(), Arg: "yanked"} + case event.Cut: + newEvent = event.Event{Type: event.Copied, Buffer: w.cut(), Arg: "deleted"} + case event.Paste, event.PastePrev: + newEvent = event.Event{Type: event.Pasted, Count: w.paste(e)} + case event.ExecuteSearch: + w.search(e.Arg, e.Rune == '/') + case event.NextSearch: + w.search(e.Arg, e.Rune == '/') + case event.PreviousSearch: + w.search(e.Arg, e.Rune != '/') + case event.AbortSearch: + w.abortSearch() + default: + w.mu.Unlock() + return + } + changed := changedTick != w.changedTick + if e.Type != event.Undo && e.Type != event.Redo { + if (e.Mode == mode.Normal || e.Mode == mode.Visual) && changed || e.Type == event.ExitInsert && w.prevChanged { + w.history.Push(w.buffer, w.offset, w.cursor, w.changedTick) + } else if e.Mode != mode.Normal && e.Mode != mode.Visual && w.prevChanged && !changed && + event.CursorUp <= e.Type && e.Type <= event.JumpBack { + w.history.Push(w.buffer, offset, cursor, w.changedTick) + } + } + w.prevChanged = changed + w.mu.Unlock() + if newEvent.Type == event.Nop { + w.redrawCh <- struct{}{} + } else { + w.eventCh <- newEvent + } +} + +func (w *window) readByte(offset int64) (byte, error) { + n, err := w.buffer.ReadAt(w.buf1[:], offset) + if err != nil && err != io.EOF { + return 0, err + } + if n == 0 { + return 0, io.EOF + } + return w.buf1[0], nil +} + +func (w *window) readBytes(offset int64, l int) (int, []byte, error) { + var reused bool + if l <= cap(w.buf) { + w.buf, reused = w.buf[:l], true + } else { + w.buf = make([]byte, l) + } + n, err := w.buffer.ReadAt(w.buf, offset) + if err != nil && err != io.EOF { + return 0, w.buf, err + } + if reused { + for i := n; i < len(w.buf); i++ { + w.buf[i] = 0 + } + } + return n, w.buf, nil +} + +func (w *window) writeTo(r *event.Range, dst io.Writer) (int64, error) { + w.mu.Lock() + defer w.mu.Unlock() + var from, to int64 + if r == nil { + from, to = 0, w.length-1 + } else { + var err error + if from, err = w.positionToOffset(r.From); err != nil { + return 0, err + } + if to, err = w.positionToOffset(r.To); err != nil { + return 0, err + } + if from > to { + from, to = to, from + } + } + return io.Copy(dst, io.NewSectionReader(w.buffer, from, to-from+1)) +} + +func (w *window) positionToOffset(pos event.Position) (int64, error) { + var offset int64 + switch pos := pos.(type) { + case event.Absolute: + offset = pos.Offset + case event.Relative: + offset = w.cursor + pos.Offset + case event.End: + offset = max(w.length, 1) - 1 + pos.Offset + case event.VisualStart: + if w.visualStart < 0 { + return 0, errors.New("no visual selection found") + } + // TODO: save visualStart after exiting visual mode + offset = w.visualStart + pos.Offset + case event.VisualEnd: + if w.visualStart < 0 { + return 0, errors.New("no visual selection found") + } + offset = w.cursor + pos.Offset + default: + return 0, errors.New("invalid range") + } + return max(min(offset, max(w.length, 1)-1), 0), nil +} + +func (w *window) state(width, height int) (*state.WindowState, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.setSize(width, height) + n, bytes, err := w.readBytes(w.offset, int(w.height*w.width)) + if err != nil { + return nil, err + } + return &state.WindowState{ + Name: w.name, + Modified: w.changedTick != w.savedChangedTick, + Width: int(w.width), + Offset: w.offset, + Cursor: w.cursor, + Bytes: bytes, + Size: n, + Length: w.length, + Pending: w.pending, + PendingByte: w.pendingByte, + VisualStart: w.visualStart, + EditedIndices: w.buffer.EditedIndices(), + FocusText: w.focusText, + }, nil +} + +func (w *window) updateTick() { + w.maxChangedTick++ + w.changedTick = w.maxChangedTick +} + +func (w *window) insert(offset int64, c byte) { + w.buffer.Insert(offset, c) + w.updateTick() +} + +func (w *window) replace(offset int64, c byte) { + w.buffer.Replace(offset, c) + w.updateTick() +} + +func (w *window) undoReplace(offset int64) { + w.buffer.UndoReplace(offset) + w.updateTick() +} + +func (w *window) replaceIn(start, end int64, c byte) { + w.buffer.ReplaceIn(start, end, c) + w.updateTick() +} + +func (w *window) delete(offset int64) { + w.buffer.Delete(offset) + w.updateTick() +} + +func (w *window) undo(count int64) { + for range max(count, 1) { + buffer, _, offset, cursor, tick := w.history.Undo() + if buffer == nil { + return + } + w.buffer, w.offset, w.cursor, w.changedTick = buffer, offset, cursor, tick + w.length, _ = w.buffer.Len() + } +} + +func (w *window) redo(count int64) { + for range max(count, 1) { + buffer, offset, cursor, tick := w.history.Redo() + if buffer == nil { + return + } + w.buffer, w.offset, w.cursor, w.changedTick = buffer, offset, cursor, tick + w.length, _ = w.buffer.Len() + } +} + +func (w *window) cursorUp(count int64) { + w.cursor -= min(max(count, 1), w.cursor/w.width) * w.width + if w.append && w.extending && w.cursor < w.length-1 { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorDown(count int64) { + w.cursor += min( + min( + max(count, 1), + (max(w.length, 1)-1)/w.width-w.cursor/w.width, + )*w.width, + max(w.length, 1)-1-w.cursor) +} + +func (w *window) cursorLeft(count int64) { + w.cursor -= min(max(count, 1), w.cursor%w.width) + if w.append && w.extending && w.cursor < w.length-1 { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorRight(m mode.Mode, count int64) { + if m != mode.Insert { + w.cursor += min( + min(max(count, 1), w.width-1-w.cursor%w.width), + max(w.length, 1)-1-w.cursor, + ) + } else if !w.extending { + w.cursor += min( + min(max(count, 1), w.width-1-w.cursor%w.width), + w.length-w.cursor, + ) + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } +} + +func (w *window) cursorPrev(count int64) { + w.cursor -= min(max(count, 1), w.cursor) + if w.append && w.extending && w.cursor != w.length { + w.append = false + w.extending = false + if w.length > 0 { + w.length-- + } + } +} + +func (w *window) cursorNext(m mode.Mode, count int64) { + if m != mode.Insert { + w.cursor += min(max(count, 1), max(w.length, 1)-1-w.cursor) + } else if !w.extending { + w.cursor += min(max(count, 1), w.length-w.cursor) + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } +} + +func (w *window) cursorHead(_ int64) { + w.cursor -= w.cursor % w.width +} + +func (w *window) cursorEnd(count int64) { + w.cursor = min( + (w.cursor/w.width+max(count, 1))*w.width-1, + max(w.length, 1)-1, + ) +} + +func (w *window) cursorGoto(e event.Event) { + if e.Range != nil { + if e.Range.To != nil { + w.cursorGotoPos(e.Range.To, e.CmdName) + } else if e.Range.From != nil { + w.cursorGotoPos(e.Range.From, e.CmdName) + } + } +} + +func (w *window) cursorGotoPos(pos event.Position, cmdName string) { + switch cmdName { + case "go[to]": + switch p := pos.(type) { + case event.Absolute: + pos = event.Absolute{Offset: p.Offset * w.width} + case event.Relative: + pos = event.Relative{Offset: p.Offset * w.width} + case event.End: + pos = event.End{Offset: p.Offset * w.width} + case event.VisualStart: + pos = event.VisualStart{Offset: p.Offset * w.width} + case event.VisualEnd: + pos = event.VisualEnd{Offset: p.Offset * w.width} + } + case "%": + switch p := pos.(type) { + case event.Absolute: + pos = event.Absolute{Offset: p.Offset * w.length / 100} + case event.Relative: + pos = event.Relative{Offset: p.Offset * w.length / 100} + case event.End: + pos = event.End{Offset: p.Offset * w.length / 100} + case event.VisualStart: + pos = event.VisualStart{Offset: p.Offset * w.length / 100} + case event.VisualEnd: + pos = event.VisualEnd{Offset: p.Offset * w.length / 100} + } + } + if offset, err := w.positionToOffset(pos); err == nil { + w.cursor = offset + if w.cursor < w.offset { + w.offset = (max(w.cursor/w.width, w.height/2) - w.height/2) * w.width + } else if w.cursor >= w.offset+w.height*w.width { + h := (max(w.length, 1)+w.width-1)/w.width - w.height + w.offset = min((w.cursor-w.height*w.width+w.width)/w.width+w.height/2, h) * w.width + } + } +} + +func (w *window) scrollUp(count int64) { + w.offset -= min(max(count, 1), w.offset/w.width) * w.width + if w.cursor >= w.offset+w.height*w.width { + w.cursor -= ((w.cursor-w.offset-w.height*w.width)/w.width + 1) * w.width + } +} + +func (w *window) scrollDown(count int64) { + h := max((max(w.length, 1)+w.width-1)/w.width-w.height, 0) + w.offset += min(max(count, 1), h-w.offset/w.width) * w.width + if w.cursor < w.offset { + w.cursor += min( + (w.offset-w.cursor+w.width-1)/w.width*w.width, + max(w.length, 1)-1-w.cursor, + ) + } +} + +func (w *window) scrollTop(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = w.cursor / w.width * w.width +} + +func (w *window) scrollTopHead(count int64) { + w.cursorHead(0) + w.scrollTop(count) +} + +func (w *window) scrollMiddle(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = max(w.cursor/w.width-w.height/2, 0) * w.width +} + +func (w *window) scrollMiddleHead(count int64) { + w.cursorHead(0) + w.scrollMiddle(count) +} + +func (w *window) scrollBottom(count int64) { + if count > 0 { + w.cursor = min( + min( + count*w.width+w.cursor%w.width, + (max(w.length, 1)-1)/w.width*w.width+w.cursor%w.width, + ), + max(w.length, 1)-1, + ) + } + w.offset = max(w.cursor/w.width-w.height, 0) * w.width +} + +func (w *window) scrollBottomHead(count int64) { + w.cursorHead(0) + w.scrollBottom(count) +} + +func (w *window) pageUp() { + w.offset = max(w.offset-(w.height-2)*w.width, 0) + if w.offset == 0 { + w.cursor = 0 + } else if w.cursor >= w.offset+w.height*w.width { + w.cursor = w.offset + (w.height-1)*w.width + } +} + +func (w *window) pageDown() { + offset := max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.offset = min(w.offset+(w.height-2)*w.width, offset) + if w.cursor < w.offset { + w.cursor = w.offset + } else if w.offset == offset { + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width + } +} + +func (w *window) pageUpHalf() { + w.offset = max(w.offset-max(w.height/2, 1)*w.width, 0) + if w.offset == 0 { + w.cursor = 0 + } else if w.cursor >= w.offset+w.height*w.width { + w.cursor = w.offset + (w.height-1)*w.width + } +} + +func (w *window) pageDownHalf() { + offset := max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.offset = min(w.offset+max(w.height/2, 1)*w.width, offset) + if w.cursor < w.offset { + w.cursor = w.offset + } else if w.offset == offset { + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width + } +} + +func (w *window) pageTop() { + w.offset = 0 + w.cursor = 0 +} + +func (w *window) pageEnd() { + w.offset = max(((w.length+w.width-1)/w.width-w.height)*w.width, 0) + w.cursor = ((max(w.length, 1)+w.width-1)/w.width - 1) * w.width +} + +func (w *window) windowTop(count int64) { + w.cursor = (w.offset/w.width + min( + min(max(count, 1)-1, (w.length-w.offset)/w.width), + max(w.height, 1)-1, + )) * w.width +} + +func (w *window) windowMiddle() { + h := min((w.length-w.offset)/w.width, max(w.height, 1)-1) + w.cursor = (w.offset/w.width + h/2) * w.width +} + +func (w *window) windowBottom(count int64) { + h := min((w.length-w.offset)/w.width, max(w.height, 1)-1) + w.cursor = (w.offset/w.width + h - min(h, max(count, 1)-1)) * w.width +} + +func (w *window) jumpTo() { + i := min(w.cursor, 16) + _, bytes, err := w.readBytes(w.cursor-i, 32) + if err != nil { + return + } + for ; i >= 0; i-- { + if !unicode.IsDigit(rune(bytes[i])) { + bytes = bytes[i+1:] + break + } + } + for i := 0; i < len(bytes); i++ { + if !unicode.IsDigit(rune(bytes[i])) { + bytes = bytes[:i] + break + } + } + offset, _ := strconv.ParseInt(string(bytes), 10, 64) + if offset <= 0 || w.length <= offset { + return + } + w.stack = append(w.stack, position{cursor: w.cursor, offset: w.offset}) + w.cursor = offset + w.offset = max(offset-offset%w.width-max(w.height/3, 0)*w.width, 0) +} + +func (w *window) jumpBack() { + if len(w.stack) == 0 { + return + } + if pos := w.stack[len(w.stack)-1]; pos.cursor < w.length { + w.cursor, w.offset = pos.cursor, pos.offset + } + w.stack = w.stack[:len(w.stack)-1] +} + +func (w *window) deleteBytes(count int64) *buffer.Buffer { + if w.length == 0 { + return nil + } + count = min(max(count, 1), w.length-w.cursor) + b := w.buffer.Copy(w.cursor, w.cursor+count) + w.buffer.Cut(w.cursor, w.cursor+count) + w.length, _ = w.buffer.Len() + w.cursor = min(w.cursor, max(w.length, 1)-1) + w.updateTick() + return b +} + +func (w *window) deletePrevBytes(count int64) *buffer.Buffer { + if w.cursor == 0 { + return nil + } + count = min(max(count, 1), w.cursor) + b := w.buffer.Copy(w.cursor-count, w.cursor) + w.buffer.Cut(w.cursor-count, w.cursor) + w.length, _ = w.buffer.Len() + w.cursor -= count + w.updateTick() + return b +} + +func (w *window) increment(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b+byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) decrement(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b-byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) shiftLeft(count int64) { + b, err := w.readByte(w.cursor) + if err != nil && err != io.EOF { + return + } + w.replace(w.cursor, b<>byte(max(count, 1))) + if w.length == 0 { + w.length++ + } +} + +func (w *window) showBinary() string { + b, err := w.readByte(w.cursor) + if err != nil { + return "" + } + return fmt.Sprintf("%08b", b) +} + +func (w *window) showDecimal() string { + b, err := w.readByte(w.cursor) + if err != nil { + return "" + } + return strconv.FormatInt(int64(b), 10) +} + +func (w *window) startInsert() { + w.append = false + w.extending = false + w.pending = false + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } +} + +func (w *window) startInsertHead() { + w.cursorHead(0) + w.append = false + w.extending = false + w.pending = false + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } +} + +func (w *window) startAppend() { + w.append = true + w.extending = false + w.pending = false + if w.length > 0 { + w.cursor++ + } + if w.cursor == w.length { + w.extending = true + w.length++ + } +} + +func (w *window) startAppendEnd() { + w.cursorEnd(0) + w.startAppend() +} + +func (w *window) startReplaceByte() { + w.replaceByte = true + w.append = false + w.extending = false + w.pending = false +} + +func (w *window) startReplace() { + w.replaceByte = false + w.append = true + w.extending = false + w.pending = false +} + +func (w *window) exitInsert() { + w.pending = false + if w.append { + if w.extending && w.length > 0 { + w.length-- + } + if w.cursor > 0 { + w.cursor-- + } + w.replaceByte = false + w.append = false + w.extending = false + w.pending = false + } + w.buffer.Flush() +} + +func (w *window) insertRune(m mode.Mode, ch rune) (exitInsert bool) { + if m == mode.Insert || m == mode.Replace { + if w.focusText { + var buf [4]byte + n := utf8.EncodeRune(buf[:], ch) + for i := range n { + exitInsert = exitInsert || w.insertByte(m, byte(buf[i]>>4)) + exitInsert = exitInsert || w.insertByte(m, byte(buf[i]&0x0f)) + } + } else if '0' <= ch && ch <= '9' { + exitInsert = w.insertByte(m, byte(ch-'0')) + } else if 'a' <= ch && ch <= 'f' { + exitInsert = w.insertByte(m, byte(ch-'a'+0x0a)) + } + } + return +} + +func (w *window) insertByte(m mode.Mode, b byte) bool { + if w.pending { + switch m { + case mode.Insert: + w.insert(w.cursor, w.pendingByte|b) + w.cursor++ + w.length++ + case mode.Replace: + if w.visualStart >= 0 && w.replaceByte { + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + w.replaceIn(start, end+1, w.pendingByte|b) + w.visualStart = -1 + return true + } + w.replace(w.cursor, w.pendingByte|b) + if w.length == 0 { + w.length++ + } + if w.replaceByte { + w.exitInsert() + return true + } + w.cursor++ + if w.cursor == w.length { + w.append = true + w.extending = true + w.length++ + } + } + w.pending = false + w.pendingByte = '\x00' + } else { + w.pending = true + w.pendingByte = b << 4 + } + return false +} + +func (w *window) backspace(m mode.Mode) { + if w.pending { + w.pending = false + w.pendingByte = '\x00' + } else if m == mode.Replace { + if w.cursor > 0 { + w.cursor-- + w.undoReplace(w.cursor) + } + } else if w.cursor > 0 { + w.delete(w.cursor - 1) + w.cursor-- + w.length-- + } +} + +func (w *window) deleteByte() { + if w.length == 0 { + return + } + w.delete(w.cursor) + w.length-- + if w.cursor == w.length && w.cursor > 0 { + w.cursor-- + } +} + +func (w *window) startVisual() { + w.visualStart = w.cursor +} + +func (w *window) switchVisualEnd() { + if w.visualStart < 0 { + panic("window#switchVisualEnd should be called in visual mode") + } + w.cursor, w.visualStart = w.visualStart, w.cursor +} + +func (w *window) exitVisual() { + w.visualStart = -1 +} + +func (w *window) copy() *buffer.Buffer { + if w.visualStart < 0 { + panic("window#copy should be called in visual mode") + } + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + if end == w.length { + return nil + } + w.visualStart = -1 + w.cursor = start + return w.buffer.Copy(start, end+1) +} + +func (w *window) cut() *buffer.Buffer { + if w.visualStart < 0 { + panic("window#cut should be called in visual mode") + } + start, end := w.visualStart, w.cursor + if start > end { + start, end = end, start + } + if end == w.length { + return nil + } + w.visualStart = -1 + b := w.buffer.Copy(start, end+1) + w.buffer.Cut(start, end+1) + w.length, _ = w.buffer.Len() + w.cursor = min(start, max(w.length, 1)-1) + w.updateTick() + return b +} + +func (w *window) paste(e event.Event) int64 { + count := max(e.Count, 1) + pos := w.cursor + if e.Type != event.PastePrev { + pos = min(w.cursor+1, w.length) + } + for range count { + w.buffer.Paste(pos, e.Buffer) + } + l, _ := e.Buffer.Len() + w.length, _ = w.buffer.Len() + w.cursor = min(max(pos+l*count-1, 0), max(w.length, 1)-1) + w.updateTick() + return l * count +} + +func (w *window) search(str string, forward bool) { + if w.searchTick != w.changedTick { + w.searcher.Abort() + w.searcher = searcher.NewSearcher(w.buffer) + w.searchTick = w.changedTick + } + ch := w.searcher.Search(w.cursor, str, forward) + go func() { + switch x := (<-ch).(type) { + case error: + w.eventCh <- event.Event{Type: event.Info, Error: x} + case int64: + w.mu.Lock() + w.cursor = x + w.mu.Unlock() + w.redrawCh <- struct{}{} + } + }() +} + +func (w *window) abortSearch() { + if err := w.searcher.Abort(); err != nil { + w.eventCh <- event.Event{Type: event.Info, Error: err} + } +} + +func (w *window) setPathName(path, name string) { + w.path, w.name = path, name +} + +func (w *window) getName() string { + return cmp.Or(w.name, "[No Name]") +} diff --git a/bed/window/window_test.go b/bed/window/window_test.go new file mode 100644 index 0000000..da24afa --- /dev/null +++ b/bed/window/window_test.go @@ -0,0 +1,1870 @@ +package window + +import ( + "bytes" + "math" + "reflect" + "strings" + "testing" + + "b612.me/apps/b612/bed/event" + "b612.me/apps/b612/bed/mode" +) + +func TestWindowState(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := "test"; s.Name != expected { + t.Errorf("state.Name should be %q but got %q", expected, s.Name) + } + + if s.Width != width { + t.Errorf("state.Width should be %d but got %d", width, s.Width) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + if expected := 13; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + if !reflect.DeepEqual(s.EditedIndices, []int64{}) { + t.Errorf("state.EditedIndices should be empty but got %v", s.EditedIndices) + } + + expected := []byte("Hello, world!" + strings.Repeat("\x00", height*width-13)) + if !reflect.DeepEqual(s.Bytes, expected) { + t.Errorf("s.Bytes should be %q but got %q", expected, s.Bytes) + } +} + +func TestWindowEmptyState(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := "test"; s.Name != expected { + t.Errorf("state.Name should be %q but got %q", expected, s.Name) + } + + if s.Width != width { + t.Errorf("state.Width should be %d but got %d", width, s.Width) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + if expected := 0; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + if !reflect.DeepEqual(s.EditedIndices, []int64{}) { + t.Errorf("state.EditedIndices should be empty but got %v", s.EditedIndices) + } + + expected := []byte(strings.Repeat("\x00", height*width)) + if !reflect.DeepEqual(s.Bytes, expected) { + t.Errorf("s.Bytes should be %q but got %q", expected, s.Bytes) + } + + window.scrollDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowCursorMotions(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 100)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 2; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorUp(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorDown(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 11; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 2; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + if expected := " world!"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.cursorRight(mode.Normal, 3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*11 + 3; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorRight(mode.Normal, 20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*12 - 1; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorLeft(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width)*12 - 4; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorLeft(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 11; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorPrev(154) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(22); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if s.Offset != int64(width) { + t.Errorf("s.Offset should be %d but got %d", width, s.Offset) + } + + window.cursorNext(mode.Normal, 200) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(222); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 4; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorHead(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1296); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorEnd(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorUp(20) + window.cursorEnd(1) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(991); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 61; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorEnd(11) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1151); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 62; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorPrev(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(width) * 81; s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorRight(mode.Normal, 1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1299); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorUp(2000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(7) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(96); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowTop(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(144); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowMiddle() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(64); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(144); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(7) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(48); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.windowBottom(20) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorGotoPos(event.Absolute{Offset: 0}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 50}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(50); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 100}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(100); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: -10}, "goto") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(90); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(390); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: 30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(780); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.End{Offset: -30}, "%") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(909); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Absolute{Offset: 30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(480); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.Relative{Offset: 30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(960); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorGotoPos(event.End{Offset: -30}, "go[to]") + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(819); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowScreenMotions(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 100)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.pageDown() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(128); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(128); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageDownHalf() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(208); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollDown(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(224); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(224); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollUp(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(224); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollDown(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(688); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(688); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollUp(30) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(352); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(208); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageUpHalf() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(272); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(128); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageUp() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageEnd() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(1296); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(width) * 72; s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.pageTop() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollTop(5) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(85); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(80); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(4) + window.scrollTop(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(149); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(144); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollTopHead(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(160); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(160); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollMiddle(12) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(197); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(112); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollMiddleHead(15) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(240); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(160); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorNext(mode.Normal, 5) + window.scrollBottom(12) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(197); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(48); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.cursorDown(8) + window.scrollBottom(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(325); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(176); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } + + window.scrollBottomHead(10) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(160); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(16); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowDeleteBytes(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.deleteBytes(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, d!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, \x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(6); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteByte() + window.deleteByte() + window.deleteByte() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + window.deleteBytes(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } +} + +func TestWindowDeletePrevBytes(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.deletePrevBytes(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deletePrevBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "H, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.deletePrevBytes(3) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ", world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowIncrementDecrement(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.increment(0) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Iello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.increment(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "1ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.increment(math.MaxInt64) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "0ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "/ello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Gello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.decrement(math.MaxInt64) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + + window.cursorNext(mode.Normal, 7) + window.increment(1000) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, _orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowIncrementDecrementEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := 0; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.increment(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x01\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := 1; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window, err = newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.decrement(0) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\xff\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := 1; s.Size != expected { + t.Errorf("s.Size should be %d but got %d", expected, s.Size) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } +} + +func TestWindowInsertByte(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 1 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startInsert() + + window.insertByte(mode.Insert, 0x04) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := true; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x40'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + window.insertByte(mode.Insert, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, Jworld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(14); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.exitInsert() + window.startAppendEnd() + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0b) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0c) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0d) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "M\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(18); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(16); s.Offset != expected { + t.Errorf("s.Offset should be %d but got %d", expected, s.Offset) + } +} + +func TestWindowInsertEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsert() + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0a) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertHead(t *testing.T) { + r := strings.NewReader(strings.Repeat("Hello, world!", 2)) + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.pageEnd() + window.startInsertHead() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(16); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!Hel:lo, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(27); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(17); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertHeadEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsertHead() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "J\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowAppend(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startAppend() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(8); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, w:orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(14); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(8); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.cursorNext(mode.Normal, 10) + window.startAppend() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0A) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, w:orld!:\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(15); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(14); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowAppendEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startAppend() + window.exitInsert() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.startAppend() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0a) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.startAppendEnd() + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x0b) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":;\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceByte(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 7) + window.startReplaceByte() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, :orld!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(7); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceByteEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startReplaceByte() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplace(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 10) + window.startReplace() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(10); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, wor:d!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(13); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(11); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0b) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0c) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0d) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0e) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, wor:;<=>\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(15); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(14); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowReplaceEmpty(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startReplace() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := int64(0); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0a) + window.insertByte(mode.Replace, 0x03) + window.insertByte(mode.Replace, 0x0b) + window.exitInsert() + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ":;\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(2); s.Length != expected { + t.Errorf("s.Length should be %d but got %d", expected, s.Length) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } +} + +func TestWindowInsertByte2(t *testing.T) { + r := strings.NewReader("") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.startInsert() + window.insertByte(mode.Insert, 0x00) + window.insertByte(mode.Insert, 0x01) + window.insertByte(mode.Insert, 0x02) + window.insertByte(mode.Insert, 0x03) + window.insertByte(mode.Insert, 0x04) + window.insertByte(mode.Insert, 0x05) + window.insertByte(mode.Insert, 0x06) + window.insertByte(mode.Insert, 0x07) + window.insertByte(mode.Insert, 0x08) + window.insertByte(mode.Insert, 0x09) + window.insertByte(mode.Insert, 0x0a) + window.insertByte(mode.Insert, 0x0b) + window.insertByte(mode.Insert, 0x0c) + window.insertByte(mode.Insert, 0x0d) + window.insertByte(mode.Insert, 0x0e) + window.insertByte(mode.Insert, 0x0f) + window.exitInsert() + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x01\x23\x45\x67\x89\xab\xcd\xef\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowBackspace(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.startInsert() + window.backspace(mode.Insert) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hell, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + window.backspace(mode.Insert) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := ", world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } +} + +func TestWindowBackspacePending(t *testing.T) { + r := strings.NewReader("Hello, world!") + width, height := 16, 10 + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + window.cursorNext(mode.Normal, 5) + window.startInsert() + window.insertByte(mode.Insert, 0x03) + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := true; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x30'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } + + window.backspace(mode.Insert) + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := false; s.Pending != expected { + t.Errorf("s.Pending should be %v but got %v", expected, s.Pending) + } + if expected := byte('\x00'); s.PendingByte != expected { + t.Errorf("s.PendingByte should be %q but got %q", expected, s.PendingByte) + } +} + +func TestWindowEventRune(t *testing.T) { + width, height := 16, 10 + redrawCh := make(chan struct{}) + window, err := newWindow(strings.NewReader(""), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + str := "48723fffab" + go func() { + defer close(redrawCh) + window.emit(event.Event{Type: event.StartInsert}) + for _, r := range str { + window.emit(event.Event{Type: event.Rune, Rune: r, Mode: mode.Insert}) + } + }() + <-redrawCh + for range str { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "\x48\x72\x3f\xff\xab\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + <-redrawCh +} + +func TestWindowEventRuneText(t *testing.T) { + width, height := 16, 10 + redrawCh := make(chan struct{}) + window, err := newWindow(strings.NewReader(""), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + str := "Hello, World!\nこんにちは、世界!\n鰰は魚の一種" + go func() { + defer close(redrawCh) + window.emit(event.Event{Type: event.SwitchFocus}) + window.emit(event.Event{Type: event.StartInsert}) + for _, r := range str { + window.emit(event.Event{Type: event.Rune, Rune: r, Mode: mode.Insert}) + } + }() + <-redrawCh + <-redrawCh + for range str { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := str + "\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + <-redrawCh +} + +func TestWindowEventUndoRedo(t *testing.T) { + width, height := 16, 10 + redrawCh, waitCh := make(chan struct{}), make(chan struct{}) + window, err := newWindow(strings.NewReader("Hello, world!"), "test", "test", nil, redrawCh) + if err != nil { + t.Fatal(err) + } + window.setSize(width, height) + + go func() { + defer func() { + close(redrawCh) + close(waitCh) + }() + window.emit(event.Event{Type: event.Undo}) + window.emit(event.Event{Type: event.SwitchFocus}) + window.emit(event.Event{Type: event.StartAppend, Mode: mode.Insert}) + + <-waitCh + window.emit(event.Event{Type: event.Rune, Rune: 'x', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'y', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'z', Mode: mode.Insert}) + window.emit(event.Event{Type: event.ExitInsert}) + + <-waitCh + window.emit(event.Event{Type: event.StartInsert, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'x', Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'y', Mode: mode.Insert}) + window.emit(event.Event{Type: event.CursorLeft, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'z', Mode: mode.Insert}) + window.emit(event.Event{Type: event.ExitInsert}) + + <-waitCh + window.emit(event.Event{Type: event.Undo, Count: 2}) + window.emit(event.Event{Type: event.StartInsert, Mode: mode.Insert}) + window.emit(event.Event{Type: event.Rune, Rune: 'w', Mode: mode.Insert}) + + <-waitCh + window.emit(event.Event{Type: event.ExitInsert}) + window.emit(event.Event{Type: event.Undo}) + + <-waitCh + window.emit(event.Event{Type: event.Redo, Count: 2}) + }() + + for range 3 { + <-redrawCh + } + s, err := window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(1); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 4 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 6 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyxzyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(5); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 3 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxywzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + for range 2 { + <-redrawCh + } + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxyzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(3); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + waitCh <- struct{}{} + + <-redrawCh + s, err = window.state(width, height) + if err != nil { + t.Fatal(err) + } + if expected := "Hxywzello, world!\x00"; !strings.HasPrefix(string(s.Bytes), expected) { + t.Errorf("s.Bytes should start with %q but got %q", expected, string(s.Bytes)) + } + if expected := int64(4); s.Cursor != expected { + t.Errorf("s.Cursor should be %d but got %d", expected, s.Cursor) + } + <-redrawCh + <-waitCh +} + +func TestWindowWriteTo(t *testing.T) { + r := strings.NewReader("Hello, world!") + window, err := newWindow(r, "test", "test", nil, nil) + if err != nil { + t.Fatal(err) + } + window.setSize(20, 10) + window.cursorNext(mode.Normal, 3) + window.startVisual() + window.cursorNext(mode.Normal, 7) + for _, testCase := range []struct { + r *event.Range + expected string + }{ + {nil, "Hello, world!"}, + {&event.Range{From: event.VisualStart{}, To: event.VisualEnd{}}, "lo, worl"}, + } { + b := new(bytes.Buffer) + n, err := window.writeTo(testCase.r, b) + if expected := int64(len(testCase.expected)); n != expected { + t.Errorf("writeTo should return %d but got: %d", expected, n) + } + if err != nil { + t.Errorf("err should be nil but got: %v", err) + } + if b.String() != testCase.expected { + t.Errorf("window should write %q with range %+v but got %q", testCase.expected, testCase.r, b.String()) + } + } +} diff --git a/gdu/.gitignore b/gdu/.gitignore new file mode 100644 index 0000000..0e51ce0 --- /dev/null +++ b/gdu/.gitignore @@ -0,0 +1,6 @@ +/.vscode +/.idea +/coverage.txt +/dist +/test_dir +/vendor \ No newline at end of file diff --git a/gdu/.golangci.yml b/gdu/.golangci.yml new file mode 100644 index 0000000..e5e9a87 --- /dev/null +++ b/gdu/.golangci.yml @@ -0,0 +1,122 @@ +linters-settings: + errcheck: + check-blank: true + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + # While we agree with this rule, right now it would break too many + # projects. So, we disable it by default. + - name: unused-parameter + disabled: true + gocyclo: + min-complexity: 25 + dupl: + threshold: 100 + goconst: + min-len: 3 + min-occurrences: 3 + lll: + line-length: 160 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - whyNoLint + funlen: + lines: 500 + statements: 50 + govet: + enable: + - shadow + +linters: + disable-all: true + enable: + - bodyclose + - dogsled + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - goconst + - gocritic + - gocyclo + - gofmt + - goimports + - revive + - gosimple + - govet + - ineffassign + - lll + - nakedret + - staticcheck + - typecheck + - unparam + - unused + - whitespace + +issues: + exclude: + # We allow error shadowing + - 'declaration of "err" shadows declaration at' + + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - funlen + - gocritic + - gochecknoglobals # Globals in test files are tolerated. + - goconst # Repeated consts in test files are tolerated. + # This rule is buggy and breaks on our `///Block` lines. Disable for now. + - linters: + - gocritic + text: "commentFormatting: put a space" + # This rule incorrectly flags nil references after assert.Assert(t, x != nil) + - path: _test\.go + text: "SA5011" + linters: + - staticcheck + - linters: + - lll + source: "^//go:generate " + - linters: + - lll + - gocritic + path: \.resolvers\.go + source: '^func \(r \*[a-zA-Z]+Resolvers\) ' + +output: + formats: + - format: colored-line-number + sort-results: true diff --git a/gdu/.tool-versions b/gdu/.tool-versions new file mode 100644 index 0000000..e0217ba --- /dev/null +++ b/gdu/.tool-versions @@ -0,0 +1 @@ +golang 1.23.3 diff --git a/gdu/Dockerfile b/gdu/Dockerfile new file mode 100644 index 0000000..07b7d42 --- /dev/null +++ b/gdu/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.io/library/golang:1.23 as builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN make build-static + +FROM scratch + +COPY --from=builder /app/dist/gdu /opt/gdu + +ENTRYPOINT ["/opt/gdu"] \ No newline at end of file diff --git a/gdu/INSTALL.md b/gdu/INSTALL.md new file mode 100644 index 0000000..27c026d --- /dev/null +++ b/gdu/INSTALL.md @@ -0,0 +1,142 @@ +# Installation + +[Arch Linux](https://archlinux.org/packages/extra/x86_64/gdu/): + + pacman -S gdu + +[Debian](https://packages.debian.org/bullseye/gdu): + + apt install gdu + +[Ubuntu](https://launchpad.net/~daniel-milde/+archive/ubuntu/gdu) + + add-apt-repository ppa:daniel-milde/gdu + apt-get update + apt-get install gdu + +[NixOS](https://search.nixos.org/packages?channel=unstable&show=gdu&query=gdu): + + nix-env -iA nixos.gdu + +[Homebrew](https://formulae.brew.sh/formula/gdu): + + brew install -f gdu + # gdu will be installed as `gdu-go` to avoid conflicts with coreutils + gdu-go + +[Snap](https://snapcraft.io/gdu-disk-usage-analyzer): + + snap install gdu-disk-usage-analyzer + snap connect gdu-disk-usage-analyzer:mount-observe :mount-observe + snap connect gdu-disk-usage-analyzer:system-backup :system-backup + snap alias gdu-disk-usage-analyzer.gdu gdu + +[Binenv](https://github.com/devops-works/binenv) + + binenv install gdu + +[Go](https://pkg.go.dev/github.com/dundee/gdu): + + go install b612.me/apps/b612/gdu/cmd/gdu@latest + +[Winget](https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/dundee/gdu) (for Windows users): + + winget install gdu + +You can either run it as `gdu_windows_amd64.exe` or +* add an alias with `Doskey`. +* add `alias gdu="gdu_windows_amd64.exe"` to your `~/.bashrc` file if using Git Bash to run it as `gdu`. + +You might need to restart your terminal. + +[Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/gdu.json): + + scoop install gdu + +[X-cmd](https://www.x-cmd.com/start/) + + x env use gdu + +## [COPR builds](https://copr.fedorainfracloud.org/coprs/faramirza/gdu/) +COPR Builds exist for the the following Linux Distros. + +[How to enable a CORP Repo](https://docs.pagure.org/copr.copr/how_to_enable_repo.html) + +Amazon Linux 2023: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/amazonlinux-2023-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 7: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-7-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 8: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-8-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +EPEL 9: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-9-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 38: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` +Fedora 39: +``` +[copr:copr.fedorainfracloud.org:faramirza:gdu] +name=Copr repo for gdu owned by faramirza +baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ +type=rpm-md +skip_if_unavailable=True +gpgcheck=1 +gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg +repo_gpgcheck=0 +enabled=1 +enabled_metadata=1 +``` diff --git a/gdu/LICENSE.md b/gdu/LICENSE.md new file mode 100644 index 0000000..3d3b99f --- /dev/null +++ b/gdu/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2020-2021 Daniel Milde + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/gdu/Makefile b/gdu/Makefile new file mode 100644 index 0000000..5699d58 --- /dev/null +++ b/gdu/Makefile @@ -0,0 +1,159 @@ +NAME := gdu +MAJOR_VER := v5 +PACKAGE := github.com/dundee/$(NAME)/$(MAJOR_VER) +CMD_GDU := cmd/gdu +VERSION := $(shell git describe --tags 2>/dev/null) +NAMEVER := $(NAME)-$(subst v,,$(VERSION)) +DATE := $(shell date +'%Y-%m-%d') +GOFLAGS ?= -buildmode=pie -trimpath -mod=readonly -modcacherw -pgo=default.pgo +GOFLAGS_STATIC ?= -trimpath -mod=readonly -modcacherw -pgo=default.pgo +LDFLAGS := -s -w -extldflags '-static' \ + -X '$(PACKAGE)/build.Version=$(VERSION)' \ + -X '$(PACKAGE)/build.User=$(shell id -u -n)' \ + -X '$(PACKAGE)/build.Time=$(shell LC_ALL=en_US.UTF-8 date)' +TAR := tar +ifeq ($(shell uname -s),Darwin) + TAR := gtar # brew install gnu-tar +endif + +all: clean tarball build-all build-docker man clean-uncompressed-dist shasums + +run: + go run $(PACKAGE)/$(CMD_GDU) + +vendor: go.mod go.sum + go mod vendor + +tarball: vendor + -mkdir dist + $(TAR) czf dist/$(NAMEVER).tgz --transform "s,^,$(NAMEVER)/," --exclude dist --exclude test_dir --exclude coverage.txt * + +build: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-static: + @echo "Version: " $(VERSION) + mkdir -p dist + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) + +build-docker: + @echo "Version: " $(VERSION) + docker build . --tag ghcr.io/dundee/gdu:$(VERSION) + +build-all: + @echo "Version: " $(VERSION) + -mkdir dist + -CGO_ENABLED=0 gox \ + -os="darwin" \ + -arch="amd64 arm64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="windows" \ + -arch="amd64" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + -CGO_ENABLED=0 gox \ + -os="linux freebsd netbsd openbsd" \ + -output="dist/gdu_{{.OS}}_{{.Arch}}" \ + -ldflags="$(LDFLAGS)" \ + $(PACKAGE)/$(CMD_GDU) + + GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64-x $(PACKAGE)/$(CMD_GDU) + GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64_static $(PACKAGE)/$(CMD_GDU) + + CGO_ENABLED=0 GOOS=linux GOARM=5 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv5l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv6l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv7l $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_arm64 $(PACKAGE)/$(CMD_GDU) + CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/gdu_android_arm64 $(PACKAGE)/$(CMD_GDU) + + cd dist; for file in gdu_linux_* gdu_darwin_* gdu_netbsd_* gdu_openbsd_* gdu_freebsd_* gdu_android_*; do tar czf $$file.tgz $$file; done + cd dist; for file in gdu_windows_*; do zip $$file.zip $$file; done + +gdu.1: gdu.1.md + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man > gdu.1 + rm -f gdu.1.date.md + +man: gdu.1 + cp gdu.1 dist + cd dist; tar czf gdu.1.tgz gdu.1 + +show-man: + sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md + pandoc gdu.1.date.md -s -t man | man -l - + +test: + gotestsum + +coverage: + gotestsum -- -race -coverprofile=coverage.txt -covermode=atomic ./... + +coverage-html: coverage + go tool cover -html=coverage.txt + +gobench: + go test -bench=. $(PACKAGE)/pkg/analyze + +heap-profile: + go tool pprof -web http://localhost:6060/debug/pprof/heap + +pgo: + wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 + go tool pprof -proto cpu.pprof default.pgo > merged.pprof + mv merged.pprof default.pgo + +trace: + wget -O trace.out http://localhost:6060/debug/pprof/trace?seconds=30 + gotraceui ./trace.out + +benchmark: + sudo cpupower frequency-set -g performance + hyperfine --export-markdown=bench-cold.md \ + --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + hyperfine --export-markdown=bench-warm.md \ + --warmup 5 \ + --ignore-failure \ + 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ + 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ + 'gdu -npc ~' 'gdu -gnpc ~' 'gdu -npc --use-storage ~' + sudo cpupower frequency-set -g schedutil + +lint: + golangci-lint run -c .golangci.yml + +clean: + go mod tidy + -rm coverage.txt + -rm -r test_dir + -rm -r vendor + -rm -r dist + +clean-uncompressed-dist: + find dist -type f -not -name '*.tgz' -not -name '*.zip' -delete + +shasums: + cd dist; sha256sum * > sha256sums.txt + cd dist; gpg --sign --armor --detach-sign sha256sums.txt + +release: + gh release create -t "gdu $(VERSION)" $(VERSION) ./dist/* + +install-dev-dependencies: + go install gotest.tools/gotestsum@latest + go install github.com/mitchellh/gox@latest + go install honnef.co/go/gotraceui/cmd/gotraceui@master + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +.PHONY: run build build-static build-all test gobench benchmark coverage coverage-html clean clean-uncompressed-dist man show-man release diff --git a/gdu/README.md b/gdu/README.md new file mode 100644 index 0000000..db2b90a --- /dev/null +++ b/gdu/README.md @@ -0,0 +1,308 @@ +# go DiskUsage() + +Gdu + +[![Codecov](https://codecov.io/gh/dundee/gdu/branch/master/graph/badge.svg)](https://codecov.io/gh/dundee/gdu) +[![Go Report Card](https://goreportcard.com/badge/github.com/dundee/gdu)](https://goreportcard.com/report/github.com/dundee/gdu) +[![Maintainability](https://api.codeclimate.com/v1/badges/30d793274607f599e658/maintainability)](https://codeclimate.com/github/dundee/gdu/maintainability) +[![CodeScene Code Health](https://codescene.io/projects/13129/status-badges/code-health)](https://codescene.io/projects/13129) + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. +However HDDs work as well, but the performance gain is not so huge. + +[![asciicast](https://asciinema.org/a/382738.svg)](https://asciinema.org/a/382738) + + + Packaging status + + +## Installation + +Head for the [releases page](https://github.com/dundee/gdu/releases) and download the binary for your system. + +Using curl: + + curl -L https://github.com/dundee/gdu/releases/latest/download/gdu_linux_amd64.tgz | tar xz + chmod +x gdu_linux_amd64 + mv gdu_linux_amd64 /usr/bin/gdu + +See the [installation page](./INSTALL.md) for other ways how to install Gdu to your system. + +Or you can use Gdu directly via Docker: + + docker run --rm --init --interactive --tty --privileged --volume /:/mnt/root ghcr.io/dundee/gdu /mnt/root + +## Usage + +``` + gdu [flags] [directory_to_scan] + +Flags: + --config-file string Read config from file (default is $HOME/.gdu.yaml) + -g, --const-gc Enable memory garbage collection during analysis with constant level set by GOGC + --enable-profiling Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + -L, --follow-symlinks Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + -h, --help help for gdu + -i, --ignore-dirs strings Paths to ignore (separated by comma). Can be absolute or relative to current directory (default [/proc,/dev,/sys,/run]) + -I, --ignore-dirs-pattern strings Path patterns to ignore (separated by comma) + -X, --ignore-from string Read path patterns to ignore from file + -f, --input-file string Import analysis from JSON file + -l, --log-file string Path to a logfile (default "/dev/null") + -m, --max-cores int Set max cores that Gdu will use. 12 cores available (default 12) + -c, --no-color Do not use colorized output + -x, --no-cross Do not cross filesystem boundaries + --no-delete Do not allow deletions + -H, --no-hidden Ignore hidden directories (beginning with dot) + --no-mouse Do not use mouse + --no-prefix Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + -p, --no-progress Do not show progress in non-interactive mode + -u, --no-unicode Do not use Unicode symbols (for size bar) + -n, --non-interactive Do not run in interactive mode + -o, --output-file string Export all info into file as JSON + -r, --read-from-storage Read analysis data from persistent key-value storage + --sequential Use sequential scanning (intended for rotating HDDs) + -a, --show-apparent-size Show apparent size + -d, --show-disks Show all mounted disks + -C, --show-item-count Show number of items in directory + -M, --show-mtime Show latest mtime of items in directory + -B, --show-relative-size Show relative size + --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + --storage-path string Path to persistent key-value storage directory (default "/tmp/badger") + -s, --summarize Show only a total in non-interactive mode + -t, --top int Show only top X largest files in non-interactive mode + --use-storage Use persistent key-value storage for analysis data (experimental) + -v, --version Print version + --write-config Write current configuration to file (default is $HOME/.gdu.yaml) + +Basic list of actions in interactive mode (show help modal for more): + ↑ or k Move cursor up + ↓ or j Move cursor down + → or Enter or l Go to highlighted directory + ← or h Go to parent directory + d Delete the selected file or directory + e Empty the selected directory + n Sort by name + s Sort by size + c Show number of items in directory + ? Show help modal +``` + +## Examples + + gdu # analyze current dir + gdu -a # show apparent size instead of disk usage + gdu --no-delete # prevent write operations + gdu # analyze given dir + gdu -d # show all mounted disks + gdu -l ./gdu.log # write errors to log file + gdu -i /sys,/proc / # ignore some paths + gdu -I '.*[abc]+' # ignore paths by regular pattern + gdu -X ignore_file / # ignore paths by regular patterns from file + gdu -c / # use only white/gray/black colors + + gdu -n / # only print stats, do not start interactive mode + gdu -np / # do not show progress, useful when using its output in a script + gdu -nps /some/dir # show only total usage for given dir + gdu -nt 10 / # show top 10 largest files + gdu / > file # write stats to file, do not start interactive mode + + gdu -o- / | gzip -c >report.json.gz # write all info to JSON file for later analysis + zcat report.json.gz | gdu -f- # read analysis from file + + GOGC=10 gdu -g --use-storage / # use persistent key-value storage for saving analysis data + gdu -r / # read saved analysis data from persistent key-value storage + +## Modes + +Gdu has three modes: interactive (default), non-interactive and export. + +Non-interactive mode is started automatically when TTY is not detected (using [go-isatty](https://github.com/mattn/go-isatty)), for example if the output is being piped to a file, or it can be started explicitly by using a flag. + +Export mode (flag `-o`) outputs all usage data as JSON, which can be later opened using the `-f` flag. + +Hard links are counted only once. + +## File flags + +Files and directories may be prefixed by a one-character +flag with following meaning: + +* `!` An error occurred while reading this directory. + +* `.` An error occurred while reading a subdirectory, size may be not correct. + +* `@` File is symlink or socket. + +* `H` Same file was already counted (hard link). + +* `e` Directory is empty. + +## Configuration file + +Gdu can read (and write) YAML configuration file. + +`$HOME/.config/gdu/gdu.yaml` and `$HOME/.gdu.yaml` are checked for the presence of the config file by default. + +See the [full list of all configuration options](configuration). + +### Examples + +* To configure gdu to permanently run in gray-scale color mode: + +``` +echo "no-color: true" >> ~/.gdu.yaml +``` + +* To set default sorting in configuration file: + +``` +sorting: + by: name // size, name, itemCount, mtime + order: desc +``` + +* To configure gdu to set CWD variable when browsing directories: + +``` +echo "change-cwd: true" >> ~/.gdu.yaml +``` + +* To save the current configuration + +``` +gdu --write-config +``` + +## Styling + +There are wide options for how terminals can be colored. +Some gdu primitives (like basic text) adapt to different color schemas, but the selected/highlighted row does not. + +If the default look is not sufficient, it can be changed in configuration file, e.g.: + +``` +style: + selected-row: + text-color: black + background-color: "#ff0000" +``` + +## Deletion in background and in parallel (experimental) + +Gdu can delete items in the background, thus not blocking the UI for additional work. +To enable: + +``` +echo "delete-in-background: true" >> ~/.gdu.yaml +``` + +Directory items can be also deleted in parallel, which might increase the speed of deletion. +To enable: + +``` +echo "delete-in-parallel: true" >> ~/.gdu.yaml +``` + +## Memory usage + +### Automatic balancing + +Gdu tries to balance performance and memory usage. + +When less memory is used by gdu than the total free memory of the host, +then Garbage Collection is disabled during the analysis phase completely to gain maximum speed. + +Otherwise GC is enabled. +The more memory is used and the less memory is free, the more often will the GC happen. + +### Manual memory usage control + +If you want manual control over Garbage Collection, you can use `--const-gc` / `-g` flag. +It will run Garbage Collection during the analysis phase with constant level of aggressiveness. +As a result, the analysis will be about 25% slower and will consume about 30% less memory. +To change the level, you can set the `GOGC` environment variable to specify how often the garbage collection will happen. +Lower value (than 100) means GC will run more often. Higher means less often. Negative number will stop GC. + +Example running gdu with constant GC, but not so aggressive as default: + +``` +GOGC=200 gdu -g / +``` + +## Saving analysis data to persistent key-value storage (experimental) + +Gdu can store the analysis data to persistent key-value storage instead of just memory. +Gdu will run much slower (approx 10x) but it should use much less memory (when using small GOGC as well). +Gdu can also reopen with the saved data. +Currently only BadgerDB is supported as the key-value storage (embedded). + +``` +GOGC=10 gdu -g --use-storage / # saves analysis data to key-value storage +gdu -r / # reads just saved data, does not run analysis again +``` + +## Running tests + + make install-dev-dependencies + make test + +## Profiling + +Gdu can collect profiling data when the `--enable-profiling` flag is set. +The data are provided via embedded http server on URL `http://localhost:6060/debug/pprof/`. + +You can then use e.g. `go tool pprof -web http://localhost:6060/debug/pprof/heap` +to open the heap profile as SVG image in your web browser. + +## Benchmarks + +Benchmarks were performed on 50G directory (100k directories, 400k files) on 500 GB SSD using [hyperfine](https://github.com/sharkdp/hyperfine). +See `benchmark` target in [Makefile](Makefile) for more info. + +### Cold cache + +Filesystem cache was cleared using `sync; echo 3 | sudo tee /proc/sys/vm/drop_caches`. + +| Command | Mean [s] | Min [s] | Max [s] | Relative | +|:---|---:|---:|---:|---:| +| `diskus ~` | 3.126 ± 0.020 | 3.087 | 3.155 | 1.00 | +| `gdu -npc ~` | 3.132 ± 0.019 | 3.111 | 3.173 | 1.00 ± 0.01 | +| `gdu -gnpc ~` | 3.136 ± 0.012 | 3.112 | 3.155 | 1.00 ± 0.01 | +| `pdu ~` | 3.657 ± 0.013 | 3.641 | 3.677 | 1.17 ± 0.01 | +| `dust -d0 ~` | 3.933 ± 0.144 | 3.849 | 4.213 | 1.26 ± 0.05 | +| `dua ~` | 3.994 ± 0.073 | 3.827 | 4.134 | 1.28 ± 0.02 | +| `gdu -npc --use-storage ~` | 12.812 ± 0.078 | 12.644 | 12.912 | 4.10 ± 0.04 | +| `du -hs ~` | 14.120 ± 0.213 | 13.969 | 14.703 | 4.52 ± 0.07 | +| `duc index ~` | 14.567 ± 0.080 | 14.385 | 14.657 | 4.66 ± 0.04 | +| `ncdu -0 -o /dev/null ~` | 14.963 ± 0.254 | 14.759 | 15.637 | 4.79 ± 0.09 | + +### Warm cache + +| Command | Mean [ms] | Min [ms] | Max [ms] | Relative | +|:---|---:|---:|---:|---:| +| `pdu ~` | 226.6 ± 3.7 | 219.6 | 231.2 | 1.00 | +| `diskus ~` | 227.7 ± 5.2 | 221.6 | 239.9 | 1.00 ± 0.03 | +| `dust -d0 ~` | 400.1 ± 7.1 | 386.7 | 409.4 | 1.77 ± 0.04 | +| `dua ~` | 444.9 ± 2.4 | 442.4 | 448.9 | 1.96 ± 0.03 | +| `gdu -npc ~` | 451.3 ± 3.8 | 445.9 | 458.5 | 1.99 ± 0.04 | +| `gdu -gnpc ~` | 516.1 ± 6.7 | 503.1 | 527.5 | 2.28 ± 0.05 | +| `du -hs ~` | 905.0 ± 3.9 | 901.2 | 913.4 | 3.99 ± 0.07 | +| `duc index ~` | 1053.0 ± 5.1 | 1046.2 | 1064.1 | 4.65 ± 0.08 | +| `ncdu -0 -o /dev/null ~` | 1653.9 ± 5.7 | 1645.9 | 1663.0 | 7.30 ± 0.12 | +| `gdu -npc --use-storage ~` | 9754.9 ± 688.7 | 8403.8 | 10427.4 | 43.04 ± 3.12 | + +## Alternatives + +* [ncdu](https://dev.yorhel.nl/ncdu) - NCurses based tool written in pure `C` (LTS) or `zig` (Stable) +* [godu](https://github.com/viktomas/godu) - Analyzer with a carousel like user interface +* [dua](https://github.com/Byron/dua-cli) - Tool written in `Rust` with interface similar to gdu (and ncdu) +* [diskus](https://github.com/sharkdp/diskus) - Very simple but very fast tool written in `Rust` +* [duc](https://duc.zevv.nl/) - Collection of tools with many possibilities for inspecting and visualising disk usage +* [dust](https://github.com/bootandy/dust) - Tool written in `Rust` showing tree like structures of disk usage +* [pdu](https://github.com/KSXGitHub/parallel-disk-usage) - Tool written in `Rust` showing tree like structures of disk usage + +## Notes + +[HDD icon created by Nikita Golubev - Flaticon](https://www.flaticon.com/free-icons/hdd) \ No newline at end of file diff --git a/gdu/build/build.go b/gdu/build/build.go new file mode 100644 index 0000000..0fa5725 --- /dev/null +++ b/gdu/build/build.go @@ -0,0 +1,16 @@ +package build + +import "b612.me/apps/b612/version" + +// Version stores the current version of the app +var Version = version.Version + +// Time of the build +var Time string + +// User who built it +var User string + +// RootPathPrefix stores path to be prepended to given absolute path +// e.g. /var/lib/snapd/hostfs for snap +var RootPathPrefix = "" diff --git a/gdu/cmd/gdu/app/app.go b/gdu/cmd/gdu/app/app.go new file mode 100644 index 0000000..9aa7fb7 --- /dev/null +++ b/gdu/cmd/gdu/app/app.go @@ -0,0 +1,473 @@ +package app + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "net/http/pprof" + "os" + "path/filepath" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/build" + "b612.me/apps/b612/gdu/internal/common" + "b612.me/apps/b612/gdu/pkg/analyze" + "b612.me/apps/b612/gdu/pkg/device" + gfs "b612.me/apps/b612/gdu/pkg/fs" + "b612.me/apps/b612/gdu/report" + "b612.me/apps/b612/gdu/stdout" + "b612.me/apps/b612/gdu/tui" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// UI is common interface for both terminal UI and text output +type UI interface { + ListDevices(getter device.DevicesInfoGetter) error + AnalyzePath(path string, parentDir gfs.Item) error + ReadAnalysis(input io.Reader) error + ReadFromStorage(storagePath, path string) error + SetIgnoreDirPaths(paths []string) + SetIgnoreDirPatterns(paths []string) error + SetIgnoreFromFile(ignoreFile string) error + SetIgnoreHidden(value bool) + SetFollowSymlinks(value bool) + SetShowAnnexedSize(value bool) + SetAnalyzer(analyzer common.Analyzer) + StartUILoop() error +} + +// Flags define flags accepted by Run +type Flags struct { + CfgFile string `yaml:"-"` + LogFile string `yaml:"log-file"` + InputFile string `yaml:"input-file"` + OutputFile string `yaml:"output-file"` + IgnoreDirs []string `yaml:"ignore-dirs"` + IgnoreDirPatterns []string `yaml:"ignore-dir-patterns"` + IgnoreFromFile string `yaml:"ignore-from-file"` + MaxCores int `yaml:"max-cores"` + SequentialScanning bool `yaml:"sequential-scanning"` + ShowDisks bool `yaml:"-"` + ShowApparentSize bool `yaml:"show-apparent-size"` + ShowRelativeSize bool `yaml:"show-relative-size"` + ShowAnnexedSize bool `yaml:"show-annexed-size"` + ShowVersion bool `yaml:"-"` + ShowItemCount bool `yaml:"show-item-count"` + ShowMTime bool `yaml:"show-mtime"` + NoColor bool `yaml:"no-color"` + NoMouse bool `yaml:"no-mouse"` + NonInteractive bool `yaml:"non-interactive"` + NoProgress bool `yaml:"no-progress"` + NoUnicode bool `yaml:"no-unicode"` + NoCross bool `yaml:"no-cross"` + NoHidden bool `yaml:"no-hidden"` + NoDelete bool `yaml:"no-delete"` + FollowSymlinks bool `yaml:"follow-symlinks"` + Profiling bool `yaml:"profiling"` + ConstGC bool `yaml:"const-gc"` + UseStorage bool `yaml:"use-storage"` + StoragePath string `yaml:"storage-path"` + ReadFromStorage bool `yaml:"read-from-storage"` + Summarize bool `yaml:"summarize"` + Top int `yaml:"top"` + UseSIPrefix bool `yaml:"use-si-prefix"` + NoPrefix bool `yaml:"no-prefix"` + WriteConfig bool `yaml:"-"` + ChangeCwd bool `yaml:"change-cwd"` + DeleteInBackground bool `yaml:"delete-in-background"` + DeleteInParallel bool `yaml:"delete-in-parallel"` + Style Style `yaml:"style"` + Sorting Sorting `yaml:"sorting"` +} + +// Style define style config +type Style struct { + SelectedRow ColorStyle `yaml:"selected-row"` + ProgressModal ProgressModalOpts `yaml:"progress-modal"` + UseOldSizeBar bool `yaml:"use-old-size-bar"` + Footer FooterColorStyle `yaml:"footer"` + Header HeaderColorStyle `yaml:"header"` + ResultRow ResultRowColorStyle `yaml:"result-row"` +} + +// ProgressModalOpts defines options for progress modal +type ProgressModalOpts struct { + CurrentItemNameMaxLen int `yaml:"current-item-path-max-len"` +} + +// ColorStyle defines styling of some item +type ColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` +} + +// FooterColorStyle defines styling of footer +type FooterColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + NumberColor string `yaml:"number-color"` +} + +// HeaderColorStyle defines styling of header +type HeaderColorStyle struct { + TextColor string `yaml:"text-color"` + BackgroundColor string `yaml:"background-color"` + Hidden bool `yaml:"hidden"` +} + +// ResultRowColorStyle defines styling of result row +type ResultRowColorStyle struct { + NumberColor string `yaml:"number-color"` + DirectoryColor string `yaml:"directory-color"` +} + +// Sorting defines default sorting of items +type Sorting struct { + By string `yaml:"by"` + Order string `yaml:"order"` +} + +// App defines the main application +type App struct { + Args []string + Flags *Flags + Istty bool + Writer io.Writer + TermApp common.TermApplication + Screen tcell.Screen + Getter device.DevicesInfoGetter + PathChecker func(string) (fs.FileInfo, error) +} + +func init() { + http.DefaultServeMux = http.NewServeMux() +} + +// Run starts gdu main logic +func (a *App) Run() error { + var ui UI + + if a.Flags.ShowVersion { + fmt.Fprintln(a.Writer, "Version:\t", build.Version) + fmt.Fprintln(a.Writer, "Built time:\t", build.Time) + fmt.Fprintln(a.Writer, "Built user:\t", build.User) + return nil + } + + log.Printf("Runtime flags: %+v", *a.Flags) + + if a.Flags.NoPrefix && a.Flags.UseSIPrefix { + return fmt.Errorf("--no-prefix and --si cannot be used at once") + } + + path := a.getPath() + path, err := filepath.Abs(path) + if err != nil { + return err + } + + ui, err = a.createUI() + if err != nil { + return err + } + + if a.Flags.UseStorage { + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.StoragePath)) + } + if a.Flags.SequentialScanning { + ui.SetAnalyzer(analyze.CreateSeqAnalyzer()) + } + if a.Flags.FollowSymlinks { + ui.SetFollowSymlinks(true) + } + if a.Flags.ShowAnnexedSize { + ui.SetShowAnnexedSize(true) + } + if err := a.setNoCross(path); err != nil { + return err + } + + ui.SetIgnoreDirPaths(a.Flags.IgnoreDirs) + + if len(a.Flags.IgnoreDirPatterns) > 0 { + if err := ui.SetIgnoreDirPatterns(a.Flags.IgnoreDirPatterns); err != nil { + return err + } + } + + if a.Flags.IgnoreFromFile != "" { + if err := ui.SetIgnoreFromFile(a.Flags.IgnoreFromFile); err != nil { + return err + } + } + + if a.Flags.NoHidden { + ui.SetIgnoreHidden(true) + } + + a.setMaxProcs() + + if err := a.runAction(ui, path); err != nil { + return err + } + + return ui.StartUILoop() +} + +func (a *App) getPath() string { + if len(a.Args) == 1 { + return a.Args[0] + } + return "." +} + +func (a *App) setMaxProcs() { + if a.Flags.MaxCores < 1 || a.Flags.MaxCores > runtime.NumCPU() { + return + } + + runtime.GOMAXPROCS(a.Flags.MaxCores) + + // runtime.GOMAXPROCS(n) with n < 1 doesn't change current setting so we use it to check current value + log.Printf("Max cores set to %d", runtime.GOMAXPROCS(0)) +} + +func (a *App) createUI() (UI, error) { + var ui UI + + switch { + case a.Flags.OutputFile != "": + var output io.Writer + var err error + if a.Flags.OutputFile == "-" { + output = os.Stdout + } else { + output, err = os.OpenFile(a.Flags.OutputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("opening output file: %w", err) + } + } + ui = report.CreateExportUI( + a.Writer, + output, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + ) + case a.Flags.NonInteractive || !a.Istty: + stdoutUI := stdout.CreateStdoutUI( + a.Writer, + !a.Flags.NoColor && a.Istty, + !a.Flags.NoProgress && a.Istty, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.Summarize, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + a.Flags.NoPrefix, + a.Flags.Top, + ) + if a.Flags.NoUnicode { + stdoutUI.UseOldProgressRunes() + } + ui = stdoutUI + default: + opts := a.getOptions() + + ui = tui.CreateUI( + a.TermApp, + a.Screen, + os.Stdout, + !a.Flags.NoColor, + a.Flags.ShowApparentSize, + a.Flags.ShowRelativeSize, + a.Flags.ConstGC, + a.Flags.UseSIPrefix, + opts..., + ) + + if !a.Flags.NoColor { + tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227) + } else { + tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(150, 150, 150) + } + tview.Styles.BorderColor = tcell.ColorDefault + } + + return ui, nil +} + +func (a *App) getOptions() []tui.Option { + var opts []tui.Option + + if a.Flags.Style.SelectedRow.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedTextColor(tcell.GetColor(a.Flags.Style.SelectedRow.TextColor)) + }) + } + if a.Flags.Style.SelectedRow.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetSelectedBackgroundColor(tcell.GetColor(a.Flags.Style.SelectedRow.BackgroundColor)) + }) + } + if a.Flags.Style.Footer.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterTextColor(a.Flags.Style.Footer.TextColor) + }) + } + if a.Flags.Style.Footer.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterBackgroundColor(a.Flags.Style.Footer.BackgroundColor) + }) + } + if a.Flags.Style.Footer.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetFooterNumberColor(a.Flags.Style.Footer.NumberColor) + }) + } + if a.Flags.Style.Header.TextColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderTextColor(a.Flags.Style.Header.TextColor) + }) + } + if a.Flags.Style.Header.BackgroundColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderBackgroundColor(a.Flags.Style.Header.BackgroundColor) + }) + } + if a.Flags.Style.Header.Hidden { + opts = append(opts, func(ui *tui.UI) { + ui.SetHeaderHidden() + }) + } + if a.Flags.Style.ResultRow.NumberColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowNumberColor(a.Flags.Style.ResultRow.NumberColor) + }) + } + if a.Flags.Style.ResultRow.DirectoryColor != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetResultRowDirectoryColor(a.Flags.Style.ResultRow.DirectoryColor) + }) + } + if a.Flags.Style.ProgressModal.CurrentItemNameMaxLen > 0 { + opts = append(opts, func(ui *tui.UI) { + ui.SetCurrentItemNameMaxLen(a.Flags.Style.ProgressModal.CurrentItemNameMaxLen) + }) + } + if a.Flags.Style.UseOldSizeBar || a.Flags.NoUnicode { + opts = append(opts, func(ui *tui.UI) { + ui.UseOldSizeBar() + }) + } + if a.Flags.Sorting.Order != "" || a.Flags.Sorting.By != "" { + opts = append(opts, func(ui *tui.UI) { + ui.SetDefaultSorting(a.Flags.Sorting.By, a.Flags.Sorting.Order) + }) + } + if a.Flags.ChangeCwd { + opts = append(opts, func(ui *tui.UI) { + ui.SetChangeCwdFn(os.Chdir) + }) + } + if a.Flags.ShowItemCount { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowItemCount() + }) + } + if a.Flags.ShowMTime { + opts = append(opts, func(ui *tui.UI) { + ui.SetShowMTime() + }) + } + if a.Flags.NoDelete { + opts = append(opts, func(ui *tui.UI) { + ui.SetNoDelete() + }) + } + if a.Flags.DeleteInBackground { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInBackground() + }) + } + if a.Flags.DeleteInParallel { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInParallel() + }) + } + return opts +} + +func (a *App) setNoCross(path string) error { + if a.Flags.NoCross { + mounts, err := a.Getter.GetMounts() + if err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + paths := device.GetNestedMountpointsPaths(path, mounts) + log.Printf("Ignoring mount points: %s", strings.Join(paths, ", ")) + a.Flags.IgnoreDirs = append(a.Flags.IgnoreDirs, paths...) + } + return nil +} + +func (a *App) runAction(ui UI, path string) error { + if a.Flags.Profiling { + go func() { + http.HandleFunc("/debug/pprof/", pprof.Index) + http.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + http.HandleFunc("/debug/pprof/profile", pprof.Profile) + http.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + http.HandleFunc("/debug/pprof/trace", pprof.Trace) + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + } + + switch { + case a.Flags.ShowDisks: + if err := ui.ListDevices(a.Getter); err != nil { + return fmt.Errorf("loading mount points: %w", err) + } + case a.Flags.InputFile != "": + var input io.Reader + var err error + if a.Flags.InputFile == "-" { + input = os.Stdin + } else { + input, err = os.OpenFile(a.Flags.InputFile, os.O_RDONLY, 0o600) + if err != nil { + return fmt.Errorf("opening input file: %w", err) + } + } + + if err := ui.ReadAnalysis(input); err != nil { + return fmt.Errorf("reading analysis: %w", err) + } + case a.Flags.ReadFromStorage: + ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.StoragePath)) + if err := ui.ReadFromStorage(a.Flags.StoragePath, path); err != nil { + return fmt.Errorf("reading from storage (%s): %w", a.Flags.StoragePath, err) + } + default: + if build.RootPathPrefix != "" { + path = build.RootPathPrefix + path + } + + _, err := a.PathChecker(path) + if err != nil { + return err + } + + log.Printf("Analyzing path: %s", path) + if err := ui.AnalyzePath(path, nil); err != nil { + return fmt.Errorf("scanning dir: %w", err) + } + } + return nil +} diff --git a/gdu/cmd/gdu/app/app_linux_test.go b/gdu/cmd/gdu/app/app_linux_test.go new file mode 100644 index 0000000..e1e4826 --- /dev/null +++ b/gdu/cmd/gdu/app/app_linux_test.go @@ -0,0 +1,123 @@ +//go:build linux +// +build linux + +package app + +import ( + "os" + "testing" + + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func TestNoCrossWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) + assert.Empty(t, out) +} + +func TestListDevicesWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, + ) + + assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) +} + +func TestOutputFileError(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "/xyzxyz"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "permission denied") +} + +func TestUseStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + const storagePath = "/tmp/badger-test" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", UseStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestReadFromStorage(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + storagePath := "/tmp/badger-test4" + defer func() { + err := os.RemoveAll(storagePath) + if err != nil { + panic(err) + } + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", UseStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) + + out, err = runApp( + &Flags{LogFile: "/dev/null", ReadFromStorage: true, StoragePath: storagePath}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestReadFromStorageWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + _, err := runApp( + &Flags{LogFile: "/dev/null", ReadFromStorage: true, StoragePath: "/tmp/badger-xxx"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.ErrorContains(t, err, "Key not found") +} diff --git a/gdu/cmd/gdu/app/app_test.go b/gdu/cmd/gdu/app/app_test.go new file mode 100644 index 0000000..6184e4f --- /dev/null +++ b/gdu/cmd/gdu/app/app_test.go @@ -0,0 +1,566 @@ +package app + +import ( + "bytes" + "os" + "runtime" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + + "b612.me/apps/b612/gdu/internal/testapp" + "b612.me/apps/b612/gdu/internal/testdev" + "b612.me/apps/b612/gdu/internal/testdir" + "b612.me/apps/b612/gdu/pkg/device" + "github.com/stretchr/testify/assert" +) + +func init() { + log.SetLevel(log.WarnLevel) +} + +func TestVersion(t *testing.T) { + out, err := runApp( + &Flags{ShowVersion: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Version:\t development") + assert.Nil(t, err) +} + +func TestAnalyzePath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestSequentialScanning(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", SequentialScanning: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestFollowSymlinks(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", FollowSymlinks: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestShowAnnexedSize(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowAnnexedSize: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathProfiling(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", Profiling: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoring(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"/[abc]+"}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithIgnoringPatternError(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreDirPatterns: []string{"[[["}, + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithIgnoringFromNotExistingFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + IgnoreFromFile: "file", + NoHidden: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, out, "") + assert.NotNil(t, err) +} + +func TestAnalyzePathWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiNoColor(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoColor: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiShowMTimeAndItemCount(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowItemCount: true, ShowMTime: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoDelete: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestGuiDeleteInParallel(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInParallel: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithGuiBackgroundDeletion(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInBackground: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithDefaultSorting(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Sorting: Sorting{ + By: "name", + Order: "asc", + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithStyle(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + Style: Style{ + SelectedRow: ColorStyle{ + TextColor: "black", + BackgroundColor: "red", + }, + ProgressModal: ProgressModalOpts{ + CurrentItemNameMaxLen: 10, + }, + Footer: FooterColorStyle{ + TextColor: "black", + BackgroundColor: "red", + NumberColor: "white", + }, + Header: HeaderColorStyle{ + TextColor: "black", + BackgroundColor: "red", + Hidden: true, + }, + ResultRow: ResultRowColorStyle{ + NumberColor: "orange", + DirectoryColor: "blue", + }, + UseOldSizeBar: true, + }, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathNoUnicode(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + NoUnicode: true, + }, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestAnalyzePathWithExport(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", OutputFile: "output.json"}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Nil(t, err) +} + +func TestAnalyzePathWithChdir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{ + LogFile: "/dev/null", + ChangeCwd: true, + }, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestReadAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/test.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEmpty(t, out) + assert.Contains(t, out, "main.go") + assert.Nil(t, err) +} + +func TestReadWrongAnalysisFromFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/wrong.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "Array of maps not found") +} + +func TestWrongCombinationOfPrefixes(t *testing.T) { + out, err := runApp( + &Flags{NoPrefix: true, UseSIPrefix: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "cannot be used at once") +} + +func TestReadWrongAnalysisFromNotExistingFile(t *testing.T) { + out, err := runApp( + &Flags{LogFile: "/dev/null", InputFile: "xxx.json"}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestAnalyzePathWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := bytes.NewBufferString("") + + app := App{ + Flags: &Flags{LogFile: "/dev/null"}, + Args: []string{"xxx"}, + Istty: false, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: testdev.DevicesInfoGetterMock{}, + PathChecker: os.Stat, + } + err := app.Run() + + assert.Equal(t, "", strings.TrimSpace(buff.String())) + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestNoCross(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", NoCross: true}, + []string{"test_dir"}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "nested") + assert.Nil(t, err) +} + +func TestListDevices(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Contains(t, out, "Device") + assert.Nil(t, err) +} + +func TestListDevicesToFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + defer func() { + os.Remove("output.json") + }() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true, OutputFile: "output.json"}, + []string{}, + false, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, "", out) + assert.Contains(t, err.Error(), "not supported") +} + +func TestListDevicesWithGui(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", ShowDisks: true}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Nil(t, err) + assert.Empty(t, out) +} + +func TestMaxCores(t *testing.T) { + _, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Equal(t, 1, runtime.GOMAXPROCS(0)) + assert.Nil(t, err) +} + +func TestMaxCoresHighEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: runtime.NumCPU() + 1}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +func TestMaxCoresLowEdge(t *testing.T) { + if runtime.NumCPU() < 2 { + t.Skip("Skipping on a single core CPU") + } + out, err := runApp( + &Flags{LogFile: "/dev/null", MaxCores: -100}, + []string{}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) + assert.Empty(t, out) + assert.Nil(t, err) +} + +// nolint: unparam // Why: it's used in linux tests +func runApp(flags *Flags, args []string, istty bool, getter device.DevicesInfoGetter) (string, error) { + buff := bytes.NewBufferString("") + + app := App{ + Flags: flags, + Args: args, + Istty: istty, + Writer: buff, + TermApp: testapp.CreateMockedApp(false), + Getter: getter, + PathChecker: testdir.MockedPathChecker, + } + err := app.Run() + + return strings.TrimSpace(buff.String()), err +} diff --git a/gdu/cmd/gdu/main.go b/gdu/cmd/gdu/main.go new file mode 100644 index 0000000..760cc7d --- /dev/null +++ b/gdu/cmd/gdu/main.go @@ -0,0 +1,245 @@ +package gdu + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-isatty" + "github.com/rivo/tview" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "b612.me/apps/b612/gdu/cmd/gdu/app" + "b612.me/apps/b612/gdu/pkg/device" +) + +var ( + af *app.Flags + configErr error +) + +var Cmd = &cobra.Command{ + Use: "gdu [directory_to_scan]", + Short: "一款使用 Go 语言编写的快速磁盘空间分析工具。", + Long: `一款使用 Go 语言编写的快速磁盘空间分析工具。 + +Gdu 主要针对 SSD 固态硬盘设计,能够充分利用并行处理优势。虽然也支持机械硬盘(HDD)使用,但性能提升效果不如前者显著。 +`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + RunE: runE, +} + +func init() { + af = &app.Flags{} + flags := Cmd.Flags() + flags.StringVar(&af.CfgFile, "config-file", "", "从配置文件读取(默认为 $HOME/.gdu.yaml)") + flags.StringVarP(&af.LogFile, "log-file", "l", "/dev/null", "日志文件路径") + flags.StringVarP(&af.OutputFile, "output-file", "o", "", "将所有信息导出为JSON文件") + flags.StringVarP(&af.InputFile, "input-file", "f", "", "从JSON文件导入分析数据") + flags.IntVarP(&af.MaxCores, "max-cores", "m", runtime.NumCPU(), fmt.Sprintf("设置Gdu使用的最大核心数。当前可用%d个核心", runtime.NumCPU())) + flags.BoolVar(&af.SequentialScanning, "sequential", false, "使用顺序扫描(适用于机械硬盘HDD)") + flags.BoolVarP(&af.ShowVersion, "version", "v", false, "打印版本信息") + + flags.StringSliceVarP(&af.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, + "需要忽略的路径(逗号分隔),可为绝对路径或相对于当前目录的路径") + flags.StringSliceVarP(&af.IgnoreDirPatterns, "ignore-dirs-pattern", "I", []string{}, + "需要忽略的路径模式(逗号分隔)") + flags.StringVarP(&af.IgnoreFromFile, "ignore-from", "X", "", + "从文件中读取需要忽略的路径模式") + flags.BoolVarP(&af.NoHidden, "no-hidden", "H", false, "忽略隐藏目录(以点号开头的目录)") + flags.BoolVarP( + &af.FollowSymlinks, "follow-symlinks", "L", false, + "跟踪文件的符号链接,显示链接指向文件的大小(不跟踪目录符号链接)", + ) + flags.BoolVarP( + &af.ShowAnnexedSize, "show-annexed-size", "A", false, + "对git-annex文件显示表观大小(当文件未本地存储时,实际磁盘占用为零)", + ) + flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "不跨越文件系统边界") + flags.BoolVarP(&af.ConstGC, "const-gc", "g", false, "启用恒定级别的内存垃圾回收(由GOGC参数控制)") + flags.BoolVar(&af.Profiling, "enable-profiling", false, "启用性能分析数据收集(访问地址 http://localhost:6060/debug/pprof/)") + + flags.BoolVar(&af.UseStorage, "use-storage", false, "使用持久化键值存储分析数据(实验性功能)") + flags.StringVar(&af.StoragePath, "storage-path", "/tmp/badger", "持久化键值存储目录路径") + flags.BoolVarP(&af.ReadFromStorage, "read-from-storage", "r", false, "从持久化键值存储读取分析数据") + + flags.BoolVarP(&af.ShowDisks, "show-disks", "d", false, "显示所有已挂载磁盘") + flags.BoolVarP(&af.ShowApparentSize, "show-apparent-size", "a", false, "显示表观大小") + flags.BoolVarP(&af.ShowRelativeSize, "show-relative-size", "B", false, "显示相对大小") + flags.BoolVarP(&af.NoColor, "no-color", "c", false, "禁用彩色输出") + flags.BoolVarP(&af.ShowItemCount, "show-item-count", "C", false, "显示目录内项目数量") + flags.BoolVarP(&af.ShowMTime, "show-mtime", "M", false, "显示目录内项目最新修改时间") + flags.BoolVarP(&af.NonInteractive, "non-interactive", "n", false, "使用非交互模式") + flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "非交互模式下不显示进度条") + flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "禁用Unicode符号(用于大小进度条)") + flags.BoolVarP(&af.Summarize, "summarize", "s", false, "非交互模式下仅显示统计总数") + flags.IntVarP(&af.Top, "top", "t", 0, "非交互模式下仅显示前X个最大文件") + flags.BoolVar(&af.UseSIPrefix, "si", false, "使用十进制SI单位(kB/MB/GB)而非二进制单位(KiB/MiB/GiB)") + flags.BoolVar(&af.NoPrefix, "no-prefix", false, "非交互模式下显示原始数值(无单位前缀)") + flags.BoolVar(&af.NoMouse, "no-mouse", false, "禁用鼠标支持") + flags.BoolVar(&af.NoDelete, "no-delete", false, "禁止删除操作") + flags.BoolVar(&af.WriteConfig, "write-config", false, "将当前配置写入文件(默认为 $HOME/.gdu.yaml)") + + initConfig() + setDefaults() +} + +func initConfig() { + setConfigFilePath() + data, err := os.ReadFile(af.CfgFile) + if err != nil { + configErr = err + return // config file does not exist, return + } + + configErr = yaml.Unmarshal(data, &af) +} + +func setDefaults() { + if af.Style.Footer.BackgroundColor == "" { + af.Style.Footer.BackgroundColor = "#2479D0" + } + if af.Style.Footer.TextColor == "" { + af.Style.Footer.TextColor = "#000000" + } + if af.Style.Footer.NumberColor == "" { + af.Style.Footer.NumberColor = "#FFFFFF" + } + if af.Style.Header.BackgroundColor == "" { + af.Style.Header.BackgroundColor = "#2479D0" + } + if af.Style.Header.TextColor == "" { + af.Style.Header.TextColor = "#000000" + } + if af.Style.ResultRow.NumberColor == "" { + af.Style.ResultRow.NumberColor = "#e67100" + } + if af.Style.ResultRow.DirectoryColor == "" { + af.Style.ResultRow.DirectoryColor = "#3498db" + } +} + +func setConfigFilePath() { + command := strings.Join(os.Args, " ") + if strings.Contains(command, "--config-file") { + re := regexp.MustCompile("--config-file[= ]([^ ]+)") + parts := re.FindStringSubmatch(command) + + if len(parts) > 1 { + af.CfgFile = parts[1] + return + } + } + setDefaultConfigFilePath() +} + +func setDefaultConfigFilePath() { + home, err := os.UserHomeDir() + if err != nil { + configErr = err + return + } + + path := filepath.Join(home, ".config", "gdu", "gdu.yaml") + if _, err := os.Stat(path); err == nil { + af.CfgFile = path + return + } + + af.CfgFile = filepath.Join(home, ".gdu.yaml") +} + +func runE(command *cobra.Command, args []string) error { + var ( + termApp *tview.Application + screen tcell.Screen + err error + ) + + if af.WriteConfig { + data, err := yaml.Marshal(af) + if err != nil { + return fmt.Errorf("Error marshaling config file: %w", err) + } + if af.CfgFile == "" { + setDefaultConfigFilePath() + } + err = os.WriteFile(af.CfgFile, data, 0o600) + if err != nil { + return fmt.Errorf("Error writing config file %s: %w", af.CfgFile, err) + } + } + + if runtime.GOOS == "windows" && af.LogFile == "/dev/null" { + af.LogFile = "nul" + } + + var f *os.File + if af.LogFile == "-" { + f = os.Stdout + } else { + f, err = os.OpenFile(af.LogFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("opening log file: %w", err) + } + defer func() { + cerr := f.Close() + if cerr != nil { + panic(cerr) + } + }() + } + log.SetOutput(f) + + if configErr != nil { + log.Printf("Error reading config file: %s", configErr.Error()) + } + + istty := isatty.IsTerminal(os.Stdout.Fd()) + + // we are not able to analyze disk usage on Windows and Plan9 + if runtime.GOOS == "windows" || runtime.GOOS == "plan9" { + af.ShowApparentSize = true + } + + if !af.ShowVersion && !af.NonInteractive && istty && af.OutputFile == "" { + screen, err = tcell.NewScreen() + if err != nil { + return fmt.Errorf("Error creating screen: %w", err) + } + defer screen.Clear() + defer screen.Fini() + + termApp = tview.NewApplication() + termApp.SetScreen(screen) + + if !af.NoMouse { + termApp.EnableMouse(true) + } + } + + a := app.App{ + Flags: af, + Args: args, + Istty: istty, + Writer: os.Stdout, + TermApp: termApp, + Screen: screen, + Getter: device.Getter, + PathChecker: os.Stat, + } + return a.Run() +} + +func main() { + if err := Cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/gdu/codecov.yml b/gdu/codecov.yml new file mode 100644 index 0000000..c9ef2c2 --- /dev/null +++ b/gdu/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + target: auto + threshold: 2% + informational: true + patch: + default: + informational: true \ No newline at end of file diff --git a/gdu/configuration.md b/gdu/configuration.md new file mode 100644 index 0000000..ac96992 --- /dev/null +++ b/gdu/configuration.md @@ -0,0 +1,195 @@ +# YAML file configuration options + +Gdu provides an additional set of configuration options to the usual command line options. + +You can get the full list of all possible options by running: + +``` +gdu --write-config +``` + +This will create file `$HOME/.gdu.yaml` with all the options set to default values. + +Let's go through them one by one: + +#### `log-file` + +Path to a logfile (default "/dev/null") + +#### `input-file` + +Import analysis from JSON file + +#### `output-file` + +Export all info into file as JSON + +#### `ignore-dirs` + +Paths to ignore (separated by comma). Can be absolute (like `/proc`) or relative to the current working directory (like `node_modules`). Default values are [/proc,/dev,/sys,/run]. + +#### `ignore-dir-patterns` + +Path patterns to ignore (separated by comma). Patterns can be absolute or relative to the current working directory. + +#### `ignore-from-file` + +Read path patterns to ignore from file. Patterns can be absolute or relative to the current working directory. + +#### `max-cores` + +Set max cores that Gdu will use. + +#### `sequential-scanning` + +Use sequential scanning (intended for rotating HDDs) + +#### `show-apparent-size` + +Show apparent size + +#### `show-relative-size` + +Show relative size + +#### `show-item-count` + +Show number of items in directory + +#### `no-color` + +Do not use colorized output + +#### `no-mouse` + +Do not use mouse + +#### `non-interactive` + +Do not run in interactive mode + +#### `no-progress` + +Do not show progress in non-interactive mode + +#### `no-cross` + +Do not cross filesystem boundaries + +#### `no-hidden` + +Ignore hidden directories (beginning with dot) + +#### `no-delete` + +Do not allow deletions + +#### `follow-symlinks` + +Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) + +#### `profiling` + +Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ +#### `const-gc` + +Enable memory garbage collection during analysis with constant level set by GOGC + +#### `use-storage` + +Use persistent key-value storage for analysis data (experimental) + +#### `storage-path` + +Path to persistent key-value storage directory (default is /tmp/badger) + +#### `read-from-storage` + +Read analysis data from persistent key-value storage + +#### `summarize` + +Show only a total in non-interactive mode + +#### `use-si-prefix` + +Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +#### `no-prefix` + +Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +#### `change-cwd` + +Set CWD variable when browsing directories + +#### `delete-in-background` + +Delete items in the background, not blocking the UI from work + +#### `delete-in-parallel` + +Delete items in parallel, which might increase the speed of deletion + +#### `style.selected-row.text-color` + +Color of text for the selected row + +#### `style.selected-row.background-color` + +Background color for the selected row + +#### `style.progress-modal.current-item-path-max-len` + +Maximum length of file path for the current item in progress bar. +When the length is reached, the path is shortened with "/.../". + +#### `style.use-old-size-bar` + +Show size bar without Unicode symbols. + +#### `style.footer.text-color` + +Color of text for footer bar + +#### `style.footer.background-color` + +Background color for footer bar + +#### `style.footer.number-color` + +Color of numbers displayed in the footer + +#### `style.header.text-color` + +Color of text for header bar + +#### `style.header.background-color` + +Background color for header bar + +#### `style.header.hidden` + +Hide the header bar + +#### `style.result-row.number-color` + +Color of numbers in result rows + +#### `style.result-row.directory-color` + +Color of directory names in result rows + +#### `sorting.by` + +Sort items. Possible values: +* name - name of the item +* size - usage or apparent size +* itemCount - number of items in the folder tree +* mtime - modification time + +#### `sorting.order` + +Set sorting order. Possible values: +* asc - ascending order +* desc - descending order \ No newline at end of file diff --git a/gdu/default.pgo b/gdu/default.pgo new file mode 100644 index 0000000000000000000000000000000000000000..dd55ccfd4bc03430606b97de5f27d4c944907a4f GIT binary patch literal 62946 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
YPtV{nXNXt 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

=qRe{uaE^DJa$9iXcRQjkI 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

^TWW$$XToj+V6u0tX46llgP9TB?mf&KWDg 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 literal 0 HcmV?d00001 diff --git a/gdu/docs/run-books.md b/gdu/docs/run-books.md new file mode 100644 index 0000000..61901c7 --- /dev/null +++ b/gdu/docs/run-books.md @@ -0,0 +1,13 @@ +# Release process + +1. update usage in README.md and gdu.1.md +1. `make show-man` +1. `make man` +1. commit the changes +1. tag new version with `-sa` +1. `make` +1. `git push --tags` +1. `git push` +1. `make release` +1. update `gdu.spec` +1. Release snapcraft, AUR, ... diff --git a/gdu/gdu.1 b/gdu/gdu.1 new file mode 100644 index 0000000..03ac528 --- /dev/null +++ b/gdu/gdu.1 @@ -0,0 +1,123 @@ +.\" Automatically generated by Pandoc 3.1.11.1 +.\" +.TH "gdu" "1" "2024\-12\-30" "" "" +.SH NAME +gdu \- Pretty fast disk usage analyzer written in Go +.SH SYNOPSIS +\f[B]gdu [flags] [directory_to_scan]\f[R] +.SH DESCRIPTION +Pretty fast disk usage analyzer written in Go. +.PP +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. +However HDDs work as well, but the performance gain is not so huge. +.SH OPTIONS +\f[B]\-h\f[R], \f[B]\-\-help\f[R][=false] help for gdu +.PP +\f[B]\-i\f[R], \f[B]\-\-ignore\-dirs\f[R]=[/proc,/dev,/sys,/run] +Absolute paths to ignore (separated by comma) +.PP +\f[B]\-I\f[R], \f[B]\-\-ignore\-dirs\-pattern\f[R] Absolute path +patterns to ignore (separated by comma) +.PP +\f[B]\-X\f[R], \f[B]\-\-ignore\-from\f[R] Read absolute path patterns to +ignore from file +.PP +\f[B]\-l\f[R], \f[B]\-\-log\-file\f[R]=\[dq]/dev/null\[dq] Path to a +logfile +.PP +\f[B]\-m\f[R], \f[B]\-\-max\-cores\f[R] Set max cores that Gdu will use. +.PP +\f[B]\-c\f[R], \f[B]\-\-no\-color\f[R][=false] Do not use colorized +output +.PP +\f[B]\-x\f[R], \f[B]\-\-no\-cross\f[R][=false] Do not cross filesystem +boundaries +.PP +\f[B]\-H\f[R], \f[B]\-\-no\-hidden\f[R][=false] Ignore hidden +directories (beginning with dot) +.PP +\f[B]\-L\f[R], \f[B]\-\-follow\-symlinks\f[R][=false] Follow symlinks +for files, i.e.\ show the size of the file to which symlink points to +(symlinks to directories are not followed) +.PP +\f[B]\-n\f[R], \f[B]\-\-non\-interactive\f[R][=false] Do not run in +interactive mode +.PP +\f[B]\-p\f[R], \f[B]\-\-no\-progress\f[R][=false] Do not show progress +in non\-interactive mode +.PP +\f[B]\-u\f[R], \f[B]\-\-no\-unicode\f[R][=false] Do not use Unicode +symbols (for size bar) +.PP +\f[B]\-s\f[R], \f[B]\-\-summarize\f[R][=false] Show only a total in +non\-interactive mode +.PP +\f[B]\-t\f[R], \f[B]\-\-top\f[R][=0] Show only top X largest files in +non\-interactive mode +.PP +\f[B]\-d\f[R], \f[B]\-\-show\-disks\f[R][=false] Show all mounted disks +.PP +\f[B]\-a\f[R], \f[B]\-\-show\-apparent\-size\f[R][=false] Show apparent +size +.PP +\f[B]\-C\f[R], \f[B]\-\-show\-item\-count\f[R][=false] Show number of +items in directory +.PP +\f[B]\-M\f[R], \f[B]\-\-show\-mtime\f[R][=false] Show latest mtime of +items in directory +.PP +\f[B]\-\-si\f[R][=false] Show sizes with decimal SI prefixes (kB, MB, +GB) instead of binary prefixes (KiB, MiB, GiB) +.PP +\f[B]\-\-no\-prefix\f[R][=false] Show sizes as raw numbers without any +prefixes (SI or binary) in non\-interactive mode +.PP +\f[B]\-\-no\-mouse\f[R][=false] Do not use mouse +.PP +\f[B]\-\-no\-delete\f[R][=false] Do not allow deletions +.PP +\f[B]\-f\f[R], \f[B]\-\-input\-file\f[R] Import analysis from JSON file. +If the file is \[dq]\-\[dq], read from standard input. +.PP +\f[B]\-o\f[R], \f[B]\-\-output\-file\f[R] Export all info into file as +JSON. +If the file is \[dq]\-\[dq], write to standard output. +.PP +\f[B]\-\-config\-file\f[R]=\[dq]$HOME/.gdu.yaml\[dq] Read config from +file +.PP +\f[B]\-\-write\-config\f[R][=false] Write current configuration to file +(default is $HOME/.gdu.yaml) +.PP +\f[B]\-g\f[R], \f[B]\-\-const\-gc\f[R][=false] Enable memory garbage +collection during analysis with constant level set by GOGC +.PP +\f[B]\-\-enable\-profiling\f[R][=false] Enable collection of profiling +data and provide it on http://localhost:6060/debug/pprof/ +.PP +\f[B]\-\-use\-storage\f[R][=false] Use persistent key\-value storage for +analysis data (experimental) +.PP +\f[B]\-r\f[R], \f[B]\-\-read\-from\-storage\f[R][=false] Read analysis +data from persistent key\-value storage +.PP +\f[B]\-v\f[R], \f[B]\-\-version\f[R][=false] Print version +.SH FILE FLAGS +Files and directories may be prefixed by a one\-character flag with +following meaning: +.TP +\f[B]!\f[R] +An error occurred while reading this directory. +.TP +\f[B].\f[R] +An error occurred while reading a subdirectory, size may be not correct. +.TP +\f[B]\[at]\f[R] +File is symlink or socket. +.TP +\f[B]H\f[R] +Same file was already counted (hard link). +.TP +\f[B]e\f[R] +Directory is empty. diff --git a/gdu/gdu.1.md b/gdu/gdu.1.md new file mode 100644 index 0000000..cd70d6a --- /dev/null +++ b/gdu/gdu.1.md @@ -0,0 +1,120 @@ +--- +date: {{date}} +section: 1 +title: gdu +--- + +# NAME + +gdu - Pretty fast disk usage analyzer written in Go + +# SYNOPSIS + +**gdu \[flags\] \[directory_to_scan\]** + +# DESCRIPTION + +Pretty fast disk usage analyzer written in Go. + +Gdu is intended primarily for SSD disks where it can fully utilize +parallel processing. However HDDs work as well, but the performance gain +is not so huge. + +# OPTIONS + +**-h**, **\--help**\[=false\] help for gdu + +**-i**, **\--ignore-dirs**=\[/proc,/dev,/sys,/run\] + Paths to ignore (separated by comma). + Supports both absolute and relative paths. + +**-I**, **\--ignore-dirs-pattern** + Path patterns to ignore (separated by comma). + Supports both absolute and relative path patterns. + +**-X**, **\--ignore-from** + Read path patterns to ignore from file. + Supports both absolute and relative path patterns. + +**-l**, **\--log-file**=\"/dev/null\" Path to a logfile + +**-m**, **\--max-cores** Set max cores that Gdu will use. + +**-c**, **\--no-color**\[=false\] Do not use colorized output + +**-x**, **\--no-cross**\[=false\] Do not cross filesystem boundaries + +**-H**, **\--no-hidden**\[=false\] Ignore hidden directories (beginning with dot) + +**-L**, **\--follow-symlinks**\[=false\] Follow symlinks for files, i.e. show the +size of the file to which symlink points to (symlinks to directories are not followed) + +**-n**, **\--non-interactive**\[=false\] Do not run in interactive mode + +**-p**, **\--no-progress**\[=false\] Do not show progress in +non-interactive mode + +**-u**, **\--no-unicode**\[=false\] Do not use Unicode symbols (for size bar) + +**-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode + +**-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode + +**-d**, **\--show-disks**\[=false\] Show all mounted disks + +**-a**, **\--show-apparent-size**\[=false\] Show apparent size + +**-C**, **\--show-item-count**\[=false\] Show number of items in directory + +**-M**, **\--show-mtime**\[=false\] Show latest mtime of items in directory + +**\--si**\[=false\] Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) + +**\--no-prefix**\[=false\] Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode + +**\--no-mouse**\[=false\] Do not use mouse + +**\--no-delete**\[=false\] Do not allow deletions + +**-f**, **\--input-file** Import analysis from JSON file. If the file is \"-\", read from standard input. + +**-o**, **\--output-file** Export all info into file as JSON. If the file is \"-\", write to standard output. + +**\--config-file**=\"$HOME/.gdu.yaml\" Read config from file + +**\--write-config**\[=false\] Write current configuration to file (default is $HOME/.gdu.yaml) + +**-g**, **\--const-gc**\[=false\] Enable memory garbage collection during analysis with constant level set by GOGC + +**\--enable-profiling**\[=false\] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ + +**\--use-storage**\[=false\] Use persistent key-value storage for analysis data (experimental) + +**-r**, **\--read-from-storage**\[=false\] Read analysis data from persistent key-value storage + +**-v**, **\--version**\[=false\] Print version + +# FILE FLAGS + +Files and directories may be prefixed by a one-character +flag with following meaning: + +**!** + +: An error occurred while reading this directory. + +**.** + +: An error occurred while reading a subdirectory, size may be not correct. + +**\@** + +: File is symlink or socket. + +**H** + +: Same file was already counted (hard link). + +**e** + +: Directory is empty. diff --git a/gdu/gdu.png b/gdu/gdu.png new file mode 100644 index 0000000000000000000000000000000000000000..e2716d9960963915381458a744f4ebdcf433d797 GIT binary patch literal 131657 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