commit 562dec1e354872ff4bde9e262e3f8c3f0840b336 Author: Tim Cooper Date: Thu Dec 4 17:08:26 2014 -0400 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..259fb22 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# barnard + +barnard is a terminal-based client for the [Mumble](http://mumble.info) voice +chat software. + +## Requirements + +- [gumble](https://github.com/layeh/gumble/tree/master/gumble) +- [gumble_openal](https://github.com/layeh/gumble/tree/master/gumble_openal) +- [termbox-go](https://github.com/nsf/termbox-go) + +## Author + +Tim Cooper () diff --git a/barnard.go b/barnard.go new file mode 100644 index 0000000..0b51e01 --- /dev/null +++ b/barnard.go @@ -0,0 +1,21 @@ +package barnard + +import ( + "github.com/layeh/barnard/uiterm" + "github.com/layeh/gumble/gumble" + "github.com/layeh/gumble/gumble_openal" +) + +type Barnard struct { + Config gumble.Config + Client *gumble.Client + + Stream *gumble_openal.Stream + + Ui *uiterm.Ui + UiOutput uiterm.Textview + UiInput uiterm.Textbox + UiStatus uiterm.Label + UiTree uiterm.Tree + UiInputStatus uiterm.Label +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..dc1eb1a --- /dev/null +++ b/client.go @@ -0,0 +1,112 @@ +package barnard + +import ( + "fmt" + + "github.com/layeh/gumble/gumble" +) + +func (b *Barnard) OnConnect(e *gumble.ConnectEvent) { + b.Ui.SetActive(uiViewInput) + b.UiTree.Rebuild() + b.Ui.Refresh() + + if b.Client.AudioEncoder().Bitrate() > e.MaximumBitrate { + b.Client.AudioEncoder().SetBitrate(e.MaximumBitrate / 3) + } + + b.UpdateInputStatus(fmt.Sprintf("To: %s", e.Client.Self().Channel().Name())) + b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn().RemoteAddr())) + if e.WelcomeMessage != "" { + b.AddOutputLine(fmt.Sprintf("Welcome message: %s", esc(e.WelcomeMessage))) + } +} + +func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) { + var reason string + switch e.Type { + case gumble.DisconnectError: + reason = "connection error" + case gumble.DisconnectOther: + reason = e.String + case gumble.DisconnectVersion: + reason = "invalid version number" + case gumble.DisconnectUserName: + reason = "invalid user name" + case gumble.DisconnectUserCredentials: + reason = "incorrect user password/certificate" + case gumble.DisconnectServerPassword: + reason = "incorrect server password" + case gumble.DisconnectUsernameInUse: + reason = "user name in use" + case gumble.DisconnectServerFull: + reason = "server full" + case gumble.DisconnectNoCertificate: + reason = "missing certificate" + case gumble.DisconnectAuthenticatorFail: + reason = "authenticator via failed" + } + if reason == "" { + b.AddOutputLine("Disconnected") + } else { + b.AddOutputLine("Disconnected: " + reason) + } + b.UiTree.Rebuild() + b.Ui.Refresh() +} + +func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) { + b.AddOutputMessage(e.Sender, e.Message) +} + +func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { + if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self() { + b.UpdateInputStatus(fmt.Sprintf("To: %s", e.User.Channel().Name())) + } + b.UiTree.Rebuild() + b.Ui.Refresh() +} + +func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) { + b.UiTree.Rebuild() + b.Ui.Refresh() +} + +func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) { + var info string + switch e.Type { + case gumble.PermissionDeniedOther: + info = e.String + case gumble.PermissionDeniedPermission: + info = "insufficient permissions" + case gumble.PermissionDeniedSuperUser: + info = "cannot modify SuperUser" + case gumble.PermissionDeniedInvalidChannelName: + info = "invalid channel name" + case gumble.PermissionDeniedTextTooLong: + info = "text too long" + case gumble.PermissionDeniedTemporaryChannel: + info = "temporary channel" + case gumble.PermissionDeniedMissingCertificate: + info = "missing certificate" + case gumble.PermissionDeniedInvalidUserName: + info = "invalid user name" + case gumble.PermissionDeniedChannelFull: + info = "channel full" + case gumble.PermissionDeniedNestingLimit: + info = "nesting limit" + } + b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info)) +} + +func (b *Barnard) OnUserList(e *gumble.UserListEvent) { +} + +func (b *Barnard) OnAcl(e *gumble.AclEvent) { +} + +func (b *Barnard) OnBanList(e *gumble.BanListEvent) { +} + +func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) { +} diff --git a/cmd/barnard/main.go b/cmd/barnard/main.go new file mode 100644 index 0000000..ba086c5 --- /dev/null +++ b/cmd/barnard/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "os" + + "github.com/layeh/barnard" + "github.com/layeh/barnard/uiterm" + "github.com/layeh/gumble/gumble" + "github.com/layeh/gumble/gumble_openal" +) + +func main() { + // Command line flags + server := flag.String("server", "localhost:64738", "the server to connect to") + username := flag.String("username", "", "the username of the client") + insecure := flag.Bool("insecure", false, "skip server certificate verification") + certificate := flag.String("certificate", "", "PEM encoded certificate and private key") + + flag.Parse() + + // Initialize + b := barnard.Barnard{} + b.Ui = uiterm.New(&b) + + // Gumble + b.Config = gumble.Config{ + Username: *username, + Address: *server, + Listener: &b, + } + if *insecure { + b.Config.TlsConfig.InsecureSkipVerify = true + } + if *certificate != "" { + if cert, err := tls.LoadX509KeyPair(*certificate, *certificate); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } else { + b.Config.TlsConfig.Certificates = []tls.Certificate{cert} + } + } + + b.Client = gumble.NewClient(&b.Config) + // Audio + if stream, err := gumble_openal.New(b.Client); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } else { + b.Config.AudioListener = stream + b.Stream = stream + } + + if err := b.Client.Connect(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + + b.Ui.Run() +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..c38b8b7 --- /dev/null +++ b/ui.go @@ -0,0 +1,175 @@ +package barnard + +import ( + "fmt" + "strings" + "time" + + "github.com/layeh/barnard/uiterm" + "github.com/layeh/gumble/gumble" + "github.com/kennygrant/sanitize" +) + +const ( + uiViewLogo = "logo" + uiViewTop = "top" + uiViewStatus = "status" + uiViewInput = "input" + uiViewInputStatus = "inputstatus" + uiViewOutput = "output" + uiViewTree = "tree" +) + +func esc(str string) string { + return sanitize.HTML(str) +} + +func (b *Barnard) UpdateInputStatus(status string) { + b.UiInputStatus.Text = status + b.UiTree.Rebuild() + b.Ui.Refresh() +} + +func (b *Barnard) AddOutputLine(line string) { + now := time.Now() + b.UiOutput.AddLine(fmt.Sprintf("[%02d:%02d:%02d] %s", now.Hour(), now.Minute(), now.Second(), line)) + b.Ui.Refresh() +} + +func (b *Barnard) AddOutputMessage(sender *gumble.User, message string) { + if sender == nil { + b.AddOutputLine(message) + } else { + b.AddOutputLine(fmt.Sprintf("%s: %s", sender.Name(), strings.TrimSpace(esc(message)))) + } +} + +func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) { + if b.UiStatus.Text == " Tx " { + b.UiStatus.Text = " Idle " + b.UiStatus.Fg = uiterm.ColorBlack + b.UiStatus.Bg = uiterm.ColorWhite + b.Stream.StopSource() + } else { + b.UiStatus.Fg = uiterm.ColorWhite | uiterm.AttrBold + b.UiStatus.Bg = uiterm.ColorRed + b.UiStatus.Text = " Tx " + b.Stream.StartSource() + } + ui.Refresh() +} + +func (b *Barnard) OnQuitPress(ui *uiterm.Ui, key uiterm.Key) { + b.Client.Disconnect() + b.Ui.Close() +} + +func (b *Barnard) OnClearPress(ui *uiterm.Ui, key uiterm.Key) { + b.UiOutput.Clear() + b.Ui.Refresh() +} + +func (b *Barnard) OnScrollOutputUp(ui *uiterm.Ui, key uiterm.Key) { + b.UiOutput.ScrollUp() + b.Ui.Refresh() +} + +func (b *Barnard) OnScrollOutputDown(ui *uiterm.Ui, key uiterm.Key) { + b.UiOutput.ScrollDown() + b.Ui.Refresh() +} + +func (b *Barnard) OnScrollOutputTop(ui *uiterm.Ui, key uiterm.Key) { + b.UiOutput.ScrollTop() + b.Ui.Refresh() +} + +func (b *Barnard) OnScrollOutputBottom(ui *uiterm.Ui, key uiterm.Key) { + b.UiOutput.ScrollBottom() + b.Ui.Refresh() +} + +func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) { + active := b.Ui.Active() + if active == &b.UiInput { + b.Ui.SetActive(uiViewTree) + } else if active == &b.UiTree { + b.Ui.SetActive(uiViewInput) + } +} + +func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) { + if text == "" { + return + } + if b.Client != nil && b.Client.Self() != nil { + b.Client.Self().Channel().Send(text, false) + b.AddOutputMessage(b.Client.Self(), text) + } +} + +func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { + ui.SetView(uiViewLogo, 0, 0, 0, 0, &uiterm.Label{ + Text: " barnard ", + Fg: uiterm.ColorWhite | uiterm.AttrBold, + Bg: uiterm.ColorMagenta, + }) + + ui.SetView(uiViewTop, 0, 0, 0, 0, &uiterm.Label{ + Fg: uiterm.ColorWhite, + Bg: uiterm.ColorBlue, + }) + + b.UiStatus = uiterm.Label{ + Text: " Idle ", + Fg: uiterm.ColorBlack, + Bg: uiterm.ColorWhite, + } + ui.SetView(uiViewStatus, 0, 0, 0, 0, &b.UiStatus) + + b.UiInput = uiterm.Textbox{ + Fg: uiterm.ColorWhite, + Bg: uiterm.ColorBlack, + Input: b.OnTextInput, + } + ui.SetView(uiViewInput, 0, 0, 0, 0, &b.UiInput) + + b.UiInputStatus = uiterm.Label{ + Fg: uiterm.ColorBlack, + Bg: uiterm.ColorWhite, + } + ui.SetView(uiViewInputStatus, 0, 0, 0, 0, &b.UiInputStatus) + + b.UiOutput = uiterm.Textview{ + Fg: uiterm.ColorWhite, + Bg: uiterm.ColorBlack, + } + ui.SetView(uiViewOutput, 0, 0, 0, 0, &b.UiOutput) + + b.UiTree = uiterm.Tree{ + Generator: b.TreeItem, + Listener: b.TreeItemSelect, + Fg: uiterm.ColorWhite, + Bg: uiterm.ColorBlack, + } + ui.SetView(uiViewTree, 0, 0, 0, 0, &b.UiTree) + + b.Ui.AddKeyListener(b.OnFocusPress, uiterm.KeyTab) + b.Ui.AddKeyListener(b.OnVoiceToggle, uiterm.KeyF1) + b.Ui.AddKeyListener(b.OnQuitPress, uiterm.KeyF10) + b.Ui.AddKeyListener(b.OnClearPress, uiterm.KeyCtrlL) + b.Ui.AddKeyListener(b.OnScrollOutputUp, uiterm.KeyPgup) + b.Ui.AddKeyListener(b.OnScrollOutputDown, uiterm.KeyPgdn) + b.Ui.AddKeyListener(b.OnScrollOutputTop, uiterm.KeyHome) + b.Ui.AddKeyListener(b.OnScrollOutputBottom, uiterm.KeyEnd) +} + +func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) { + ui.SetView(uiViewLogo, 0, 0, 9, 1, nil) + ui.SetView(uiViewTop, 9, 0, width-6, 1, nil) + ui.SetView(uiViewStatus, width-6, 0, width, 1, nil) + ui.SetView(uiViewInput, 0, height-1, width, height, nil) + ui.SetView(uiViewInputStatus, 0, height-2, width, height-1, nil) + ui.SetView(uiViewOutput, 0, 1, width-20, height-2, nil) + ui.SetView(uiViewTree, width-20, 1, width, height-2, nil) +} diff --git a/ui_tree.go b/ui_tree.go new file mode 100644 index 0000000..1c35d9c --- /dev/null +++ b/ui_tree.go @@ -0,0 +1,77 @@ +package barnard + +import ( + "github.com/layeh/barnard/uiterm" + "github.com/layeh/gumble/gumble" +) + +type TreeItem struct { + User *gumble.User + Channel *gumble.Channel +} + +func (ti TreeItem) String() string { + if ti.User != nil { + return ti.User.Name() + } + if ti.Channel != nil { + return ti.Channel.Name() + } + return "" +} + +func (ti TreeItem) TreeItemStyle(active bool) (uiterm.Attribute, uiterm.Attribute) { + fg := uiterm.ColorDefault + bg := uiterm.ColorBlack + if ti.Channel != nil { + fg |= uiterm.AttrBold + } + if active { + bg |= uiterm.AttrReverse + } + return fg, bg +} + +func (b *Barnard) TreeItemSelect(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem) { + treeItem := item.(TreeItem) + if treeItem.Channel != nil { + b.Client.Self().Move(treeItem.Channel) + } +} + +func (b *Barnard) TreeItem(item uiterm.TreeItem) []uiterm.TreeItem { + var treeItem TreeItem + if ti, ok := item.(TreeItem); !ok { + root := b.Client.Channels()[0] + if root == nil { + return nil + } + return []uiterm.TreeItem{ + TreeItem{ + Channel: root, + }, + } + } else { + treeItem = ti + } + + if treeItem.User != nil { + return nil + } + + users := []uiterm.TreeItem{} + for _, user := range treeItem.Channel.Users() { + users = append(users, TreeItem{ + User: user, + }) + } + + channels := []uiterm.TreeItem{} + for _, subchannel := range treeItem.Channel.Children() { + channels = append(channels, TreeItem{ + Channel: subchannel, + }) + } + + return append(users, channels...) +} diff --git a/uiterm/attributes.go b/uiterm/attributes.go new file mode 100644 index 0000000..ba1534d --- /dev/null +++ b/uiterm/attributes.go @@ -0,0 +1,25 @@ +package uiterm + +/* + * Source: https://godoc.org/github.com/nsf/termbox-go + */ + +type Attribute int + +const ( + ColorDefault Attribute = iota + ColorBlack + ColorRed + ColorGreen + ColorYellow + ColorBlue + ColorMagenta + ColorCyan + ColorWhite +) + +const ( + AttrBold Attribute = 1 << (iota + 4) + AttrUnderline + AttrReverse +) diff --git a/uiterm/keys.go b/uiterm/keys.go new file mode 100644 index 0000000..a5466b8 --- /dev/null +++ b/uiterm/keys.go @@ -0,0 +1,91 @@ +package uiterm + +/* + * Source: https://godoc.org/github.com/nsf/termbox-go + */ + +type Modifier uint8 + +const ( + ModAlt Modifier = 0x01 +) + +type Key uint16 + +const ( + KeyF1 Key = 0xFFFF - iota + KeyF2 + KeyF3 + KeyF4 + KeyF5 + KeyF6 + KeyF7 + KeyF8 + KeyF9 + KeyF10 + KeyF11 + KeyF12 + KeyInsert + KeyDelete + KeyHome + KeyEnd + KeyPgup + KeyPgdn + KeyArrowUp + KeyArrowDown + KeyArrowLeft + KeyArrowRight + + MouseLeft + MouseMiddle + MouseRight +) + +const ( + KeyCtrlTilde Key = 0x00 + KeyCtrl2 Key = 0x00 + KeyCtrlSpace Key = 0x00 + KeyCtrlA Key = 0x01 + KeyCtrlB Key = 0x02 + KeyCtrlC Key = 0x03 + KeyCtrlD Key = 0x04 + KeyCtrlE Key = 0x05 + KeyCtrlF Key = 0x06 + KeyCtrlG Key = 0x07 + KeyBackspace Key = 0x08 + KeyCtrlH Key = 0x08 + KeyTab Key = 0x09 + KeyCtrlI Key = 0x09 + KeyCtrlJ Key = 0x0A + KeyCtrlK Key = 0x0B + KeyCtrlL Key = 0x0C + KeyEnter Key = 0x0D + KeyCtrlM Key = 0x0D + KeyCtrlN Key = 0x0E + KeyCtrlO Key = 0x0F + KeyCtrlP Key = 0x10 + KeyCtrlQ Key = 0x11 + KeyCtrlR Key = 0x12 + KeyCtrlS Key = 0x13 + KeyCtrlT Key = 0x14 + KeyCtrlU Key = 0x15 + KeyCtrlV Key = 0x16 + KeyCtrlW Key = 0x17 + KeyCtrlX Key = 0x18 + KeyCtrlY Key = 0x19 + KeyCtrlZ Key = 0x1A + KeyEsc Key = 0x1B + KeyCtrlLsqBracket Key = 0x1B + KeyCtrl3 Key = 0x1B + KeyCtrl4 Key = 0x1C + KeyCtrlBackslash Key = 0x1C + KeyCtrl5 Key = 0x1D + KeyCtrlRsqBracket Key = 0x1D + KeyCtrl6 Key = 0x1E + KeyCtrl7 Key = 0x1F + KeyCtrlSlash Key = 0x1F + KeyCtrlUnderscore Key = 0x1F + KeySpace Key = 0x20 + KeyBackspace2 Key = 0x7F + KeyCtrl8 Key = 0x7F +) diff --git a/uiterm/label.go b/uiterm/label.go new file mode 100644 index 0000000..3e772e5 --- /dev/null +++ b/uiterm/label.go @@ -0,0 +1,46 @@ +package uiterm + +import ( + "strings" + + "github.com/nsf/termbox-go" +) + +type Label struct { + Text string + Fg Attribute + Bg Attribute + + x0, y0, x1, y1 int +} + +func (l *Label) SetActive(ui *Ui, active bool) { +} + +func (l *Label) SetBounds(ui *Ui, x0, y0, x1, y1 int) { + l.x0 = x0 + l.y0 = y0 + l.x1 = x1 + l.y1 = y1 +} + +func (l *Label) Draw(ui *Ui) { + reader := strings.NewReader(l.Text) + for y := l.y0; y < l.y1; y++ { + for x := l.x0; x < l.x1; x++ { + var chr rune + if ch, _, err := reader.ReadRune(); err != nil { + chr = ' ' + } else { + chr = ch + } + termbox.SetCell(x, y, chr, termbox.Attribute(l.Fg), termbox.Attribute(l.Bg)) + } + } +} + +func (l *Label) KeyEvent(ui *Ui, mod Modifier, key Key) { +} + +func (l *Label) CharacterEvent(ui *Ui, chr rune) { +} diff --git a/uiterm/textbox.go b/uiterm/textbox.go new file mode 100644 index 0000000..54726cd --- /dev/null +++ b/uiterm/textbox.go @@ -0,0 +1,88 @@ +package uiterm + +import ( + "strings" + "unicode/utf8" + + "github.com/nsf/termbox-go" +) + +type InputFunc func(ui *Ui, textbox *Textbox, text string) + +type Textbox struct { + Text string + Fg Attribute + Bg Attribute + + Input InputFunc + + active bool + x0, y0, x1, y1 int +} + +func (t *Textbox) SetBounds(ui *Ui, x0, y0, x1, y1 int) { + t.x0 = x0 + t.y0 = y0 + t.x1 = x1 + t.y1 = y1 +} + +func (t *Textbox) SetActive(ui *Ui, active bool) { + t.active = active +} + +func (t *Textbox) Draw(ui *Ui) { + var setCursor = false + reader := strings.NewReader(t.Text) + for y := t.y0; y < t.y1; y++ { + for x := t.x0; x < t.x1; x++ { + var chr rune + if ch, _, err := reader.ReadRune(); err != nil { + if t.active && !setCursor { + termbox.SetCursor(x, y) + setCursor = true + } + chr = ' ' + } else { + chr = ch + } + termbox.SetCell(x, y, chr, termbox.Attribute(t.Fg), termbox.Attribute(t.Bg)) + } + } +} + +func (t *Textbox) KeyEvent(ui *Ui, mod Modifier, key Key) { + redraw := false + switch key { + case KeyCtrlC: + t.Text = "" + redraw = true + case KeyEnter: + if t.Input != nil { + t.Input(ui, t, t.Text) + } + t.Text = "" + redraw = true + case KeySpace: + t.Text = t.Text + " " + redraw = true + case KeyBackspace: + case KeyBackspace2: + if len(t.Text) > 0 { + if r, size := utf8.DecodeLastRuneInString(t.Text); r != utf8.RuneError { + t.Text = t.Text[:len(t.Text)-size] + redraw = true + } + } + } + if redraw { + t.Draw(ui) + termbox.Flush() + } +} + +func (t *Textbox) CharacterEvent(ui *Ui, chr rune) { + t.Text = t.Text + string(chr) + t.Draw(ui) + termbox.Flush() +} diff --git a/uiterm/textview.go b/uiterm/textview.go new file mode 100644 index 0000000..cc80fd4 --- /dev/null +++ b/uiterm/textview.go @@ -0,0 +1,141 @@ +package uiterm + +import ( + "strings" + + "github.com/nsf/termbox-go" +) + +type Textview struct { + Lines []string + CurrentLine int + Fg Attribute + Bg Attribute + + parsedLines []string + x0, y0, x1, y1 int +} + +func (t *Textview) SetActive(ui *Ui, active bool) { +} + +func (t *Textview) SetBounds(ui *Ui, x0, y0, x1, y1 int) { + t.x0 = x0 + t.y0 = y0 + t.x1 = x1 + t.y1 = y1 + t.updateParsedLines() +} + +func (t *Textview) ScrollUp() { + if newLine := t.CurrentLine + 1; newLine < len(t.parsedLines) { + t.CurrentLine = newLine + } +} + +func (t *Textview) ScrollDown() { + if newLine := t.CurrentLine - 1; newLine >= 0 { + t.CurrentLine = newLine + } +} + +func (t *Textview) ScrollTop() { + if newLine := len(t.parsedLines) - 1; newLine > 0 { + t.CurrentLine = newLine + } else { + t.CurrentLine = 0 + } +} + +func (t *Textview) ScrollBottom() { + t.CurrentLine = 0 +} + +func (t *Textview) updateParsedLines() { + width := t.x1 - t.x0 - 3 + + if t.Lines == nil || width <= 0 { + t.parsedLines = nil + return + } + + parsed := make([]string, 0, len(t.Lines)) + for _, line := range t.Lines { + current := "" + chars := 0 + reader := strings.NewReader(line) + for { + if chars >= width { + parsed = append(parsed, current) + chars = 0 + current = "" + } + if reader.Len() <= 0 { + if chars > 0 { + parsed = append(parsed, current) + } + break + } + if ch, _, err := reader.ReadRune(); err == nil { + current = current + string(ch) + chars++ + } + } + } + t.parsedLines = parsed +} + +func (t *Textview) AddLine(line string) { + t.Lines = append(t.Lines, line) + t.updateParsedLines() +} + +func (t *Textview) Clear() { + t.Lines = nil + t.CurrentLine = 0 + t.parsedLines = nil +} + +func (t *Textview) Draw(ui *Ui) { + var reader *strings.Reader + line := len(t.parsedLines) - 1 - t.CurrentLine + if line < 0 { + line = 0 + } + totalLines := len(t.parsedLines) + if totalLines == 0 { + totalLines = 1 + } + currentScrollLine := t.y1 - 1 - int((float32(t.CurrentLine)/float32(totalLines))*float32(t.y1-t.y0)) + for y := t.y1 - 1; y >= t.y0; y-- { + if t.parsedLines != nil && line >= 0 { + reader = strings.NewReader(t.parsedLines[line]) + } else { + reader = nil + } + for x := t.x0; x < t.x1; x++ { + var chr rune = ' ' + if x == t.x1-1 { // scrollbar + if y == currentScrollLine { + chr = '█' + } else { + chr = '░' + } + } else if x < t.x1-3 { + if reader != nil { + if ch, _, err := reader.ReadRune(); err == nil { + chr = ch + } + } + } + termbox.SetCell(x, y, chr, termbox.Attribute(t.Fg), termbox.Attribute(t.Bg)) + } + line-- + } +} + +func (t *Textview) KeyEvent(ui *Ui, mod Modifier, key Key) { +} + +func (t *Textview) CharacterEvent(ui *Ui, chr rune) { +} diff --git a/uiterm/tree.go b/uiterm/tree.go new file mode 100644 index 0000000..2c55f0e --- /dev/null +++ b/uiterm/tree.go @@ -0,0 +1,140 @@ +package uiterm + +import ( + "strings" + + "github.com/nsf/termbox-go" +) + +type TreeItem interface { + TreeItemStyle(active bool) (Attribute, Attribute) + String() string +} + +type renderedTreeItem struct { + //String string + Level int + Item TreeItem +} + +type TreeFunc func(item TreeItem) []TreeItem +type TreeListener func(ui *Ui, tree *Tree, item TreeItem) + +type Tree struct { + Fg Attribute + Bg Attribute + Generator TreeFunc + Listener TreeListener + + lines []renderedTreeItem + activeLine int + active bool + x0, y0, x1, y1 int +} + +func bounded(i, lower, upper int) int { + if i < lower { + return lower + } + if i > upper { + return upper + } + return i +} + +func (t *Tree) SetBounds(ui *Ui, x0, y0, x1, y1 int) { + t.x0 = x0 + t.y0 = y0 + t.x1 = x1 + t.y1 = y1 +} + +func (t *Tree) Rebuild() { + if t.Generator == nil { + t.lines = []renderedTreeItem{} + return + } + + lines := []renderedTreeItem{} + for _, item := range t.Generator(nil) { + children := t.rebuild_rec(item, 0) + if children != nil { + lines = append(lines, children...) + } + } + t.lines = lines + t.activeLine = bounded(t.activeLine, 0, len(t.lines)-1) +} + +func (t *Tree) rebuild_rec(parent TreeItem, level int) []renderedTreeItem { + if parent == nil { + return nil + } + lines := []renderedTreeItem{ + renderedTreeItem{ + Level: level, + Item: parent, + }, + } + for _, item := range t.Generator(parent) { + children := t.rebuild_rec(item, level+1) + if children != nil { + lines = append(lines, children...) + } + } + return lines +} + +func (t *Tree) Draw(ui *Ui) { + if t.lines == nil { + t.Rebuild() + } + + line := 0 + for y := t.y0; y < t.y1; y++ { + var reader *strings.Reader + var item TreeItem + level := 0 + if line < len(t.lines) { + item = t.lines[line].Item + level = t.lines[line].Level + reader = strings.NewReader(item.String()) + } + for x := t.x0; x < t.x1; x++ { + var chr rune = ' ' + fg := t.Fg + bg := t.Bg + dx := x - t.x0 + dy := y - t.y0 + if reader != nil && level*2 <= dx { + if ch, _, err := reader.ReadRune(); err == nil { + chr = ch + fg, bg = item.TreeItemStyle(t.active && t.activeLine == dy) + } + } + termbox.SetCell(x, y, chr, termbox.Attribute(fg), termbox.Attribute(bg)) + } + line++ + } +} + +func (t *Tree) SetActive(ui *Ui, active bool) { + t.active = active +} + +func (t *Tree) KeyEvent(ui *Ui, mod Modifier, key Key) { + switch key { + case KeyArrowUp: + t.activeLine = bounded(t.activeLine-1, 0, len(t.lines)-1) + case KeyArrowDown: + t.activeLine = bounded(t.activeLine+1, 0, len(t.lines)-1) + case KeyEnter: + if t.Listener != nil && t.activeLine >= 0 && t.activeLine < len(t.lines) { + t.Listener(ui, t, t.lines[t.activeLine].Item) + } + } + ui.Refresh() +} + +func (t *Tree) CharacterEvent(ui *Ui, ch rune) { +} diff --git a/uiterm/ui.go b/uiterm/ui.go new file mode 100644 index 0000000..9a47132 --- /dev/null +++ b/uiterm/ui.go @@ -0,0 +1,170 @@ +package uiterm + +import ( + "github.com/nsf/termbox-go" +) + +type LayoutFunc func(ui *Ui, width, height int) + +type KeyListener func(ui *Ui, key Key) + +type UiManager interface { + OnUiInitialize(ui *Ui) + OnUiResize(ui *Ui, width, height int) +} + +type Ui struct { + close chan bool + manager UiManager + + elements map[string]*uiElement + activeElement *uiElement + + keyListeners map[Key][]KeyListener + + fg Attribute + bg Attribute +} + +type uiElement struct { + X0, Y0, X1, Y1 int + View View +} + +func New(manager UiManager) *Ui { + ui := &Ui{ + close: make(chan bool, 10), + elements: make(map[string]*uiElement), + manager: manager, + keyListeners: make(map[Key][]KeyListener), + } + return ui +} + +func (ui *Ui) Close() { + if termbox.IsInit { + ui.close <- true + } +} + +func (ui *Ui) Refresh() { + if termbox.IsInit { + termbox.Clear(termbox.Attribute(ui.fg), termbox.Attribute(ui.bg)) + termbox.HideCursor() + for _, element := range ui.elements { + element.View.Draw(ui) + } + termbox.Flush() + } +} + +func (ui *Ui) Active() View { + return ui.activeElement.View +} + +func (ui *Ui) SetActive(name string) { + element, _ := ui.elements[name] + if ui.activeElement != nil { + ui.activeElement.View.SetActive(ui, false) + } + ui.activeElement = element + if element != nil { + element.View.SetActive(ui, true) + } + ui.Refresh() +} + +func (ui *Ui) SetClear(fg, bg Attribute) { + ui.fg = fg + ui.bg = bg +} + +func (ui *Ui) Run() error { + if termbox.IsInit { + return nil + } + if err := termbox.Init(); err != nil { + return nil + } + defer termbox.Close() + termbox.SetInputMode(termbox.InputAlt) + + events := make(chan termbox.Event) + go func() { + for { + events <- termbox.PollEvent() + } + }() + + ui.manager.OnUiInitialize(ui) + width, height := termbox.Size() + ui.manager.OnUiResize(ui, width, height) + ui.Refresh() + + for { + select { + case <-ui.close: + return nil + case event := <-events: + switch event.Type { + case termbox.EventResize: + ui.manager.OnUiResize(ui, event.Width, event.Height) + ui.Refresh() + case termbox.EventKey: + if event.Ch != 0 { + ui.onCharacterEvent(event.Ch) + } else { + ui.onKeyEvent(Modifier(event.Mod), Key(event.Key)) + } + } + } + } +} + +func (ui *Ui) onCharacterEvent(ch rune) { + if ui.activeElement != nil { + ui.activeElement.View.CharacterEvent(ui, ch) + } +} + +func (ui *Ui) onKeyEvent(mod Modifier, key Key) { + if ui.keyListeners[key] != nil { + for _, listener := range ui.keyListeners[key] { + listener(ui, key) + } + } + if ui.activeElement != nil { + ui.activeElement.View.KeyEvent(ui, mod, key) + } +} + +func (ui *Ui) SetView(name string, x0, y0, x1, y1 int, view View) { + if element, ok := ui.elements[name]; ok { + element.X0 = x0 + element.Y0 = y0 + element.X1 = x1 + element.Y1 = y1 + view = element.View + } else { + ui.elements[name] = &uiElement{ + X0: x0, + Y0: y0, + X1: x1, + Y1: y1, + View: view, + } + } + view.SetBounds(ui, x0, y0, x1, y1) +} + +func (ui *Ui) View(name string) View { + if element, ok := ui.elements[name]; !ok { + return nil + } else { + return element.View + } +} + +func (ui *Ui) AddKeyListener(listener KeyListener, key Key) { + ui.keyListeners[key] = append(ui.keyListeners[key], listener) +} diff --git a/uiterm/view.go b/uiterm/view.go new file mode 100644 index 0000000..61e9070 --- /dev/null +++ b/uiterm/view.go @@ -0,0 +1,9 @@ +package uiterm + +type View interface { + SetBounds(ui *Ui, x0, y0, x1, y1 int) + Draw(ui *Ui) + SetActive(ui *Ui, active bool) + KeyEvent(ui *Ui, mod Modifier, key Key) + CharacterEvent(ui *Ui, ch rune) +}