From 13a73ac16da81c5057ba78d32cf6cd08ffb606d3 Mon Sep 17 00:00:00 2001 From: dzp Date: Sat, 24 Aug 2019 14:17:19 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20v0.6.0=EF=BC=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E5=8F=AF=E7=94=A8=E7=9A=84=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变化如下: * 重构代码结构,拆分 Lua/MUD/UI 三部分代码,调整目录结构以支持 go get * 开始支持通过配置文件和命令行选项的方式来设置程序参数 * Lua 可选启动 * 智能渲染表格线,也可通过配置文件指定,使得不同环境下的表格始终能够对齐 * 基本能够正确渲染颜色,但仍有不足 * 实现了一个 IAC 处理的基本框架 已知问题: * 由于 tview.TextView 和 tview.ANSIWriter 在颜色处理方面的 BUG 因此本项目直接集成了这两个模块的修正版 * 由于 tview.TextView 的性能问题,在屏幕内容超过一万行时,有明显的卡顿 以上修改应当可以: Close #1 #2 #3 #16 #17 #18 --- Makefile | 37 +- lua | 2 +- lua-api/lua.go | 337 +++++++++++++ main.go | 601 +++++------------------ mud/iac.go | 145 ++++++ mud/mud.go | 146 ++++++ mud/scan.go | 126 +++++ ui/ansi.go | 321 +++++++++++++ ui/textview.go | 1246 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/ui.go | 129 +++++ 10 files changed, 2602 insertions(+), 488 deletions(-) create mode 100644 lua-api/lua.go create mode 100644 mud/iac.go create mode 100644 mud/mud.go create mode 100644 mud/scan.go create mode 100644 ui/ansi.go create mode 100644 ui/textview.go create mode 100644 ui/ui.go diff --git a/Makefile b/Makefile index cec851a..4b8b3ff 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,35 @@ default: all -ALL=go-mud-linux-amd64 go-mud-linux-arm64 go-mud-macOS-amd64 go-mud-windows-amd64.exe -all: $(ALL) +ALL=go-mud-macOS-amd64 \ + go-mud-linux-amd64 \ + go-mud-linux-arm8 \ + go-mud-linux-arm7 \ + go-mud-windows-x86.exe \ + go-mud-windows-amd64.exe + +all: $(patsubst %,dist/%,$(ALL)) + +GOOPTS=-trimpath SRCS=main.go -go-mud-linux-amd64: $(SRC) - GOOS=linux GOARCH=amd64 go build -o $@ main.go +dist/go-mud-linux-amd64: $(SRC) + GOOS=linux GOARCH=amd64 go build $(GOOPTS) -o $@ main.go + +dist/go-mud-linux-arm7: $(SRC) + GOOS=linux GOARM=7 GOARCH=arm go build $(GOOPTS) -o $@ main.go -go-mud-linux-arm64: $(SRC) - GOOS=linux GOARCH=arm64 go build -o $@ main.go +dist/go-mud-linux-arm8: $(SRC) + GOOS=linux GOARCH=arm64 go build $(GOOPTS) -o $@ main.go -go-mud-macOS-amd64: $(SRC) - GOOS=darwin GOARCH=amd64 go build -o $@ main.go +dist/go-mud-macOS-amd64: $(SRC) + GOOS=darwin GOARCH=amd64 go build $(GOOPTS) -o $@ main.go -go-mud-windows-amd64.exe: $(SRC) - GOOS=windows GOARCH=amd64 go build -o $@ main.go +dist/go-mud-windows-amd64.exe: $(SRC) + GOOS=windows GOARCH=amd64 go build $(GOOPTS) -o $@ main.go -zip: all - zip go-mud.zip go-mud-{linux,macOS,windows}-* *.lua +dist/go-mud-windows-x86.exe: $(SRC) + GOOS=windows GOARCH=386 go build $(GOOPTS) -o $@ main.go clean: - rm -f $(ALL) + rm -rf dist/ diff --git a/lua b/lua index af4ede0..04aed5a 160000 --- a/lua +++ b/lua @@ -1 +1 @@ -Subproject commit af4ede026af931124d3be121ca259c5ae283bb25 +Subproject commit 04aed5aa853542bf734be63863cf1178b49597fa diff --git a/lua-api/lua.go b/lua-api/lua.go new file mode 100644 index 0000000..78e8306 --- /dev/null +++ b/lua-api/lua.go @@ -0,0 +1,337 @@ +package lua + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "path" + "regexp" + "sync" + "time" + + "github.com/flw-cn/printer" + lua "github.com/yuin/gopher-lua" +) + +type LuaRobotConfig struct { + Enable bool `flag:"|true|是否加载 Lua 机器人"` + Path string `flag:"p|lua|Lua 插件路径 {path}"` +} + +type LuaRobot struct { + printer.SimplePrinter + + config LuaRobotConfig + + screen io.Writer + mud io.Writer + + lstate *lua.LState + onReceive lua.P + onSend lua.P + + timer sync.Map +} + +func NewLuaRobot(config LuaRobotConfig) *LuaRobot { + return &LuaRobot{ + config: config, + screen: os.Stdout, + } +} + +func (l *LuaRobot) Init() { + if !l.config.Enable { + return + } + + if err := l.Reload(); err != nil { + l.Println("Lua 初始化失败。") + return + } +} + +func (l *LuaRobot) SetScreen(w io.Writer) { + l.screen = w + l.SetOutput(w) +} + +func (l *LuaRobot) SetMud(w io.Writer) { + l.mud = w +} + +func (l *LuaRobot) Reload() error { + mainFile := path.Join(l.config.Path, "main.lua") + if _, err := os.Open(mainFile); err != nil { + l.Printf("Load error: %s\n", err) + l.Println("无法打开 lua 主程序,请检查你的配置。") + return err + } + + if l.lstate != nil { + l.lstate.Close() + l.Println("Lua 环境已关闭。") + } + + l.Println("初始化 Lua 环境...") + + luaPath := path.Join(l.config.Path, "?.lua") + os.Setenv(lua.LuaPath, luaPath+";;") + + l.lstate = lua.NewState() + + l.lstate.SetGlobal("RegEx", l.lstate.NewFunction(l.RegEx)) + l.lstate.SetGlobal("Echo", l.lstate.NewFunction(l.Echo)) + l.lstate.SetGlobal("Print", l.lstate.NewFunction(l.Print)) + l.lstate.SetGlobal("Run", l.lstate.NewFunction(l.Run)) + l.lstate.SetGlobal("Send", l.lstate.NewFunction(l.Send)) + l.lstate.SetGlobal("AddTimer", l.lstate.NewFunction(l.AddTimer)) + l.lstate.SetGlobal("AddMSTimer", l.lstate.NewFunction(l.AddTimer)) + l.lstate.SetGlobal("DelTimer", l.lstate.NewFunction(l.DelTimer)) + l.lstate.SetGlobal("DelMSTimer", l.lstate.NewFunction(l.DelTimer)) + + l.lstate.Panic = func(*lua.LState) { + l.Panic(errors.New("LUA Panic")) + return + } + + if err := l.lstate.DoFile(mainFile); err != nil { + l.lstate.Close() + l.lstate = nil + return err + } + + l.onReceive = lua.P{ + Fn: l.lstate.GetGlobal("OnReceive"), + NRet: 0, + Protect: true, + } + + l.onSend = lua.P{ + Fn: l.lstate.GetGlobal("OnSend"), + NRet: 1, + Protect: true, + } + + l.Println("Lua 环境初始化完成。") + + return nil +} + +func (l *LuaRobot) OnReceive(raw, input string) { + if l.lstate == nil { + return + } + + L := l.lstate + err := L.CallByParam(l.onReceive, lua.LString(raw), lua.LString(input)) + if err != nil { + l.Panic(err) + } +} + +func (l *LuaRobot) OnSend(cmd string) bool { + if l.lstate == nil { + return true + } + + L := l.lstate + err := L.CallByParam(l.onSend, lua.LString(cmd)) + if err != nil { + l.Panic(err) + } + + ret := L.Get(-1) + L.Pop(1) + + if ret == lua.LFalse { + return false + } else { + return true + } +} + +func (l *LuaRobot) Panic(err error) { + l.Printf("Lua error: [%v]\n", err) +} + +func (l *LuaRobot) RegEx(L *lua.LState) int { + text := L.ToString(1) + regex := L.ToString(2) + + re, err := regexp.Compile(regex) + if err != nil { + L.Push(lua.LString("0")) + return 1 + } + + matchs := re.FindAllStringSubmatch(text, -1) + if matchs == nil { + L.Push(lua.LString("0")) + return 1 + } + + subs := matchs[0] + length := len(subs) + if length == 1 { + L.Push(lua.LString("-1")) + return 1 + } + + L.Push(lua.LString(fmt.Sprintf("%d", length-1))) + + for i := 1; i < length; i++ { + L.Push(lua.LString(subs[i])) + } + + return length +} + +func (l *LuaRobot) Print(L *lua.LState) int { + text := L.ToString(1) + l.Println(text) + return 0 +} + +func (l *LuaRobot) Echo(L *lua.LState) int { + text := L.ToString(1) + + re := regexp.MustCompile(`\$(BLK|NOR|RED|HIR|GRN|HIG|YEL|HIY|BLU|HIB|MAG|HIM|CYN|HIC|WHT|HIW|BNK|REV|U)\$`) + text = re.ReplaceAllStringFunc(text, func(code string) string { + switch code { + case "$BLK$": + return "[black::]" + case "$NOR$": + return "[-:-:-]" + case "$RED$": + return "[red::]" + case "$HIR$": + return "[red::b]" + case "$GRN$": + return "[green::]" + case "$HIG$": + return "[green::b]" + case "$YEL$": + return "[yellow::]" + case "$HIY$": + return "[yellow::b]" + case "$BLU$": + return "[blue::]" + case "$HIB$": + return "[blue::b]" + case "$MAG$": + return "[darkmagenta::]" + case "$HIM$": + return "[#ff00ff::]" + case "$CYN$": + return "[dardcyan::]" + case "$HIC$": + return "[#00ffff::]" + case "$WHT$": + return "[white::]" + case "$HIW$": + return "[#ffffff::]" + case "$BNK$": + return "[::l]" + case "$REV$": + return "[::7]" + case "$U$": + return "[::u]" + default: + l.Printf("Find Unknown Color Code: %s\n", code) + } + return "" + }) + + l.Println(text) + + // TODO: 这里暂时不支持 ANSI 到 PLAIN 的转换 + l.OnReceive(text, text) + + return 0 +} + +func (l *LuaRobot) Run(L *lua.LState) int { + text := L.ToString(1) + fmt.Fprintln(l.screen, text) + return 0 +} + +func (l *LuaRobot) Send(L *lua.LState) int { + text := L.ToString(1) + fmt.Fprintln(l.mud, text) + return 0 +} + +func (l *LuaRobot) AddTimer(L *lua.LState) int { + id := L.ToString(1) + code := L.ToString(2) + delay := L.ToInt(3) + times := L.ToInt(4) + + go func() { + count := 0 + quit := make(chan bool, 1) + timer := Timer{ + id: id, + code: code, + delay: delay, + maxTimes: times, + times: 0, + quit: quit, + } + v, exists := l.timer.LoadOrStore(id, timer) + if exists { + v.(Timer).quit <- true + l.timer.Store(id, timer) + } + + for { + select { + case <-quit: + return + case <-time.After(time.Millisecond * time.Duration(delay)): + timer.Emit(l) + count++ + if times > 0 && times >= count { + return + } + } + } + }() + + return 0 +} + +func (l *LuaRobot) DelTimer(L *lua.LState) int { + id := L.ToString(1) + v, ok := l.timer.Load(id) + if ok { + v.(Timer).quit <- true + } + l.timer.Delete(id) + return 0 +} + +func (l *LuaRobot) Logf(format string, a ...interface{}) { + log.Printf(format, a...) + return +} + +type Timer struct { + id string + code string + delay int + maxTimes int + times int + quit chan<- bool +} + +func (t *Timer) Emit(l *LuaRobot) { + err := l.lstate.DoString(`call_timer_actions("` + t.id + `")`) + if err != nil { + l.Printf("Lua Error: %s\n", err) + } +} diff --git a/main.go b/main.go index 0e3ad52..1af7f80 100644 --- a/main.go +++ b/main.go @@ -1,500 +1,153 @@ package main import ( - "bufio" - "errors" - "fmt" - "io" - "log" - "net" - "os" "regexp" + "runtime" "strings" - "sync" "time" - lua "github.com/yuin/gopher-lua" - - "github.com/axgle/mahonia" - "github.com/gdamore/tcell" - "github.com/rivo/tview" + lua "github.com/dzpao/go-mud/lua-api" + "github.com/dzpao/go-mud/mud" + "github.com/dzpao/go-mud/ui" + smartConfig "github.com/flw-cn/go-smartConfig" "golang.org/x/text/width" ) -var ( - decoder mahonia.Decoder - encoder mahonia.Encoder - - ansiRe *regexp.Regexp - app *tview.Application - luaRobot *LuaRobot - mainWindow *tview.TextView -) - -func init() { - decoder = mahonia.NewDecoder("GB18030") - encoder = mahonia.NewEncoder("GB18030") - - ansiRe = regexp.MustCompile("\x1b" + `\[\d*(?:;\d*(?:;\d*)?)?(?:A|D|K|m)`) -} - -type LuaRobot struct { - lstate *lua.LState - timer sync.Map - mud io.Writer - - onReceive lua.P - onSend lua.P -} - -type Timer struct { - id string - code string - delay int - maxTimes int - times int - quit chan<- bool -} - -func (t *Timer) Emit(l *LuaRobot) { - err := l.lstate.DoString(`call_timer_actions("` + t.id + `")`) - if err != nil { - l.Logf("Lua Error: %s", err) - } -} - -func initLua() { - luaRobot = &LuaRobot{} - if err := luaRobot.Reload(); err != nil { - panic(err) - } -} - -func (l *LuaRobot) Reload() error { - if luaRobot.lstate != nil { - luaRobot.lstate.Close() - l.Logf("Lua 环境已关闭。") - } - - l.Logf("初始化 Lua 环境...") - - os.Setenv(lua.LuaPath, "lua/?.lua;;") - - luaRobot.lstate = lua.NewState() - - luaRobot.lstate.SetGlobal("RegEx", luaRobot.lstate.NewFunction(luaRobot.RegEx)) - luaRobot.lstate.SetGlobal("Print", luaRobot.lstate.NewFunction(luaRobot.Print)) - luaRobot.lstate.SetGlobal("Show", luaRobot.lstate.NewFunction(luaRobot.Show)) - luaRobot.lstate.SetGlobal("Run", luaRobot.lstate.NewFunction(luaRobot.Run)) - luaRobot.lstate.SetGlobal("Echo", luaRobot.lstate.NewFunction(luaRobot.Echo)) - luaRobot.lstate.SetGlobal("Send", luaRobot.lstate.NewFunction(luaRobot.Send)) - luaRobot.lstate.SetGlobal("AddTimer", luaRobot.lstate.NewFunction(luaRobot.AddTimer)) - luaRobot.lstate.SetGlobal("AddMSTimer", luaRobot.lstate.NewFunction(luaRobot.AddTimer)) - luaRobot.lstate.SetGlobal("DelTimer", luaRobot.lstate.NewFunction(luaRobot.DelTimer)) - luaRobot.lstate.SetGlobal("DelMSTimer", luaRobot.lstate.NewFunction(luaRobot.DelTimer)) - - luaRobot.lstate.Panic = func(*lua.LState) { - luaRobot.Panic(errors.New("LUA Panic")) - return - } - - if err := luaRobot.lstate.DoFile("lua/main.lua"); err != nil { - return err - } - - luaRobot.onReceive = lua.P{ - Fn: luaRobot.lstate.GetGlobal("OnReceive"), - NRet: 0, - Protect: true, - } - - luaRobot.onSend = lua.P{ - Fn: luaRobot.lstate.GetGlobal("OnSend"), - NRet: 1, - Protect: true, - } - - l.Logf("Lua 环境初始化完成。") - - return nil -} - -func (l *LuaRobot) OnReceive(raw, input string) { - L := l.lstate - err := L.CallByParam(l.onReceive, lua.LString(raw), lua.LString(input)) - if err != nil { - luaRobot.Panic(err) - } -} - -func (l *LuaRobot) OnSend(cmd string) bool { - L := l.lstate - err := L.CallByParam(l.onSend, lua.LString(cmd)) - if err != nil { - l.Panic(err) - } - - ret := L.Get(-1) - L.Pop(1) - - if ret == lua.LFalse { - return false - } else { - return true - } -} - -func (l *LuaRobot) Panic(err error) { - l.Logf("Lua error: [%s]", err) -} - -func (l *LuaRobot) RegEx(L *lua.LState) int { - text := L.ToString(1) - regex := L.ToString(2) - - re, err := regexp.Compile(regex) - if err != nil { - L.Push(lua.LString("0")) - return 1 - } - - matchs := re.FindAllStringSubmatch(text, -1) - if matchs == nil { - L.Push(lua.LString("0")) - return 1 - } - - subs := matchs[0] - length := len(subs) - if length == 1 { - L.Push(lua.LString("-1")) - return 1 - } - - L.Push(lua.LString(fmt.Sprintf("%d", length-1))) - - for i := 1; i < length; i++ { - L.Push(lua.LString(subs[i])) - } - - return length -} - -func (l *LuaRobot) Print(L *lua.LState) int { - text := L.ToString(1) - l.Logf("Lua.Print: %s", text) - return 0 -} - -func (l *LuaRobot) Echo(L *lua.LState) int { - text := L.ToString(1) - l.Logf("Lua.Echo: %s", text) - return 0 -} - -func (l *LuaRobot) Show(L *lua.LState) int { - text := L.ToString(1) - - re := regexp.MustCompile(`\$(BLK|NOR|RED|HIR|GRN|HIG|YEL|HIY|BLU|HIB|MAG|HIM|CYN|HIC|WHT|HIW|BNK|REV|U)\$`) - text = re.ReplaceAllStringFunc(text, func(code string) string { - switch code { - case "$BLK$": - return "[black::]" - case "$NOR$": - return "[-:-:-]" - case "$RED$": - return "[red::]" - case "$HIR$": - return "[red::b]" - case "$GRN$": - return "[green::]" - case "$HIG$": - return "[green::b]" - case "$YEL$": - return "[yellow::]" - case "$HIY$": - return "[yellow::b]" - case "$BLU$": - return "[blue::]" - case "$HIB$": - return "[blue::b]" - case "$MAG$": - return "[darkmagenta::]" - case "$HIM$": - return "[#ff00ff::]" - case "$CYN$": - return "[dardcyan::]" - case "$HIC$": - return "[#00ffff::]" - case "$WHT$": - return "[white::]" - case "$HIW$": - return "[#ffffff::]" - case "$BNK$": - return "[::l]" - case "$REV$": - return "[::7]" - case "$U$": - return "[::u]" - default: - l.Logf("Find Unknown Color Code: %s", code) - } - return "" - }) - - l.Logf("%s", text) - - return 0 -} - -func (l *LuaRobot) Run(L *lua.LState) int { - text := L.ToString(1) - l.Logf("Lua.Run: %s", text) - return 0 +type ClientConfig struct { + UI ui.UIConfig + Mud mud.MudConfig + Lua lua.LuaRobotConfig } -func (l *LuaRobot) Send(L *lua.LState) int { - text := L.ToString(1) - text = UTF8_TO_GBK(text) - fmt.Fprintln(l.mud, text) - return 0 +type Client struct { + config ClientConfig + ui *ui.UI + lua *lua.LuaRobot + mud *mud.MudServer + quit chan bool } -func (l *LuaRobot) AddTimer(L *lua.LState) int { - id := L.ToString(1) - code := L.ToString(2) - delay := L.ToInt(3) - times := L.ToInt(4) - - go func() { - count := 0 - quit := make(chan bool, 1) - timer := Timer{ - id: id, - code: code, - delay: delay, - maxTimes: times, - times: 0, - quit: quit, - } - v, exists := l.timer.LoadOrStore(id, timer) - if exists { - v.(Timer).quit <- true - l.timer.Store(id, timer) - } - - for { - select { - case <-quit: - return - case <-time.After(time.Millisecond * time.Duration(delay)): - timer.Emit(l) - count++ - if times > 0 && times >= count { - return - } +func main() { + config := ClientConfig{} + smartConfig.LoadConfig("go-mud", "0.6", &config) + client := NewClient(config) + client.Run() +} + +func NewClient(config ClientConfig) *Client { + return &Client{ + config: config, + ui: ui.NewUI(config.UI), + lua: lua.NewLuaRobot(config.Lua), + mud: mud.NewMudServer(config.Mud), + quit: make(chan bool, 1), + } +} + +func (c *Client) Run() { + ansiRe := regexp.MustCompile("\x1b" + `\[\d*(?:;\d*(?:;\d*)?)?(?:A|D|K|m)`) + + c.ui.Create() + go c.ui.Run() + c.lua.SetScreen(c.ui) + c.lua.SetMud(c.mud) + c.lua.Init() + c.mud.SetScreen(c.ui) + go c.mud.Run() + + beautify := ambiWidthAdjuster(c.config.UI.AmbiguousWidth) + +LOOP: + for { + select { + case <-c.quit: + break LOOP + case rawLine, ok := <-c.mud.Input(): + if ok { + showLine := beautify(rawLine) + plainLine := ansiRe.ReplaceAllString(rawLine, "") + c.ui.Println(showLine) + c.lua.OnReceive(rawLine, plainLine) + } else { + c.ui.Println("程序即将退出。") + time.Sleep(3 * time.Second) + break LOOP } + case cmd := <-c.ui.Input(): + c.DoCmd(cmd) } - }() - - return 0 -} - -func (l *LuaRobot) DelTimer(L *lua.LState) int { - id := L.ToString(1) - v, ok := l.timer.Load(id) - if ok { - v.(Timer).quit <- true } - l.timer.Delete(id) - return 0 -} -func (l *LuaRobot) Logf(format string, a ...interface{}) { - if mainWindow == nil { - log.Printf(format, a...) - } else { - fmt.Fprintf(mainWindow, format+"\n", a...) - } - return + c.ui.Stop() + c.mud.Stop() } -func main() { - initLua() - - app = tview.NewApplication() - mainWindow = tview.NewTextView(). - SetDynamicColors(true). - SetScrollable(true). - SetChangedFunc(func() { - app.Draw() - }) - - mudInput, output := mudServer() - go func() { - w := tview.ANSIWriter(mainWindow) - for raw := range mudInput { - fmt.Fprintln(w, raw) - input := ansiRe.ReplaceAllString(raw, "") - luaRobot.OnReceive(raw, input) - } - }() - - luaRobot.mud = output - - cmdLine := tview.NewInputField(). - SetFieldBackgroundColor(tcell.ColorBlack). - SetLabelColor(tcell.ColorWhite). - SetLabel("命令: ") - - cmdLine.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEnter { - cmd := cmdLine.GetText() - if cmd == "exit" || cmd == "quit" { - app.Stop() - log.Print("程序已退出。") - return - } else if cmd == "lua.reload" { - cmdLine.SetText("") - luaRobot.Reload() - return - } else if strings.HasPrefix(cmd, `'`) { - // 北侠默认支持单引号自动变成 say 命令效果 - } else if strings.HasPrefix(cmd, `"`) { - cmd = "chat " + cmd[1:] - } else if strings.HasPrefix(cmd, `*`) { - cmd = "chat* " + cmd[1:] - } else if strings.HasPrefix(cmd, `;`) { - cmd = "rumor " + cmd[1:] - } - - if cmd != "" { - cmdLine.SetText("") - fmt.Fprintln(mainWindow, cmd) - needSend := luaRobot.OnSend(cmd) - if needSend { - cmd = UTF8_TO_GBK(cmd) - fmt.Fprintln(output, cmd) - } - } - mainWindow.ScrollToEnd() - } - }) - - cmdLine.SetChangedFunc(func(text string) { - if strings.HasPrefix(text, `"`) { - cmdLine.SetLabel("闲聊: "). - SetLabelColor(tcell.ColorLightCyan). - SetFieldTextColor(tcell.ColorLightCyan) - } else if strings.HasPrefix(text, `*`) { - cmdLine.SetLabel("表情: "). - SetLabelColor(tcell.ColorLime). - SetFieldTextColor(tcell.ColorLime) - } else if strings.HasPrefix(text, `'`) { - cmdLine.SetLabel("说话: "). - SetLabelColor(tcell.ColorDarkCyan). - SetFieldTextColor(tcell.ColorDarkCyan) - } else if strings.HasPrefix(text, `;`) { - cmdLine.SetLabel("谣言: "). - SetLabelColor(tcell.ColorPink). - SetFieldTextColor(tcell.ColorPink) +func (c *Client) DoCmd(cmd string) { + if cmd == "exit" || cmd == "quit" { + c.quit <- true + return + } else if cmd == "lua.reload" { + c.lua.Reload() + return + } else if strings.HasPrefix(cmd, `'`) { + // 北侠默认支持单引号自动变成 say 命令效果 + } else if strings.HasPrefix(cmd, `"`) { + cmd = "chat " + cmd[1:] + } else if strings.HasPrefix(cmd, `*`) { + cmd = "chat* " + cmd[1:] + } else if strings.HasPrefix(cmd, `;`) { + cmd = "rumor " + cmd[1:] + } + + c.ui.Println(cmd) + needSend := c.lua.OnSend(cmd) + if needSend { + c.mud.Println(cmd) + } +} + +func ambiWidthAdjuster(option string) func(string) string { + singleAmbiguousWidth := func(str string) string { + return str + } + option = strings.ToLower(option) + switch option { + case "double": + return doubleAmbiguousWidth + case "single": + return singleAmbiguousWidth + case "auto": + if runtime.GOOS == "windows" { + return singleAmbiguousWidth } else { - cmdLine.SetLabel("命令: "). - SetLabelColor(tcell.ColorWhite). - SetFieldTextColor(tcell.ColorLightGrey) + return doubleAmbiguousWidth } - }) - - mainFrame := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(mainWindow, 0, 1, false). - AddItem(cmdLine, 1, 1, false) - - app.SetRoot(mainFrame, true). - SetFocus(cmdLine). - SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyCtrlC { - if mainWindow.HasFocus() { - mainWindow.ScrollToEnd() - app.SetFocus(cmdLine) - } else { - cmdLine.SetText("") - } - return nil - } else if event.Key() == tcell.KeyCtrlB { - app.SetFocus(mainWindow) - row, _ := mainWindow.GetScrollOffset() - row -= 10 - if row < 0 { - row = 0 - } - mainWindow.ScrollTo(row, 0) - } else if event.Key() == tcell.KeyCtrlF { - app.SetFocus(mainWindow) - row, _ := mainWindow.GetScrollOffset() - row += 10 - mainWindow.ScrollTo(row, 0) - } - return event - }) - - defer app.Stop() - - if err := app.Run(); err != nil { - panic(err) - } -} - -func mudServer() (input <-chan string, output io.Writer) { - serverAddress := "mud.pkuxkx.net:8080" - - log.Printf("连接到服务器 %s...", serverAddress) - conn, _ := net.Dial("tcp", serverAddress) - rd := bufio.NewReader(conn) - log.Print("连接成功。") - - mudInput := make(chan string, 1024) - go func(ch chan<- string) { - for { - lineBuf, _, err := rd.ReadLine() - if err != nil { - break - } - lineStr := GBK_TO_UTF8(string(lineBuf)) - newLineStr := "" - for _, c := range lineStr { - newLineStr += string(c) - switch c { - case '┌', '┬', '├', '┼', '└', '┴', '─', - '╓', '╥', '╟', '╫', '╙', '╨', - '╭', '╰': - newLineStr += "─" - case '╔', '╦', '╠', '╬', '╚', '╩', '═', - '╒', '╤', '╞', '╪', '╘', '╧': - newLineStr += "═" - case '█', '▇', '▆', '▅', '▄', '▃', '▂', '▁', '▀': - newLineStr += string(c) - default: - p := width.LookupRune(c) - if p.Kind() == width.EastAsianAmbiguous { - newLineStr += " " - } - } + default: + return singleAmbiguousWidth + } +} + +func doubleAmbiguousWidth(str string) string { + newStr := "" + for _, c := range str { + newStr += string(c) + switch c { + case '┌', '┬', '├', '┼', '└', '┴', '─', + '╓', '╥', '╟', '╫', '╙', '╨', + '╭', '╰': + newStr += "─" + case '╔', '╦', '╠', '╬', '╚', '╩', '═', + '╒', '╤', '╞', '╪', '╘', '╧': + newStr += "═" + case '█', '▇', '▆', '▅', '▄', '▃', '▂', '▁', '▀': + newStr += string(c) + default: + p := width.LookupRune(c) + if p.Kind() == width.EastAsianAmbiguous { + newStr += " " } - - ch <- newLineStr } - close(ch) - }(mudInput) - - return mudInput, conn -} - -func UTF8_TO_GBK(input string) (output string) { - output = encoder.ConvertString(input) - return -} + } -func GBK_TO_UTF8(input string) (output string) { - output = decoder.ConvertString(input) - return + return newStr } diff --git a/mud/iac.go b/mud/iac.go new file mode 100644 index 0000000..cc29abf --- /dev/null +++ b/mud/iac.go @@ -0,0 +1,145 @@ +package mud + +import ( + "bytes" + "fmt" + "log" +) + +const IAC = 255 // 0xFF Interpret as Command +const WILL = 251 // 0xFB Will do something +const WONT = 252 // 0xFC Won't do something +const DO = 253 // 0xFD Do something +const DONT = 254 // 0xFE Don't do something +const SB = 250 // 0xFA Subnegotiation Begin +const SE = 240 // 0xF0 Subnegotiation End +const GA = 249 // 0xF9 Go Ahead + +const EL = 248 // 0xF8 Erase Line +const EC = 247 // 0xF7 Erase Character +const AYT = 246 // 0xF6 Are You Here? +const TTYPE = 24 // 0x18 Terminal Type +const NAWS = 31 // 0x1F Negotiate About Window Size +const NENV = 39 // 0x27 New Environment +const MXP = 91 // 0x5B MUD eXtension Protocol +const MSSP = 70 // 0x46 MUD Server Status Protocol +const ZMP = 93 // 0x5D Zenith MUD Protocol +const GMCP = 201 // 0xC9 Generic MUD Communication Protocol +const NOP = 241 // 0xF1 No operation +const ECHO = 1 // 0x01 Echo + +var codeName = map[byte]string{ + IAC: "IAC", + WILL: "WILL", + WONT: "WONT", + DO: "DO", + DONT: "DONT", + SB: "SB", + SE: "SE", + GA: "GA", + EL: "EL", + EC: "EC", + AYT: "AYT", + TTYPE: "TTYPE", + NAWS: "NAWS", + NENV: "NEW-ENV", + MXP: "MXP", + MSSP: "MSSP", + ZMP: "ZMP", + GMCP: "GMCP", + NOP: "NOP", + ECHO: "ECHO", +} + +type iacStage int + +const ( + stCmd iacStage = iota + stArg + stSuboption + stDone +) + +type IACMessage struct { + state iacStage + Command byte + Args []byte +} + +func NewIACMessage() *IACMessage { + iac := &IACMessage{} + iac.Reset() + return iac +} + +func (IACMessage) IsMessage() {} + +func (iac *IACMessage) Reset() { + iac.state = stCmd + iac.Args = make([]byte, 0, 128) +} + +func (iac IACMessage) String() string { + cmdName := codeName[iac.Command] + if cmdName == "" { + cmdName = fmt.Sprintf("%d", iac.Command) + } + argName := fmt.Sprintf("%v", iac.Args[:len(iac.Args)]) + if (iac.Command == WILL || + iac.Command == WONT || + iac.Command == DO || + iac.Command == DONT || + iac.Command == SB) && + codeName[iac.Args[0]] != "" { + argName = codeName[iac.Args[0]] + } + return fmt.Sprintf("IAC %s %s", cmdName, argName) +} + +func (iac IACMessage) Eq(command byte, args ...byte) bool { + if iac.Command != command { + return false + } + + return bytes.Equal(iac.Args, args) +} + +func (iac *IACMessage) Scan(b byte) bool { + switch iac.state { + case stCmd: + switch b { + case WILL, WONT, DO, DONT: + iac.Command = b + iac.state = stArg + return false + case SB: + iac.Command = SB + iac.state = stSuboption + return false + case SE: + iac.Command = SE + iac.state = stDone + return true + case GA: + iac.Command = GA + iac.state = stDone + return true + default: + // TODO: 在这里处理所有的 IAC 指令 + log.Printf("----未知指令: IAC %d", b) + iac.state = stDone + return true + } + case stArg: + iac.Args = append(iac.Args, b) + iac.state = stDone + return true + case stSuboption: + iac.Args = append(iac.Args, b) + return false + default: + iac.state = stDone + log.Printf("未知指令: IAC %d", b) + return true + } +} diff --git a/mud/mud.go b/mud/mud.go new file mode 100644 index 0000000..77aff5d --- /dev/null +++ b/mud/mud.go @@ -0,0 +1,146 @@ +package mud + +import ( + "fmt" + "io" + "io/ioutil" + "net" + "os" + "time" + + "github.com/axgle/mahonia" + "github.com/flw-cn/printer" +) + +var ( + decoder mahonia.Decoder + encoder mahonia.Encoder +) + +func init() { + decoder = mahonia.NewDecoder("GB18030") + encoder = mahonia.NewEncoder("GB18030") +} + +type MudConfig struct { + IACDebug bool + Host string `flag:"H|mud.pkuxkx.net|服务器 {IP/Domain}"` + Port int `flag:"P|8080|服务器 {Port}"` +} + +type MudServer struct { + printer.SimplePrinter + + config MudConfig + + screen printer.Printer + server printer.WritePrinter + + conn net.Conn + input chan string +} + +func NewMudServer(config MudConfig) *MudServer { + mud := &MudServer{ + config: config, + screen: printer.NewSimplePrinter(os.Stdout), + server: printer.NewSimplePrinter(ioutil.Discard), + input: make(chan string, 1024), + } + + mud.SetOutput(mud.server) + + return mud +} + +func (mud *MudServer) SetScreen(w io.Writer) { + mud.screen.SetOutput(w) +} + +func (mud *MudServer) Run() { + serverAddress := fmt.Sprintf("%s:%d", mud.config.Host, mud.config.Port) + mud.screen.Printf("连接到服务器 %s...", serverAddress) + + var err error + mud.conn, err = net.DialTimeout("tcp", serverAddress, 4*time.Second) + + if err != nil { + mud.screen.Println("连接失败。") + mud.screen.Printf("失败原因: %v\n", err) + close(mud.input) + return + } + + mud.Println("连接成功。") + + netWriter := encoder.NewWriter(mud.conn) + mud.server.SetOutput(netWriter) + + scanner := NewScanner(mud.conn) + +LOOP: + for { + msg := scanner.Scan() + + switch m := msg.(type) { + case EOF: + break LOOP + case IncompleteLine: + r := decoder.NewReader(m) + buf, _ := ioutil.ReadAll(r) + mud.input <- string(buf) + case Line: + r := decoder.NewReader(m) + buf, _ := ioutil.ReadAll(r) + mud.input <- string(buf) + case IACMessage: + mud.telnetNegotiate(m) + } + } + + mud.server.SetOutput(ioutil.Discard) + + mud.screen.Println("连接已断开。") + mud.screen.Println("TODO: 这里需要实现自动重连。") + + close(mud.input) +} + +func (mud *MudServer) telnetNegotiate(m IACMessage) { + if m.Eq(WILL, ZMP) { + mud.conn.Write([]byte{IAC, DO, ZMP}) + go func() { + for { + time.Sleep(10 * time.Second) + mud.conn.Write([]byte{IAC, SB, ZMP}) + mud.conn.Write([]byte("zmp.ping")) + mud.conn.Write([]byte{0, IAC, SE}) + } + }() + } else if m.Eq(DO, TTYPE) { + mud.conn.Write([]byte{IAC, WILL, TTYPE}) + } else if m.Eq(SB, TTYPE, 0x01) { + mud.conn.Write(append([]byte{IAC, SB, TTYPE, 0x00}, []byte("GoMud")...)) + mud.conn.Write([]byte{IAC, SE}) + } else if m.Command == WILL { + mud.conn.Write([]byte{IAC, DONT, m.Args[0]}) + } else if m.Command == DO { + mud.conn.Write([]byte{IAC, WONT, m.Args[0]}) + } else if m.Command == GA { + mud.input <- "IAC GA" + } + // TODO: IAC 不继续传递给 UI + if mud.config.IACDebug { + mud.input <- m.String() + } +} + +func (mud *MudServer) Stop() { + if mud.conn != nil { + mud.conn.Close() + } +} + +func (mud *MudServer) Input() <-chan string { + return mud.input +} diff --git a/mud/scan.go b/mud/scan.go new file mode 100644 index 0000000..6db06c2 --- /dev/null +++ b/mud/scan.go @@ -0,0 +1,126 @@ +package mud + +import ( + "bytes" + "io" + "net" + "time" +) + +type MudMessage interface { + IsMessage() +} + +type CSIMessage struct { + Parameter bytes.Buffer + Intermediate bytes.Buffer + Command byte +} + +type Line struct{ *bytes.Buffer } +type IncompleteLine struct{ *bytes.Buffer } +type EOF bool + +func (CSIMessage) IsMessage() {} +func (Line) IsMessage() {} +func (IncompleteLine) IsMessage() {} +func (EOF) IsMessage() {} + +type ReaderWithDeadline interface { + io.Reader + SetReadDeadline(t time.Time) error +} + +type Scanner struct { + r ReaderWithDeadline + buf bytes.Buffer + state ScannerStatus + msg MudMessage + done bool +} + +type ScannerStatus int + +const ( + stText ScannerStatus = iota + stIACCommand + stANSICodes +) + +func NewScanner(r ReaderWithDeadline) *Scanner { + return &Scanner{ + r: r, + } +} + +func (s *Scanner) Scan() MudMessage { + if s.done { + return EOF(true) + } + + iacCmd := NewIACMessage() + line := new(bytes.Buffer) + + for { + b, err := s.readByte() + if err == io.EOF { + s.done = true + return EOF(true) + } else if err != nil { + if line.Len() == 0 { + continue + } else { + return IncompleteLine{line} + } + } + + switch s.state { + case stText: + switch b { + case IAC: + s.state = stIACCommand + if line.Len() > 0 { + return IncompleteLine{line} + } + case '\r': // 忽略 + case '\n': + return Line{line} + default: + line.WriteByte(b) + } + case stIACCommand: + if b == IAC { + return *iacCmd + } else if iacCmd.Scan(b) { + s.state = stText + return *iacCmd + } + } + } +} + +// readByte 努力读取一个字节,并返回成功(nil)或两种错误之一: +// timeout: 超时 +// io.EOF: 连接已经不可用 +// 优先从 s.buf 中读取,如果 s.buf 为空,则从 s.r 中读取 +func (s *Scanner) readByte() (byte, error) { + b, err := s.buf.ReadByte() + if err != io.EOF { + return b, err + } + + s.r.SetReadDeadline(time.Now().Add(1 * time.Second)) + bytes := make([]byte, 1024) + n, err := s.r.Read(bytes) + if err == nil && n > 0 { + s.buf.Write(bytes[:n]) + return s.buf.ReadByte() + } + + e, ok := err.(net.Error) + if ok && (e.Timeout() || e.Temporary()) { + return 0, err + } + + return 0, io.EOF +} diff --git a/ui/ansi.go b/ui/ansi.go new file mode 100644 index 0000000..2986f81 --- /dev/null +++ b/ui/ansi.go @@ -0,0 +1,321 @@ +package ui + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" +) + +// The states of the ANSI escape code parser. +const ( + ansiText = iota + ansiEscape + ansiSubstring + ansiControlSequence +) + +// ansi is a io.Writer which translates ANSI escape codes into tview color +// tags. +type ansi struct { + io.Writer + + // Reusable buffers. + buffer *bytes.Buffer // The entire output text of one Write(). + csiParameter, csiIntermediate *bytes.Buffer // Partial CSI strings. + + // The current state of the parser. One of the ansi constants. + state int +} + +// ANSIWriter returns an io.Writer which translates any ANSI escape codes +// written to it into tview color tags. Other escape codes don't have an effect +// and are simply removed. The translated text is written to the provided +// writer. +func ANSIWriter(writer io.Writer) io.Writer { + return &ansi{ + Writer: writer, + buffer: new(bytes.Buffer), + csiParameter: new(bytes.Buffer), + csiIntermediate: new(bytes.Buffer), + state: ansiText, + } +} + +// Write parses the given text as a string of runes, translates ANSI escape +// codes to color tags and writes them to the output writer. +func (a *ansi) Write(text []byte) (int, error) { + defer func() { + a.buffer.Reset() + }() + + for _, r := range string(text) { + switch a.state { + + // We just entered an escape sequence. + case ansiEscape: + switch r { + case '[': // Control Sequence Introducer. + a.csiParameter.Reset() + a.csiIntermediate.Reset() + a.state = ansiControlSequence + case 'c': // Reset. + fmt.Fprint(a.buffer, "[-:-:-]") + a.state = ansiText + case 'P', ']', 'X', '^', '_': // Substrings and commands. + a.state = ansiSubstring + default: // Ignore. + a.state = ansiText + } + + // CSI Sequences. + case ansiControlSequence: + switch { + case r >= 0x30 && r <= 0x3f: // Parameter bytes. + if _, err := a.csiParameter.WriteRune(r); err != nil { + return 0, err + } + case r >= 0x20 && r <= 0x2f: // Intermediate bytes. + if _, err := a.csiIntermediate.WriteRune(r); err != nil { + return 0, err + } + case r >= 0x40 && r <= 0x7e: // Final byte. + switch r { + case 'E': // Next line. + count, _ := strconv.Atoi(a.csiParameter.String()) + if count == 0 { + count = 1 + } + fmt.Fprint(a.buffer, strings.Repeat("\n", count)) + case 'm': // Select Graphic Rendition. + var ( + background, foreground, attributes string + bold, dim, italic, underline, blink, reverse *bool + ) + codes := strings.Split(a.csiParameter.String(), ";") + codesNum := len(codes) + for index := 0; index < codesNum; index++ { + code, err := strconv.Atoi(codes[index]) + if err != nil { + continue + } + switch code { + case 0: + background = "-" + foreground = "-" + attributes = "-" + bold = nil + dim = nil + italic = nil + underline = nil + blink = nil + reverse = nil + case 1: + bold = boolTrue() + case 2: + dim = boolTrue() + case 3: + italic = boolTrue() + case 4: + underline = boolTrue() + case 5: + blink = boolTrue() + case 7: + reverse = boolTrue() + case 21: + bold = boolFalse() + case 22: + dim = boolFalse() + case 23: + italic = boolFalse() + case 24: + underline = boolFalse() + case 25: + blink = boolFalse() + case 27: + reverse = boolFalse() + case 30, 31, 32, 33, 34, 35, 36, 37: + isBright := false + if bold != nil && *bold { + isBright = true + } + foreground = lookupColor(code-30, isBright) + case 40, 41, 42, 43, 44, 45, 46, 47: + background = lookupColor(code-40, false) + case 90, 91, 92, 93, 94, 95, 96, 97: + bold = boolTrue() + foreground = lookupColor(code-90, true) + case 100, 101, 102, 103, 104, 105, 106, 107: + bold = boolTrue() + background = lookupColor(code-100, true) + case 38, 48: + var color string + + if codes[index+1] == "5" && codesNum > index+2 { // 8-bit colors. + index += 2 + color = resolve8bitsColor(codes[index+2]) + } else if codes[index+1] == "2" && len(codes) > index+4 { // 24-bit colors. + index += 4 + color = resolve24bitsColor(codes[index+2], codes[index+3], codes[index+4]) + } + + if len(color) > 0 { + if code == 38 { + foreground = color + } else { + background = color + } + } + } + } + attributes2 := makeAttr(bold, dim, italic, underline, blink, reverse) + if len(attributes2) > 0 { + if attributes == "-" { + attributes = "BDULR" + attributes2 + } else { + attributes = attributes2 + } + } + if len(foreground) > 0 || len(background) > 0 || len(attributes) > 0 { + fmt.Fprintf(a.buffer, "[%s:%s:%s]", foreground, background, attributes) + } + } + a.state = ansiText + default: // Undefined byte. + a.state = ansiText // Abort CSI. + } + + // We just entered a substring/command sequence. + case ansiSubstring: + if r == 27 { // Most likely the end of the substring. + a.state = ansiEscape + } // Ignore all other characters. + + // "ansiText" and all others. + default: + if r == 27 { + // This is the start of an escape sequence. + a.state = ansiEscape + } else { + // Just a regular rune. Send to buffer. + if _, err := a.buffer.WriteRune(r); err != nil { + return 0, err + } + } + } + } + + // Write buffer to target writer. + n, err := a.buffer.WriteTo(a.Writer) + if err != nil { + return int(n), err + } + return len(text), nil +} + +func lookupColor(colorNumber int, isBright bool) string { + if colorNumber < 0 || colorNumber > 7 { + return "black" + } + if isBright { + colorNumber += 8 + } + return [...]string{ + "black", + "red", + "green", + "yellow", + "blue", + "darkmagenta", + "darkcyan", + "white", + "#7f7f7f", + "#ff0000", + "#00ff00", + "#ffff00", + "#5c5cff", + "#ff00ff", + "#00ffff", + "#ffffff", + }[colorNumber] +} + +func resolve8bitsColor(code string) string { + colorNumber, _ := strconv.Atoi(code) + color := "" + if colorNumber <= 7 { + color = lookupColor(colorNumber, false) + } else if colorNumber <= 15 { + color = lookupColor(colorNumber, true) + } else if colorNumber <= 231 { + red := (colorNumber - 16) / 36 + green := ((colorNumber - 16) / 6) % 6 + blue := (colorNumber - 16) % 6 + color = fmt.Sprintf("#%02x%02x%02x", 255*red/5, 255*green/5, 255*blue/5) + } else if colorNumber <= 255 { + grey := 255 * (colorNumber - 232) / 23 + color = fmt.Sprintf("#%02x%02x%02x", grey, grey, grey) + } + return color +} + +func resolve24bitsColor(red, green, blue string) string { + r, _ := strconv.Atoi(red) + g, _ := strconv.Atoi(green) + b, _ := strconv.Atoi(blue) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// TranslateANSI replaces ANSI escape sequences found in the provided string +// with tview's color tags and returns the resulting string. +func TranslateANSI(text string) string { + var buffer bytes.Buffer + writer := ANSIWriter(&buffer) + writer.Write([]byte(text)) + return buffer.String() +} + +func boolTrue() *bool { b := true; return &b } +func boolFalse() *bool { b := false; return &b } + +func makeAttr(bold, dim, italic, underline, blink, reverse *bool) string { + attr := "" + if bold != nil { + if *bold { + attr += "b" + } else { + attr += "B" + } + } + if dim != nil { + if *dim { + attr += "d" + } else { + attr += "D" + } + } + if underline != nil { + if *underline { + attr += "u" + } else { + attr += "U" + } + } + if blink != nil { + if *blink { + attr += "l" + } else { + attr += "L" + } + } + if reverse != nil { + if *reverse { + attr += "r" + } else { + attr += "R" + } + } + + return attr +} diff --git a/ui/textview.go b/ui/textview.go new file mode 100644 index 0000000..46df20c --- /dev/null +++ b/ui/textview.go @@ -0,0 +1,1246 @@ +package ui + +import ( + "bytes" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "unicode/utf8" + + "github.com/gdamore/tcell" + colorful "github.com/lucasb-eyer/go-colorful" + runewidth "github.com/mattn/go-runewidth" + "github.com/rivo/tview" + "github.com/rivo/uniseg" +) + +var ( + openColorRegex = regexp.MustCompile(`\[([a-zA-Z]*|#[0-9a-zA-Z]*)$`) + openRegionRegex = regexp.MustCompile(`\["[a-zA-Z0-9_,;: \-\.]*"?$`) + newLineRegex = regexp.MustCompile(`\r?\n`) + + // TabSize is the number of spaces with which a tab character will be replaced. + TabSize = 4 +) + +// Common regular expressions. +var ( + colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([LBDRUlbdru]+|\-)?)?)?\]`) + regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) + escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) + nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`) + boundaryPattern = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`) + spacePattern = regexp.MustCompile(`\s+`) +) + +// Positions of substrings in regular expressions. +const ( + colorForegroundPos = 1 + colorBackgroundPos = 3 + colorFlagPos = 5 +) + +const ( + AlignLeft = iota + AlignCenter + AlignRight +) + +// textViewIndex contains information about each line displayed in the text +// view. +type textViewIndex struct { + Line int // The index into the "buffer" variable. + Pos int // The index into the "buffer" string (byte position). + NextPos int // The (byte) index of the next character in this buffer line. + Width int // The screen width of this line. + ForegroundColor string // The starting foreground color ("" = don't change, "-" = reset). + BackgroundColor string // The starting background color ("" = don't change, "-" = reset). + Attributes string // The starting attributes ("" = don't change, "-" = reset). + Region string // The starting region ID. +} + +// TextView is a box which displays text. It implements the io.Writer interface +// so you can stream text to it. This does not trigger a redraw automatically +// but if a handler is installed via SetChangedFunc(), you can cause it to be +// redrawn. (See SetChangedFunc() for more details.) +// +// Navigation +// +// If the text view is scrollable (the default), text is kept in a buffer which +// may be larger than the screen and can be navigated similarly to Vim: +// +// - h, left arrow: Move left. +// - l, right arrow: Move right. +// - j, down arrow: Move down. +// - k, up arrow: Move up. +// - g, home: Move to the top. +// - G, end: Move to the bottom. +// - Ctrl-F, page down: Move down by one page. +// - Ctrl-B, page up: Move up by one page. +// +// If the text is not scrollable, any text above the top visible line is +// discarded. +// +// Use SetInputCapture() to override or modify keyboard input. +// +// Colors +// +// If dynamic colors are enabled via SetDynamicColors(), text color can be +// changed dynamically by embedding color strings in square brackets. This works +// the same way as anywhere else. Please see the package documentation for more +// information. +// +// Regions and Highlights +// +// If regions are enabled via SetRegions(), you can define text regions within +// the text and assign region IDs to them. Text regions start with region tags. +// Region tags are square brackets that contain a region ID in double quotes, +// for example: +// +// We define a ["rg"]region[""] here. +// +// A text region ends with the next region tag. Tags with no region ID ([""]) +// don't start new regions. They can therefore be used to mark the end of a +// region. Region IDs must satisfy the following regular expression: +// +// [a-zA-Z0-9_,;: \-\.]+ +// +// Regions can be highlighted by calling the Highlight() function with one or +// more region IDs. This can be used to display search results, for example. +// +// The ScrollToHighlight() function can be used to jump to the currently +// highlighted region once when the text view is drawn the next time. +// +// See https://github.com/rivo/tview/wiki/TextView for an example. +type TextView struct { + sync.Mutex + *tview.Box + + // The text buffer. + buffer []string + + // The last bytes that have been received but are not part of the buffer yet. + recentBytes []byte + + // The processed line index. This is nil if the buffer has changed and needs + // to be re-indexed. + index []*textViewIndex + + // The text alignment, one of AlignLeft, AlignCenter, or AlignRight. + align int + + // Indices into the "index" slice which correspond to the first line of the + // first highlight and the last line of the last highlight. This is calculated + // during re-indexing. Set to -1 if there is no current highlight. + fromHighlight, toHighlight int + + // The screen space column of the highlight in its first line. Set to -1 if + // there is no current highlight. + posHighlight int + + // A set of region IDs that are currently highlighted. + highlights map[string]struct{} + + // The last width for which the current table is drawn. + lastWidth int + + // The screen width of the longest line in the index (not the buffer). + longestLine int + + // The index of the first line shown in the text view. + lineOffset int + + // If set to true, the text view will always remain at the end of the content. + trackEnd bool + + // The number of characters to be skipped on each line (not in wrap mode). + columnOffset int + + // The height of the content the last time the text view was drawn. + pageSize int + + // If set to true, the text view will keep a buffer of text which can be + // navigated when the text is longer than what fits into the box. + scrollable bool + + // If set to true, lines that are longer than the available width are wrapped + // onto the next line. If set to false, any characters beyond the available + // width are discarded. + wrap bool + + // If set to true and if wrap is also true, lines are split at spaces or + // after punctuation characters. + wordWrap bool + + // The (starting) color of the text. + textColor tcell.Color + + // If set to true, the text color can be changed dynamically by piping color + // strings in square brackets to the text view. + dynamicColors bool + + // If set to true, region tags can be used to define regions. + regions bool + + // A temporary flag which, when true, will automatically bring the current + // highlight(s) into the visible screen. + scrollToHighlights bool + + // An optional function which is called when the content of the text view has + // changed. + changed func() + + // An optional function which is called when the user presses one of the + // following keys: Escape, Enter, Tab, Backtab. + done func(tcell.Key) +} + +// NewTextView returns a new text view. +func NewTextView() *TextView { + return &TextView{ + Box: tview.NewBox(), + highlights: make(map[string]struct{}), + lineOffset: -1, + scrollable: true, + align: AlignLeft, + wrap: true, + textColor: tview.Styles.PrimaryTextColor, + regions: false, + dynamicColors: false, + } +} + +// SetScrollable sets the flag that decides whether or not the text view is +// scrollable. If true, text is kept in a buffer and can be navigated. +func (t *TextView) SetScrollable(scrollable bool) *TextView { + t.scrollable = scrollable + if !scrollable { + t.trackEnd = true + } + return t +} + +// SetWrap sets the flag that, if true, leads to lines that are longer than the +// available width being wrapped onto the next line. If false, any characters +// beyond the available width are not displayed. +func (t *TextView) SetWrap(wrap bool) *TextView { + if t.wrap != wrap { + t.index = nil + } + t.wrap = wrap + return t +} + +// SetWordWrap sets the flag that, if true and if the "wrap" flag is also true +// (see SetWrap()), wraps the line at spaces or after punctuation marks. Note +// that trailing spaces will not be printed. +// +// This flag is ignored if the "wrap" flag is false. +func (t *TextView) SetWordWrap(wrapOnWords bool) *TextView { + if t.wordWrap != wrapOnWords { + t.index = nil + } + t.wordWrap = wrapOnWords + return t +} + +// SetTextAlign sets the text alignment within the text view. This must be +// either AlignLeft, AlignCenter, or AlignRight. +func (t *TextView) SetTextAlign(align int) *TextView { + if t.align != align { + t.index = nil + } + t.align = align + return t +} + +// SetTextColor sets the initial color of the text (which can be changed +// dynamically by sending color strings in square brackets to the text view if +// dynamic colors are enabled). +func (t *TextView) SetTextColor(color tcell.Color) *TextView { + t.textColor = color + return t +} + +// SetText sets the text of this text view to the provided string. Previously +// contained text will be removed. +func (t *TextView) SetText(text string) *TextView { + t.Clear() + fmt.Fprint(t, text) + return t +} + +// GetText returns the current text of this text view. If "stripTags" is set +// to true, any region/color tags are stripped from the text. +func (t *TextView) GetText(stripTags bool) string { + // Get the buffer. + buffer := t.buffer + if !stripTags { + buffer = append(buffer, string(t.recentBytes)) + } + + // Add newlines again. + text := strings.Join(buffer, "\n") + + // Strip from tags if required. + if stripTags { + if t.regions { + text = regionPattern.ReplaceAllString(text, "") + } + if t.dynamicColors { + text = colorPattern.ReplaceAllString(text, "") + } + if t.regions || t.dynamicColors { + text = escapePattern.ReplaceAllString(text, `[$1$2]`) + } + } + + return text +} + +// SetDynamicColors sets the flag that allows the text color to be changed +// dynamically. See class description for details. +func (t *TextView) SetDynamicColors(dynamic bool) *TextView { + if t.dynamicColors != dynamic { + t.index = nil + } + t.dynamicColors = dynamic + return t +} + +// SetRegions sets the flag that allows to define regions in the text. See class +// description for details. +func (t *TextView) SetRegions(regions bool) *TextView { + if t.regions != regions { + t.index = nil + } + t.regions = regions + return t +} + +// SetChangedFunc sets a handler function which is called when the text of the +// text view has changed. This is useful when text is written to this io.Writer +// in a separate goroutine. This does not automatically cause the screen to be +// refreshed so you may want to use the "changed" handler to redraw the screen. +// +// Note that to avoid race conditions or deadlocks, there are a few rules you +// should follow: +// +// - You can call Application.Draw() from this handler. +// - You can call TextView.HasFocus() from this handler. +// - During the execution of this handler, access to any other variables from +// this primitive or any other primitive should be queued using +// Application.QueueUpdate(). +// +// See package description for details on dealing with concurrency. +func (t *TextView) SetChangedFunc(handler func()) *TextView { + t.changed = handler + return t +} + +// SetDoneFunc sets a handler which is called when the user presses on the +// following keys: Escape, Enter, Tab, Backtab. The key is passed to the +// handler. +func (t *TextView) SetDoneFunc(handler func(key tcell.Key)) *TextView { + t.done = handler + return t +} + +// ScrollTo scrolls to the specified row and column (both starting with 0). +func (t *TextView) ScrollTo(row, column int) *TextView { + if !t.scrollable { + return t + } + t.lineOffset = row + t.columnOffset = column + t.trackEnd = false + return t +} + +// ScrollToBeginning scrolls to the top left corner of the text if the text view +// is scrollable. +func (t *TextView) ScrollToBeginning() *TextView { + if !t.scrollable { + return t + } + t.trackEnd = false + t.lineOffset = 0 + t.columnOffset = 0 + return t +} + +// ScrollToEnd scrolls to the bottom left corner of the text if the text view +// is scrollable. Adding new rows to the end of the text view will cause it to +// scroll with the new data. +func (t *TextView) ScrollToEnd() *TextView { + if !t.scrollable { + return t + } + t.trackEnd = true + t.columnOffset = 0 + return t +} + +// GetScrollOffset returns the number of rows and columns that are skipped at +// the top left corner when the text view has been scrolled. +func (t *TextView) GetScrollOffset() (row, column int) { + return t.lineOffset, t.columnOffset +} + +// Clear removes all text from the buffer. +func (t *TextView) Clear() *TextView { + t.buffer = nil + t.recentBytes = nil + t.index = nil + return t +} + +// Highlight specifies which regions should be highlighted. See class +// description for details on regions. Empty region strings are ignored. +// +// Text in highlighted regions will be drawn inverted, i.e. with their +// background and foreground colors swapped. +// +// Calling this function will remove any previous highlights. To remove all +// highlights, call this function without any arguments. +func (t *TextView) Highlight(regionIDs ...string) *TextView { + t.highlights = make(map[string]struct{}) + for _, id := range regionIDs { + if id == "" { + continue + } + t.highlights[id] = struct{}{} + } + t.index = nil + return t +} + +// GetHighlights returns the IDs of all currently highlighted regions. +func (t *TextView) GetHighlights() (regionIDs []string) { + for id := range t.highlights { + regionIDs = append(regionIDs, id) + } + return +} + +// ScrollToHighlight will cause the visible area to be scrolled so that the +// highlighted regions appear in the visible area of the text view. This +// repositioning happens the next time the text view is drawn. It happens only +// once so you will need to call this function repeatedly to always keep +// highlighted regions in view. +// +// Nothing happens if there are no highlighted regions or if the text view is +// not scrollable. +func (t *TextView) ScrollToHighlight() *TextView { + if len(t.highlights) == 0 || !t.scrollable || !t.regions { + return t + } + t.index = nil + t.scrollToHighlights = true + t.trackEnd = false + return t +} + +// GetRegionText returns the text of the region with the given ID. If dynamic +// colors are enabled, color tags are stripped from the text. Newlines are +// always returned as '\n' runes. +// +// If the region does not exist or if regions are turned off, an empty string +// is returned. +func (t *TextView) GetRegionText(regionID string) string { + if !t.regions || regionID == "" { + return "" + } + + var ( + buffer bytes.Buffer + currentRegionID string + ) + + for _, str := range t.buffer { + // Find all color tags in this line. + var colorTagIndices [][]int + if t.dynamicColors { + colorTagIndices = colorPattern.FindAllStringIndex(str, -1) + } + + // Find all regions in this line. + var ( + regionIndices [][]int + regions [][]string + ) + if t.regions { + regionIndices = regionPattern.FindAllStringIndex(str, -1) + regions = regionPattern.FindAllStringSubmatch(str, -1) + } + + // Analyze this line. + var currentTag, currentRegion int + for pos, ch := range str { + // Skip any color tags. + if currentTag < len(colorTagIndices) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { + if pos == colorTagIndices[currentTag][1]-1 { + currentTag++ + } + continue + } + + // Skip any regions. + if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { + if pos == regionIndices[currentRegion][1]-1 { + if currentRegionID == regionID { + // This is the end of the requested region. We're done. + return buffer.String() + } + currentRegionID = regions[currentRegion][1] + currentRegion++ + } + continue + } + + // Add this rune. + if currentRegionID == regionID { + buffer.WriteRune(ch) + } + } + + // Add newline. + if currentRegionID == regionID { + buffer.WriteRune('\n') + } + } + + return escapePattern.ReplaceAllString(buffer.String(), `[$1$2]`) +} + +// Focus is called when this primitive receives focus. +func (t *TextView) Focus(delegate func(p tview.Primitive)) { + // Implemented here with locking because this is used by layout primitives. + t.Lock() + defer t.Unlock() + t.Box.Focus(delegate) +} + +// HasFocus returns whether or not this primitive has focus. +func (t *TextView) HasFocus() bool { + // Implemented here with locking because this may be used in the "changed" + // callback. + t.Lock() + defer t.Unlock() + return t.Box.HasFocus() +} + +// Write lets us implement the io.Writer interface. Tab characters will be +// replaced with TabSize space characters. A "\n" or "\r\n" will be interpreted +// as a new line. +func (t *TextView) Write(p []byte) (n int, err error) { + // Notify at the end. + t.Lock() + changed := t.changed + t.Unlock() + if changed != nil { + defer changed() // Deadlocks may occur if we lock here. + } + + t.Lock() + defer t.Unlock() + + // Copy data over. + newBytes := append(t.recentBytes, p...) + t.recentBytes = nil + + // If we have a trailing invalid UTF-8 byte, we'll wait. + if r, _ := utf8.DecodeLastRune(p); r == utf8.RuneError { + t.recentBytes = newBytes + return len(p), nil + } + + // If we have a trailing open dynamic color, exclude it. + if t.dynamicColors { + location := openColorRegex.FindIndex(newBytes) + if location != nil { + t.recentBytes = newBytes[location[0]:] + newBytes = newBytes[:location[0]] + } + } + + // If we have a trailing open region, exclude it. + if t.regions { + location := openRegionRegex.FindIndex(newBytes) + if location != nil { + t.recentBytes = newBytes[location[0]:] + newBytes = newBytes[:location[0]] + } + } + + // Transform the new bytes into strings. + newBytes = bytes.Replace(newBytes, []byte{'\t'}, bytes.Repeat([]byte{' '}, TabSize), -1) + for index, line := range newLineRegex.Split(string(newBytes), -1) { + if index == 0 { + if len(t.buffer) == 0 { + t.buffer = []string{line} + } else { + t.buffer[len(t.buffer)-1] += line + } + } else { + t.buffer = append(t.buffer, line) + } + } + + // Reset the index. + t.index = nil + + return len(p), nil +} + +// reindexBuffer re-indexes the buffer such that we can use it to easily draw +// the buffer onto the screen. Each line in the index will contain a pointer +// into the buffer from which on we will print text. It will also contain the +// color with which the line starts. +func (t *TextView) reindexBuffer(width int) { + if t.index != nil { + return // Nothing has changed. We can still use the current index. + } + t.index = nil + t.fromHighlight, t.toHighlight, t.posHighlight = -1, -1, -1 + + // If there's no space, there's no index. + if width < 1 { + return + } + + // Initial states. + regionID := "" + var highlighted bool + + // Go through each line in the buffer. + for bufferIndex, str := range t.buffer { + colorTagIndices, colorTags, regionIndices, regions, escapeIndices, strippedStr, _ := decomposeString(str, t.dynamicColors, t.regions) + + // Split the line if required. + var splitLines []string + str = strippedStr + if t.wrap && len(str) > 0 { + for len(str) > 0 { + extract := runewidth.Truncate(str, width, "") + if t.wordWrap && len(extract) < len(str) { + // Add any spaces from the next line. + if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 { + extract = str[:len(extract)+spaces[1]] + } + + // Can we split before the mandatory end? + matches := boundaryPattern.FindAllStringIndex(extract, -1) + if len(matches) > 0 { + // Yes. Let's split there. + extract = extract[:matches[len(matches)-1][1]] + } + } + splitLines = append(splitLines, extract) + str = str[len(extract):] + } + } else { + // No need to split the line. + splitLines = []string{str} + } + + // Create index from split lines. + var ( + originalPos, colorPos, regionPos, escapePos int + foregroundColor, backgroundColor, attributes string + ) + for _, splitLine := range splitLines { + line := &textViewIndex{ + Line: bufferIndex, + Pos: originalPos, + ForegroundColor: foregroundColor, + BackgroundColor: backgroundColor, + Attributes: attributes, + Region: regionID, + } + + // Shift original position with tags. + lineLength := len(splitLine) + remainingLength := lineLength + tagEnd := originalPos + totalTagLength := 0 + for { + // Which tag comes next? + nextTag := make([][3]int, 0, 3) + if colorPos < len(colorTagIndices) { + nextTag = append(nextTag, [3]int{colorTagIndices[colorPos][0], colorTagIndices[colorPos][1], 0}) // 0 = color tag. + } + if regionPos < len(regionIndices) { + nextTag = append(nextTag, [3]int{regionIndices[regionPos][0], regionIndices[regionPos][1], 1}) // 1 = region tag. + } + if escapePos < len(escapeIndices) { + nextTag = append(nextTag, [3]int{escapeIndices[escapePos][0], escapeIndices[escapePos][1], 2}) // 2 = escape tag. + } + minPos := -1 + tagIndex := -1 + for index, pair := range nextTag { + if minPos < 0 || pair[0] < minPos { + minPos = pair[0] + tagIndex = index + } + } + + // Is the next tag in range? + if tagIndex < 0 || minPos >= tagEnd+remainingLength { + break // No. We're done with this line. + } + + // Advance. + strippedTagStart := nextTag[tagIndex][0] - originalPos - totalTagLength + tagEnd = nextTag[tagIndex][1] + tagLength := tagEnd - nextTag[tagIndex][0] + if nextTag[tagIndex][2] == 2 { + tagLength = 1 + } + totalTagLength += tagLength + remainingLength = lineLength - (tagEnd - originalPos - totalTagLength) + + // Process the tag. + switch nextTag[tagIndex][2] { + case 0: + // Process color tags. + foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) + colorPos++ + case 1: + // Process region tags. + regionID = regions[regionPos][1] + _, highlighted = t.highlights[regionID] + + // Update highlight range. + if highlighted { + line := len(t.index) + if t.fromHighlight < 0 { + t.fromHighlight, t.toHighlight = line, line + t.posHighlight = stringWidth(splitLine[:strippedTagStart]) + } else if line > t.toHighlight { + t.toHighlight = line + } + } + + regionPos++ + case 2: + // Process escape tags. + escapePos++ + } + } + + // Advance to next line. + originalPos += lineLength + totalTagLength + + // Append this line. + line.NextPos = originalPos + line.Width = stringWidth(splitLine) + t.index = append(t.index, line) + } + + // Word-wrapped lines may have trailing whitespace. Remove it. + if t.wrap && t.wordWrap { + for _, line := range t.index { + str := t.buffer[line.Line][line.Pos:line.NextPos] + spaces := spacePattern.FindAllStringIndex(str, -1) + if spaces != nil && spaces[len(spaces)-1][1] == len(str) { + oldNextPos := line.NextPos + line.NextPos -= spaces[len(spaces)-1][1] - spaces[len(spaces)-1][0] + line.Width -= stringWidth(t.buffer[line.Line][line.NextPos:oldNextPos]) + } + } + } + } + + // Calculate longest line. + t.longestLine = 0 + for _, line := range t.index { + if line.Width > t.longestLine { + t.longestLine = line.Width + } + } +} + +// Draw draws this primitive onto the screen. +func (t *TextView) Draw(screen tcell.Screen) { + t.Lock() + defer t.Unlock() + t.Box.Draw(screen) + + // Get the available size. + x, y, width, height := t.GetInnerRect() + t.pageSize = height + + // If the width has changed, we need to reindex. + if width != t.lastWidth && t.wrap { + t.index = nil + } + t.lastWidth = width + + // Re-index. + t.reindexBuffer(width) + + // If we don't have an index, there's nothing to draw. + if t.index == nil { + return + } + + // Move to highlighted regions. + if t.regions && t.scrollToHighlights && t.fromHighlight >= 0 { + // Do we fit the entire height? + if t.toHighlight-t.fromHighlight+1 < height { + // Yes, let's center the highlights. + t.lineOffset = (t.fromHighlight + t.toHighlight - height) / 2 + } else { + // No, let's move to the start of the highlights. + t.lineOffset = t.fromHighlight + } + + // If the highlight is too far to the right, move it to the middle. + if t.posHighlight-t.columnOffset > 3*width/4 { + t.columnOffset = t.posHighlight - width/2 + } + + // If the highlight is off-screen on the left, move it on-screen. + if t.posHighlight-t.columnOffset < 0 { + t.columnOffset = t.posHighlight - width/4 + } + } + t.scrollToHighlights = false + + // Adjust line offset. + if t.lineOffset+height > len(t.index) { + t.trackEnd = true + } + if t.trackEnd { + t.lineOffset = len(t.index) - height + } + if t.lineOffset < 0 { + t.lineOffset = 0 + } + + // Adjust column offset. + if t.align == AlignLeft { + if t.columnOffset+width > t.longestLine { + t.columnOffset = t.longestLine - width + } + if t.columnOffset < 0 { + t.columnOffset = 0 + } + } else if t.align == AlignRight { + if t.columnOffset-width < -t.longestLine { + t.columnOffset = width - t.longestLine + } + if t.columnOffset > 0 { + t.columnOffset = 0 + } + } else { // AlignCenter. + half := (t.longestLine - width) / 2 + if half > 0 { + if t.columnOffset > half { + t.columnOffset = half + } + if t.columnOffset < -half { + t.columnOffset = -half + } + } else { + t.columnOffset = 0 + } + } + + // Draw the buffer. + defaultStyle := tcell.StyleDefault.Foreground(t.textColor) + for line := t.lineOffset; line < len(t.index); line++ { + // Are we done? + if line-t.lineOffset >= height { + break + } + + // Get the text for this line. + index := t.index[line] + text := t.buffer[index.Line][index.Pos:index.NextPos] + foregroundColor := index.ForegroundColor + backgroundColor := index.BackgroundColor + attributes := index.Attributes + regionID := index.Region + + // Process tags. + colorTagIndices, colorTags, regionIndices, regions, escapeIndices, strippedText, _ := decomposeString(text, t.dynamicColors, t.regions) + + // Calculate the position of the line. + var skip, posX int + if t.align == AlignLeft { + posX = -t.columnOffset + } else if t.align == AlignRight { + posX = width - index.Width - t.columnOffset + } else { // AlignCenter. + posX = (width-index.Width)/2 - t.columnOffset + } + if posX < 0 { + skip = -posX + posX = 0 + } + + // Print the line. + var colorPos, regionPos, escapePos, tagOffset, skipped int + iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { + // Process tags. + for { + if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { + // Get the color. + foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) + tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] + colorPos++ + } else if regionPos < len(regionIndices) && textPos+tagOffset >= regionIndices[regionPos][0] && textPos+tagOffset < regionIndices[regionPos][1] { + // Get the region. + regionID = regions[regionPos][1] + tagOffset += regionIndices[regionPos][1] - regionIndices[regionPos][0] + regionPos++ + } else { + break + } + } + + // Skip the second-to-last character of an escape tag. + if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { + tagOffset++ + escapePos++ + } + + // Mix the existing style with the new style. + _, _, existingStyle, _ := screen.GetContent(x+posX, y+line-t.lineOffset) + _, background, _ := existingStyle.Decompose() + style := overlayStyle(background, defaultStyle, foregroundColor, backgroundColor, attributes) + + // Do we highlight this character? + var highlighted bool + if len(regionID) > 0 { + if _, ok := t.highlights[regionID]; ok { + highlighted = true + } + } + if highlighted { + fg, bg, _ := style.Decompose() + if bg == tcell.ColorDefault { + r, g, b := fg.RGB() + c := colorful.Color{R: float64(r) / 255, G: float64(g) / 255, B: float64(b) / 255} + _, _, li := c.Hcl() + if li < .5 { + bg = tcell.ColorWhite + } else { + bg = tcell.ColorBlack + } + } + style = style.Background(fg).Foreground(bg) + } + + // Skip to the right. + if !t.wrap && skipped < skip { + skipped += screenWidth + return false + } + + // Stop at the right border. + if posX+screenWidth > width { + return true + } + + // Draw the character. + for offset := screenWidth - 1; offset >= 0; offset-- { + if offset == 0 { + screen.SetContent(x+posX+offset, y+line-t.lineOffset, main, comb, style) + } else { + screen.SetContent(x+posX+offset, y+line-t.lineOffset, ' ', nil, style) + } + } + + // Advance. + posX += screenWidth + return false + }) + } + + // If this view is not scrollable, we'll purge the buffer of lines that have + // scrolled out of view. + if !t.scrollable && t.lineOffset > 0 { + if t.lineOffset <= len(t.index) { + t.buffer = nil + } else { + t.buffer = t.buffer[t.index[t.lineOffset].Line:] + } + t.index = nil + t.lineOffset = 0 + } +} + +// InputHandler returns the handler for this primitive. +func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + key := event.Key() + + if key == tcell.KeyEscape || key == tcell.KeyEnter || key == tcell.KeyTab || key == tcell.KeyBacktab { + if t.done != nil { + t.done(key) + } + return + } + + if !t.scrollable { + return + } + + switch key { + case tcell.KeyRune: + switch event.Rune() { + case 'g': // Home. + t.trackEnd = false + t.lineOffset = 0 + t.columnOffset = 0 + case 'G': // End. + t.trackEnd = true + t.columnOffset = 0 + case 'j': // Down. + t.lineOffset++ + case 'k': // Up. + t.trackEnd = false + t.lineOffset-- + case 'h': // Left. + t.columnOffset-- + case 'l': // Right. + t.columnOffset++ + } + case tcell.KeyHome: + t.trackEnd = false + t.lineOffset = 0 + t.columnOffset = 0 + case tcell.KeyEnd: + t.trackEnd = true + t.columnOffset = 0 + case tcell.KeyUp: + t.trackEnd = false + t.lineOffset-- + case tcell.KeyDown: + t.lineOffset++ + case tcell.KeyLeft: + t.columnOffset-- + case tcell.KeyRight: + t.columnOffset++ + case tcell.KeyPgDn, tcell.KeyCtrlF: + t.lineOffset += t.pageSize + case tcell.KeyPgUp, tcell.KeyCtrlB: + t.trackEnd = false + t.lineOffset -= t.pageSize + } + }) +} + +// styleFromTag takes the given style, defined by a foreground color (fgColor), +// a background color (bgColor), and style attributes, and modifies it based on +// the substrings (tagSubstrings) extracted by the regular expression for color +// tags. The new colors and attributes are returned where empty strings mean +// "don't modify" and a dash ("-") means "reset to default". +func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings []string) (newFgColor, newBgColor, newAttributes string) { + if tagSubstrings[colorForegroundPos] != "" { + color := tagSubstrings[colorForegroundPos] + if color == "-" { + fgColor = "-" + } else if color != "" { + fgColor = color + } + } + + if tagSubstrings[colorBackgroundPos-1] != "" { + color := tagSubstrings[colorBackgroundPos] + if color == "-" { + bgColor = "-" + } else if color != "" { + bgColor = color + } + } + + if tagSubstrings[colorFlagPos-1] != "" { + flags := tagSubstrings[colorFlagPos] + if flags == "-" { + attributes = "-" + } else if flags != "" { + attributes = flags + } + } + + return fgColor, bgColor, attributes +} + +// overlayStyle mixes a background color with a foreground color (fgColor), +// a (possibly new) background color (bgColor), and style attributes, and +// returns the resulting style. For a definition of the colors and attributes, +// see styleFromTag(). Reset instructions cause the corresponding part of the +// default style to be used. +func overlayStyle(background tcell.Color, defaultStyle tcell.Style, fgColor, bgColor, attributes string) tcell.Style { + defFg, defBg, defAttr := defaultStyle.Decompose() + style := defaultStyle.Background(background) + + style = style.Foreground(defFg) + if fgColor != "" { + style = style.Foreground(tcell.GetColor(fgColor)) + } + + if bgColor == "-" || bgColor == "" && defBg != tcell.ColorDefault { + style = style.Background(defBg) + } else if bgColor != "" { + style = style.Background(tcell.GetColor(bgColor)) + } + + if attributes == "-" { + style = style.Bold(defAttr&tcell.AttrBold > 0) + style = style.Blink(defAttr&tcell.AttrBlink > 0) + style = style.Reverse(defAttr&tcell.AttrReverse > 0) + style = style.Underline(defAttr&tcell.AttrUnderline > 0) + style = style.Dim(defAttr&tcell.AttrDim > 0) + } else if attributes != "" { + // style = style.Normal() + for _, flag := range attributes { + switch flag { + case 'l': + style = style.Blink(true) + case 'L': + style = style.Blink(false) + case 'b': + style = style.Bold(true) + case 'B': + style = style.Bold(false) + case 'd': + style = style.Dim(true) + case 'D': + style = style.Dim(false) + case 'r': + style = style.Reverse(true) + case 'R': + style = style.Reverse(false) + case 'u': + style = style.Underline(true) + case 'U': + style = style.Underline(false) + } + } + } + + return style +} + +// decomposeString returns information about a string which may contain color +// tags or region tags, depending on which ones are requested to be found. It +// returns the indices of the color tags (as returned by +// re.FindAllStringIndex()), the color tags themselves (as returned by +// re.FindAllStringSubmatch()), the indices of region tags and the region tags +// themselves, the indices of an escaped tags (only if at least color tags or +// region tags are requested), the string stripped by any tags and escaped, and +// the screen width of the stripped string. +func decomposeString(text string, findColors, findRegions bool) (colorIndices [][]int, colors [][]string, regionIndices [][]int, regions [][]string, escapeIndices [][]int, stripped string, width int) { + // Shortcut for the trivial case. + if !findColors && !findRegions { + return nil, nil, nil, nil, nil, text, stringWidth(text) + } + + // Get positions of any tags. + if findColors { + colorIndices = colorPattern.FindAllStringIndex(text, -1) + colors = colorPattern.FindAllStringSubmatch(text, -1) + } + if findRegions { + regionIndices = regionPattern.FindAllStringIndex(text, -1) + regions = regionPattern.FindAllStringSubmatch(text, -1) + } + escapeIndices = escapePattern.FindAllStringIndex(text, -1) + + // Because the color pattern detects empty tags, we need to filter them out. + for i := len(colorIndices) - 1; i >= 0; i-- { + if colorIndices[i][1]-colorIndices[i][0] == 2 { + colorIndices = append(colorIndices[:i], colorIndices[i+1:]...) + colors = append(colors[:i], colors[i+1:]...) + } + } + + // Make a (sorted) list of all tags. + var allIndices [][]int + if findColors && findRegions { + allIndices = colorIndices + allIndices = make([][]int, len(colorIndices)+len(regionIndices)) + copy(allIndices, colorIndices) + copy(allIndices[len(colorIndices):], regionIndices) + sort.Slice(allIndices, func(i int, j int) bool { + return allIndices[i][0] < allIndices[j][0] + }) + } else if findColors { + allIndices = colorIndices + } else { + allIndices = regionIndices + } + + // Remove the tags from the original string. + var from int + buf := make([]byte, 0, len(text)) + for _, indices := range allIndices { + buf = append(buf, []byte(text[from:indices[0]])...) + from = indices[1] + } + buf = append(buf, text[from:]...) + + // Escape string. + stripped = string(escapePattern.ReplaceAll(buf, []byte("[$1$2]"))) + + // Get the width of the stripped string. + width = stringWidth(stripped) + + return +} + +// stringWidth returns the number of horizontal cells needed to print the given +// text. It splits the text into its grapheme clusters, calculates each +// cluster's width, and adds them up to a total. +func stringWidth(text string) (width int) { + g := uniseg.NewGraphemes(text) + for g.Next() { + var chWidth int + for _, r := range g.Runes() { + chWidth = runewidth.RuneWidth(r) + if chWidth > 0 { + break // Our best guess at this point is to use the width of the first non-zero-width rune. + } + } + width += chWidth + } + return +} + +// iterateString iterates through the given string one printed character at a +// time. For each such character, the callback function is called with the +// Unicode code points of the character (the first rune and any combining runes +// which may be nil if there aren't any), the starting position (in bytes) +// within the original string, its length in bytes, the screen position of the +// character, and the screen width of it. The iteration stops if the callback +// returns true. This function returns true if the iteration was stopped before +// the last character. +func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool { + var screenPos int + + gr := uniseg.NewGraphemes(text) + for gr.Next() { + r := gr.Runes() + from, to := gr.Positions() + width := stringWidth(gr.Str()) + var comb []rune + if len(r) > 1 { + comb = r[1:] + } + + if callback(r[0], comb, from, to-from, screenPos, width) { + return true + } + + screenPos += width + } + + return false +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..1885a72 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,129 @@ +package ui + +import ( + "strings" + + "github.com/flw-cn/printer" + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +type UIConfig struct { + AmbiguousWidth string `flag:"|auto|二义性字符宽度"` +} + +type UI struct { + printer.SimplePrinter + + config UIConfig + app *tview.Application + mainWindow *TextView + input chan string +} + +func NewUI(config UIConfig) *UI { + return &UI{ + input: make(chan string, 10), + } +} + +func (ui *UI) Create() { + ui.app = tview.NewApplication() + ui.mainWindow = NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetChangedFunc(func() { + ui.app.Draw() + }) + + ui.SetOutput(ANSIWriter(ui.mainWindow)) + + cmdLine := tview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorBlack). + SetLabelColor(tcell.ColorWhite). + SetLabel("命令: ") + + cmdLine.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + cmd := cmdLine.GetText() + if cmd != "" { + ui.input <- cmd + cmdLine.SetText("") + } + ui.mainWindow.ScrollToEnd() + } + }) + + cmdLine.SetChangedFunc(func(text string) { + if strings.HasPrefix(text, `"`) { + cmdLine.SetLabel("闲聊: "). + SetLabelColor(tcell.ColorLightCyan). + SetFieldTextColor(tcell.ColorLightCyan) + } else if strings.HasPrefix(text, `*`) { + cmdLine.SetLabel("表情: "). + SetLabelColor(tcell.ColorLime). + SetFieldTextColor(tcell.ColorLime) + } else if strings.HasPrefix(text, `'`) { + cmdLine.SetLabel("说话: "). + SetLabelColor(tcell.ColorDarkCyan). + SetFieldTextColor(tcell.ColorDarkCyan) + } else if strings.HasPrefix(text, `;`) { + cmdLine.SetLabel("谣言: "). + SetLabelColor(tcell.ColorPink). + SetFieldTextColor(tcell.ColorPink) + } else { + cmdLine.SetLabel("命令: "). + SetLabelColor(tcell.ColorWhite). + SetFieldTextColor(tcell.ColorLightGrey) + } + }) + + mainFrame := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.mainWindow, 0, 1, false). + AddItem(cmdLine, 1, 1, false) + + ui.app.SetRoot(mainFrame, true). + SetFocus(cmdLine). + SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyCtrlC { + if ui.mainWindow.HasFocus() { + ui.mainWindow.ScrollToEnd() + ui.app.SetFocus(cmdLine) + } else { + cmdLine.SetText("") + } + return nil + } else if event.Key() == tcell.KeyCtrlB { + ui.app.SetFocus(ui.mainWindow) + row, _ := ui.mainWindow.GetScrollOffset() + row -= 10 + if row < 0 { + row = 0 + } + ui.mainWindow.ScrollTo(row, 0) + } else if event.Key() == tcell.KeyCtrlF { + ui.app.SetFocus(ui.mainWindow) + row, _ := ui.mainWindow.GetScrollOffset() + row += 10 + ui.mainWindow.ScrollTo(row, 0) + } + return event + }) +} + +func (ui *UI) Run() { + defer ui.app.Stop() + + if err := ui.app.Run(); err != nil { + panic(err) + } +} + +func (ui *UI) Stop() { + ui.app.Stop() + close(ui.input) +} + +func (ui *UI) Input() <-chan string { + return ui.input +}