From 72945c9a284b6858594f1e8a43743c397e90c380 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Mar 2018 21:24:03 +0200 Subject: Organize files --- ui/widget/advanced-inputfield.go | 445 +++++++++++++++++++++++++++++++++++++++ ui/widget/border.go | 44 ++++ ui/widget/center.go | 32 +++ ui/widget/color.go | 60 ++++++ ui/widget/form-text-view.go | 44 ++++ ui/widget/message-view.go | 293 ++++++++++++++++++++++++++ ui/widget/room-view.go | 152 +++++++++++++ 7 files changed, 1070 insertions(+) create mode 100644 ui/widget/advanced-inputfield.go create mode 100644 ui/widget/border.go create mode 100644 ui/widget/center.go create mode 100644 ui/widget/color.go create mode 100644 ui/widget/form-text-view.go create mode 100644 ui/widget/message-view.go create mode 100644 ui/widget/room-view.go (limited to 'ui/widget') diff --git a/ui/widget/advanced-inputfield.go b/ui/widget/advanced-inputfield.go new file mode 100644 index 0000000..6928c27 --- /dev/null +++ b/ui/widget/advanced-inputfield.go @@ -0,0 +1,445 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "math" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "github.com/zyedidia/clipboard" + "maunium.net/go/tview" +) + +// AdvancedInputField is a multi-line user-editable text area. +// +// Use SetMaskCharacter() to hide input from onlookers (e.g. for password +// input). +type AdvancedInputField struct { + *tview.Box + + // Cursor position + cursorOffset int + viewOffset int + + // The text that was entered. + text string + + // The text to be displayed before the input area. + label string + + // The text to be displayed in the input area when "text" is empty. + placeholder string + + // The label color. + labelColor tcell.Color + + // The background color of the input area. + fieldBackgroundColor tcell.Color + + // The text color of the input area. + fieldTextColor tcell.Color + + // The text color of the placeholder. + placeholderTextColor tcell.Color + + // The screen width of the input area. A value of 0 means extend as much as + // possible. + fieldWidth int + + // A character to mask entered text (useful for password fields). A value of 0 + // disables masking. + maskCharacter rune + + // An optional function which may reject the last character that was entered. + accept func(text string, ch rune) bool + + // An optional function which is called when the input has changed. + changed func(text string) + + // An optional function which is called when the user indicated that they + // are done entering text. The key which was pressed is provided (enter or escape). + done func(tcell.Key) + + // An optional function which is called when the user presses tab. + tabComplete func(text string, cursorOffset int) string +} + +// NewAdvancedInputField returns a new input field. +func NewAdvancedInputField() *AdvancedInputField { + return &AdvancedInputField{ + Box: tview.NewBox(), + labelColor: tview.Styles.SecondaryTextColor, + fieldBackgroundColor: tview.Styles.ContrastBackgroundColor, + fieldTextColor: tview.Styles.PrimaryTextColor, + placeholderTextColor: tview.Styles.ContrastSecondaryTextColor, + } +} + +// SetText sets the current text of the input field. +func (field *AdvancedInputField) SetText(text string) *AdvancedInputField { + field.text = text + if field.changed != nil { + field.changed(text) + } + return field +} + +// GetText returns the current text of the input field. +func (field *AdvancedInputField) GetText() string { + return field.text +} + +// SetLabel sets the text to be displayed before the input area. +func (field *AdvancedInputField) SetLabel(label string) *AdvancedInputField { + field.label = label + return field +} + +// GetLabel returns the text to be displayed before the input area. +func (field *AdvancedInputField) GetLabel() string { + return field.label +} + +// SetPlaceholder sets the text to be displayed when the input text is empty. +func (field *AdvancedInputField) SetPlaceholder(text string) *AdvancedInputField { + field.placeholder = text + return field +} + +// SetLabelColor sets the color of the label. +func (field *AdvancedInputField) SetLabelColor(color tcell.Color) *AdvancedInputField { + field.labelColor = color + return field +} + +// SetFieldBackgroundColor sets the background color of the input area. +func (field *AdvancedInputField) SetFieldBackgroundColor(color tcell.Color) *AdvancedInputField { + field.fieldBackgroundColor = color + return field +} + +// SetFieldTextColor sets the text color of the input area. +func (field *AdvancedInputField) SetFieldTextColor(color tcell.Color) *AdvancedInputField { + field.fieldTextColor = color + return field +} + +// SetPlaceholderExtColor sets the text color of placeholder text. +func (field *AdvancedInputField) SetPlaceholderExtColor(color tcell.Color) *AdvancedInputField { + field.placeholderTextColor = color + return field +} + +// SetFormAttributes sets attributes shared by all form items. +func (field *AdvancedInputField) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + field.label = label + field.labelColor = labelColor + field.SetBackgroundColor(bgColor) + field.fieldTextColor = fieldTextColor + field.fieldBackgroundColor = fieldBgColor + return field +} + +// SetFieldWidth sets the screen width of the input area. A value of 0 means +// extend as much as possible. +func (field *AdvancedInputField) SetFieldWidth(width int) *AdvancedInputField { + field.fieldWidth = width + return field +} + +// GetFieldWidth returns this primitive's field width. +func (field *AdvancedInputField) GetFieldWidth() int { + return field.fieldWidth +} + +// SetMaskCharacter sets a character that masks user input on a screen. A value +// of 0 disables masking. +func (field *AdvancedInputField) SetMaskCharacter(mask rune) *AdvancedInputField { + field.maskCharacter = mask + return field +} + +// SetAcceptanceFunc sets a handler which may reject the last character that was +// entered (by returning false). +// +// This package defines a number of variables Prefixed with AdvancedInputField which may +// be used for common input (e.g. numbers, maximum text length). +func (field *AdvancedInputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *AdvancedInputField { + field.accept = handler + return field +} + +// SetChangedFunc sets a handler which is called whenever the text of the input +// field has changed. It receives the current text (after the change). +func (field *AdvancedInputField) SetChangedFunc(handler func(text string)) *AdvancedInputField { + field.changed = handler + return field +} + +// SetDoneFunc sets a handler which is called when the user is done entering +// text. The callback function is provided with the key that was pressed, which +// is one of the following: +// +// - KeyEnter: Done entering text. +// - KeyEscape: Abort text input. +func (field *AdvancedInputField) SetDoneFunc(handler func(key tcell.Key)) *AdvancedInputField { + field.done = handler + return field +} + +func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int) string) *AdvancedInputField { + field.tabComplete = handler + return field +} + +// SetFinishedFunc calls SetDoneFunc(). +func (field *AdvancedInputField) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { + return field.SetDoneFunc(handler) +} + +// Draw draws this primitive onto the screen. +func (field *AdvancedInputField) Draw(screen tcell.Screen) { + field.Box.Draw(screen) + + // Prepare + x, y, width, height := field.GetInnerRect() + rightLimit := x + width + if height < 1 || rightLimit <= x { + return + } + + // Draw label. + _, drawnWidth := tview.Print(screen, field.label, x, y, rightLimit-x, tview.AlignLeft, field.labelColor) + x += drawnWidth + + // Draw input area. + fieldWidth := field.fieldWidth + if fieldWidth == 0 { + fieldWidth = math.MaxInt32 + } + if rightLimit-x < fieldWidth { + fieldWidth = rightLimit - x + } + fieldStyle := tcell.StyleDefault.Background(field.fieldBackgroundColor) + for index := 0; index < fieldWidth; index++ { + screen.SetContent(x+index, y, ' ', nil, fieldStyle) + } + + text := field.text + if text == "" && field.placeholder != "" { + tview.Print(screen, field.placeholder, x, y, fieldWidth, tview.AlignLeft, field.placeholderTextColor) + } + + // Draw entered text. + if field.maskCharacter > 0 { + text = strings.Repeat(string(field.maskCharacter), utf8.RuneCountInString(field.text)) + } + textWidth := runewidth.StringWidth(text) + if field.cursorOffset >= textWidth { + fieldWidth-- + } + + // Recalculate view offset + if field.cursorOffset < field.viewOffset { + field.viewOffset = field.cursorOffset + } else if field.cursorOffset > field.viewOffset+fieldWidth { + field.viewOffset = field.cursorOffset - fieldWidth + } else if textWidth-field.viewOffset < fieldWidth { + field.viewOffset = textWidth - fieldWidth + } + // Make sure view offset didn't become negative + if field.viewOffset < 0 { + field.viewOffset = 0 + } + + // Draw entered text. + runes := []rune(text) + relPos := 0 + for pos := field.viewOffset; pos <= fieldWidth+field.viewOffset && pos < len(runes); pos++ { + ch := runes[pos] + w := runewidth.RuneWidth(ch) + _, _, style, _ := screen.GetContent(x+relPos, y) + style = style.Foreground(field.fieldTextColor) + for w > 0 { + screen.SetContent(x+relPos, y, ch, nil, style) + relPos++ + w-- + } + } + + // Set cursor. + if field.GetFocusable().HasFocus() { + field.setCursor(screen) + } +} + +func (field *AdvancedInputField) GetCursorOffset() int { + return field.cursorOffset +} + +func (field *AdvancedInputField) SetCursorOffset(offset int) *AdvancedInputField { + if offset < 0 { + offset = 0 + } else { + width := runewidth.StringWidth(field.text) + if offset >= width { + offset = width + } + } + field.cursorOffset = offset + return field +} + +// setCursor sets the cursor position. +func (field *AdvancedInputField) setCursor(screen tcell.Screen) { + x, y, width, _ := field.GetRect() + origX, origY := x, y + rightLimit := x + width + if field.HasBorder() { + x++ + y++ + rightLimit -= 2 + } + fieldWidth := runewidth.StringWidth(field.text) + if field.fieldWidth > 0 && fieldWidth > field.fieldWidth-1 { + fieldWidth = field.fieldWidth - 1 + } + x = x + tview.StringWidth(field.label) + field.cursorOffset - field.viewOffset + if x >= rightLimit { + x = rightLimit - 1 + } else if x < origX { + x = origY + } + screen.ShowCursor(x, y) +} + +var ( + lastWord = regexp.MustCompile(`\S+\s*$`) + firstWord = regexp.MustCompile(`^\s*\S+`) +) + +func SubstringBefore(s string, w int) string { + return runewidth.Truncate(s, w, "") +} + +// InputHandler returns the handler for this primitive. +func (field *AdvancedInputField) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return field.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + currentText := field.text + defer func() { + // Trigger changed events. + if field.text != currentText && field.changed != nil { + field.changed(field.text) + } + // Make sure cursor offset is valid + if field.cursorOffset < 0 { + field.cursorOffset = 0 + } + width := runewidth.StringWidth(field.text) + if field.cursorOffset > width { + field.cursorOffset = width + } + }() + + // Process key event. + switch key := event.Key(); key { + case tcell.KeyRune: // Regular character. + leftPart := SubstringBefore(field.text, field.cursorOffset) + newText := leftPart + string(event.Rune()) + field.text[len(leftPart):] + if field.accept != nil { + if !field.accept(newText, event.Rune()) { + break + } + } + field.text = newText + field.cursorOffset += runewidth.RuneWidth(event.Rune()) + case tcell.KeyCtrlV: + clip, _ := clipboard.ReadAll("clipboard") + leftPart := SubstringBefore(field.text, field.cursorOffset) + field.text = leftPart + clip + field.text[len(leftPart):] + field.cursorOffset += runewidth.StringWidth(clip) + case tcell.KeyLeft: // Move cursor left. + before := SubstringBefore(field.text, field.cursorOffset) + if event.Modifiers() == tcell.ModCtrl { + found := lastWord.FindString(before) + field.cursorOffset -= runewidth.StringWidth(found) + } else if len(before) > 0 { + beforeRunes := []rune(before) + char := beforeRunes[len(beforeRunes)-1] + field.cursorOffset -= runewidth.RuneWidth(char) + } + case tcell.KeyRight: // Move cursor right. + before := SubstringBefore(field.text, field.cursorOffset) + after := field.text[len(before):] + if event.Modifiers() == tcell.ModCtrl { + found := firstWord.FindString(after) + field.cursorOffset += runewidth.StringWidth(found) + } else if len(after) > 0 { + char := []rune(after)[0] + field.cursorOffset += runewidth.RuneWidth(char) + } + case tcell.KeyDelete: // Delete next character. + if field.cursorOffset >= runewidth.StringWidth(field.text) { + break + } + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + rightPartRunes := []rune(rightPart) + rightPartRunes = rightPartRunes[1:] + rightPart = string(rightPartRunes) + field.text = leftPart + rightPart + case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete last character. + if field.cursorOffset == 0 { + break + } + if key == tcell.KeyBackspace { // Ctrl+backspace + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + replacement := lastWord.ReplaceAllString(leftPart, "") + field.text = replacement + rightPart + + field.cursorOffset -= runewidth.StringWidth(leftPart) - runewidth.StringWidth(replacement) + } else { // Just backspace + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + leftPartRunes := []rune(leftPart) + leftPartRunes = leftPartRunes[0 : len(leftPartRunes)-1] + leftPart = string(leftPartRunes) + removedChar := field.text[len(leftPart) : len(field.text)-len(rightPart)] + field.text = leftPart + rightPart + field.cursorOffset -= runewidth.StringWidth(removedChar) + } + case tcell.KeyTab: // Tab-completion + if field.tabComplete != nil { + oldWidth := runewidth.StringWidth(field.text) + field.text = field.tabComplete(field.text, field.cursorOffset) + newWidth := runewidth.StringWidth(field.text) + if oldWidth != newWidth { + field.cursorOffset += newWidth - oldWidth + } + } + case tcell.KeyEnter, tcell.KeyEscape: // We're done. + if field.done != nil { + field.done(key) + } + } + }) +} diff --git a/ui/widget/border.go b/ui/widget/border.go new file mode 100644 index 0000000..a32f4dd --- /dev/null +++ b/ui/widget/border.go @@ -0,0 +1,44 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "github.com/gdamore/tcell" + "maunium.net/go/tview" +) + +type Border struct { + *tview.Box +} + +func NewBorder() *Border { + return &Border{tview.NewBox()} +} + +func (border *Border) Draw(screen tcell.Screen) { + background := tcell.StyleDefault.Background(border.GetBackgroundColor()).Foreground(border.GetBorderColor()) + x, y, width, height := border.GetRect() + if width == 1 { + for borderY := y; borderY < y+height; borderY++ { + screen.SetContent(x, borderY, tview.GraphicsVertBar, nil, background) + } + } else if height == 1 { + for borderX := x; borderX < x+width; borderX++ { + screen.SetContent(borderX, y, tview.GraphicsHoriBar, nil, background) + } + } +} diff --git a/ui/widget/center.go b/ui/widget/center.go new file mode 100644 index 0000000..41181a2 --- /dev/null +++ b/ui/widget/center.go @@ -0,0 +1,32 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "maunium.net/go/tview" +) + +func Center(width, height int, p tview.Primitive) tview.Primitive { + return tview.NewFlex(). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(p, height, 1, true). + AddItem(tview.NewBox(), 0, 1, false), width, 1, true). + AddItem(tview.NewBox(), 0, 1, false) +} diff --git a/ui/widget/color.go b/ui/widget/color.go new file mode 100644 index 0000000..874b93d --- /dev/null +++ b/ui/widget/color.go @@ -0,0 +1,60 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "fmt" + "hash/fnv" + "sort" + + "github.com/gdamore/tcell" +) + +var colorNames []string + +func init() { + colorNames = make([]string, len(tcell.ColorNames)) + i := 0 + for name, _ := range tcell.ColorNames { + colorNames[i] = name + i++ + } + sort.Sort(sort.StringSlice(colorNames)) +} + +func GetHashColorName(s string) string { + switch s { + case "-->": + return "green" + case "<--": + return "red" + case "---": + return "yellow" + default: + h := fnv.New32a() + h.Write([]byte(s)) + return colorNames[int(h.Sum32())%len(colorNames)] + } +} + +func GetHashColor(s string) tcell.Color { + return tcell.ColorNames[GetHashColorName(s)] +} + +func AddHashColor(s string) string { + return fmt.Sprintf("[%s]%s[white]", GetHashColorName(s), s) +} diff --git a/ui/widget/form-text-view.go b/ui/widget/form-text-view.go new file mode 100644 index 0000000..58046e9 --- /dev/null +++ b/ui/widget/form-text-view.go @@ -0,0 +1,44 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "github.com/gdamore/tcell" + "maunium.net/go/tview" +) + +type FormTextView struct { + *tview.TextView +} + +func (ftv *FormTextView) GetLabel() string { + return "" +} + +func (ftv *FormTextView) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + return ftv +} + +func (ftv *FormTextView) GetFieldWidth() int { + _, _, w, _ := ftv.TextView.GetRect() + return w +} + +func (ftv *FormTextView) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { + ftv.SetDoneFunc(handler) + return ftv +} diff --git a/ui/widget/message-view.go b/ui/widget/message-view.go new file mode 100644 index 0000000..3503a5f --- /dev/null +++ b/ui/widget/message-view.go @@ -0,0 +1,293 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "maunium.net/go/gomuks/ui/types" + "maunium.net/go/tview" +) + +type MessageView struct { + *tview.Box + + ScrollOffset int + MaxSenderWidth int + DateFormat string + TimestampFormat string + TimestampWidth int + Separator rune + + widestSender int + prevWidth int + prevHeight int + prevScrollOffset int + firstDisplayMessage int + lastDisplayMessage int + totalHeight int + + messageIDs map[string]bool + messages []*types.Message +} + +func NewMessageView() *MessageView { + return &MessageView{ + Box: tview.NewBox(), + MaxSenderWidth: 20, + DateFormat: "January _2, 2006", + TimestampFormat: "15:04:05", + TimestampWidth: 8, + Separator: '|', + ScrollOffset: 0, + + messages: make([]*types.Message, 0), + messageIDs: make(map[string]bool), + + widestSender: 5, + prevWidth: -1, + prevHeight: -1, + prevScrollOffset: -1, + firstDisplayMessage: -1, + lastDisplayMessage: -1, + totalHeight: -1, + } +} + +func (view *MessageView) NewMessage(id, sender, text string, timestamp time.Time) *types.Message { + return types.NewMessage(id, sender, text, + timestamp.Format(view.TimestampFormat), + timestamp.Format(view.DateFormat), + GetHashColor(sender)) +} + +func (view *MessageView) recalculateBuffers() { + _, _, width, _ := view.GetInnerRect() + width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap + if width != view.prevWidth { + for _, message := range view.messages { + message.CalculateBuffer(width) + } + view.prevWidth = width + } +} + +func (view *MessageView) updateWidestSender(sender string) { + if len(sender) > view.widestSender { + view.widestSender = len(sender) + if view.widestSender > view.MaxSenderWidth { + view.widestSender = view.MaxSenderWidth + } + } +} + +const ( + AppendMessage int = iota + PrependMessage +) + +func (view *MessageView) AddMessage(message *types.Message, direction int) { + _, messageExists := view.messageIDs[message.ID] + if messageExists { + return + } + + view.updateWidestSender(message.Sender) + + _, _, width, _ := view.GetInnerRect() + width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap + message.CalculateBuffer(width) + + if direction == AppendMessage { + if view.ScrollOffset > 0 { + view.ScrollOffset += len(message.Buffer) + } + view.messages = append(view.messages, message) + } else if direction == PrependMessage { + view.messages = append([]*types.Message{message}, view.messages...) + } + + view.messageIDs[message.ID] = true + view.recalculateHeight() +} + +func (view *MessageView) recalculateHeight() { + _, _, width, height := view.GetInnerRect() + if height != view.prevHeight || width != view.prevWidth || view.ScrollOffset != view.prevScrollOffset { + view.firstDisplayMessage = -1 + view.lastDisplayMessage = -1 + view.totalHeight = 0 + prevDate := "" + for i := len(view.messages) - 1; i >= 0; i-- { + prevTotalHeight := view.totalHeight + message := view.messages[i] + view.totalHeight += len(message.Buffer) + if message.Date != prevDate { + if len(prevDate) != 0 { + view.totalHeight++ + } + prevDate = message.Date + } + + if view.totalHeight < view.ScrollOffset { + continue + } else if view.firstDisplayMessage == -1 { + view.lastDisplayMessage = i + view.firstDisplayMessage = i + } + + if prevTotalHeight < height+view.ScrollOffset { + view.lastDisplayMessage = i + } + } + view.prevScrollOffset = view.ScrollOffset + } +} + +func (view *MessageView) PageUp() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset += height / 2 + if view.ScrollOffset > view.totalHeight-height { + view.ScrollOffset = view.totalHeight - height + 5 + } +} + +func (view *MessageView) PageDown() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset -= height / 2 + if view.ScrollOffset < 0 { + view.ScrollOffset = 0 + } +} + +func (view *MessageView) writeLine(screen tcell.Screen, line string, x, y int, color tcell.Color) { + offsetX := 0 + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) + } + offsetX += chWidth + } +} + +func (view *MessageView) writeLineRight(screen tcell.Screen, line string, x, y, maxWidth int, color tcell.Color) { + offsetX := maxWidth - runewidth.StringWidth(line) + if offsetX < 0 { + offsetX = 0 + } + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) + } + offsetX += chWidth + if offsetX > maxWidth { + break + } + } +} + +const ( + TimestampSenderGap = 1 + SenderSeparatorGap = 1 + SenderMessageGap = 3 +) + +func (view *MessageView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + + x, y, _, height := view.GetInnerRect() + view.recalculateBuffers() + view.recalculateHeight() + + if view.firstDisplayMessage == -1 || view.lastDisplayMessage == -1 { + view.writeLine(screen, "It's quite empty in here.", x, y+height, tcell.ColorDefault) + return + } + + usernameOffsetX := view.TimestampWidth + TimestampSenderGap + messageOffsetX := usernameOffsetX + view.widestSender + SenderMessageGap + separatorX := x + usernameOffsetX + view.widestSender + SenderSeparatorGap + for separatorY := y; separatorY < y+height; separatorY++ { + screen.SetContent(separatorX, separatorY, view.Separator, nil, tcell.StyleDefault) + } + + writeOffset := 0 + prevDate := "" + prevSender := "" + prevSenderLine := -1 + for i := view.firstDisplayMessage; i >= view.lastDisplayMessage; i-- { + message := view.messages[i] + messageHeight := len(message.Buffer) + + // Show message when the date changes. + if message.Date != prevDate { + if len(prevDate) > 0 { + writeOffset++ + view.writeLine( + screen, fmt.Sprintf("Date changed to %s", prevDate), + x+messageOffsetX, y+height-writeOffset, tcell.ColorGreen) + } + prevDate = message.Date + } + + senderAtLine := y + height - writeOffset - messageHeight + // The message may be only partially on screen, so we need to make sure the sender + // is on screen even when the message is not shown completely. + if senderAtLine < y { + senderAtLine = y + } + + view.writeLine(screen, message.Timestamp, x, senderAtLine, tcell.ColorDefault) + view.writeLineRight(screen, message.Sender, + x+usernameOffsetX, senderAtLine, + view.widestSender, message.SenderColor) + + if message.Sender == prevSender { + // Sender is same as previous. We're looping from bottom to top, and we want the + // sender name only on the topmost message, so clear out the duplicate sender name + // below. + view.writeLineRight(screen, strings.Repeat(" ", view.widestSender), + x+usernameOffsetX, prevSenderLine, + view.widestSender, message.SenderColor) + } + prevSender = message.Sender + prevSenderLine = senderAtLine + + for num, line := range message.Buffer { + offsetY := height - messageHeight - writeOffset + num + // Only render message if it's within the message view. + if offsetY >= 0 { + view.writeLine(screen, line, x+messageOffsetX, y+offsetY, tcell.ColorDefault) + } + } + writeOffset += messageHeight + } +} diff --git a/ui/widget/room-view.go b/ui/widget/room-view.go new file mode 100644 index 0000000..eeab7b2 --- /dev/null +++ b/ui/widget/room-view.go @@ -0,0 +1,152 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package widget + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell" + rooms "maunium.net/go/gomuks/matrix/room" + "maunium.net/go/gomuks/ui/types" + "maunium.net/go/tview" +) + +type Renderable interface { + Render() +} + +type RoomView struct { + *tview.Box + + topic *tview.TextView + content *MessageView + status *tview.TextView + userList *tview.TextView + Room *rooms.Room + + parent Renderable +} + +func NewRoomView(parent Renderable, room *rooms.Room) *RoomView { + view := &RoomView{ + Box: tview.NewBox(), + topic: tview.NewTextView(), + content: NewMessageView(), + status: tview.NewTextView(), + userList: tview.NewTextView(), + Room: room, + parent: parent, + } + view.topic. + SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). + SetBackgroundColor(tcell.ColorDarkGreen) + view.status.SetBackgroundColor(tcell.ColorDimGray) + view.userList.SetDynamicColors(true) + return view +} + +func (view *RoomView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + + x, y, width, height := view.GetRect() + view.topic.SetRect(x, y, width, 1) + view.content.SetRect(x, y+1, width-30, height-2) + view.status.SetRect(x, y+height-1, width, 1) + view.userList.SetRect(x+width-29, y+1, 29, height-2) + + view.topic.Draw(screen) + view.content.Draw(screen) + view.status.Draw(screen) + + borderX := x + width - 30 + background := tcell.StyleDefault.Background(view.GetBackgroundColor()).Foreground(view.GetBorderColor()) + for borderY := y + 1; borderY < y+height-1; borderY++ { + screen.SetContent(borderX, borderY, tview.GraphicsVertBar, nil, background) + } + view.userList.Draw(screen) +} + +func (view *RoomView) SetStatus(status string) { + view.status.SetText(status) +} + +func (view *RoomView) SetTyping(users []string) { + for index, user := range users { + member := view.Room.GetMember(user) + if member != nil { + users[index] = member.DisplayName + } + } + if len(users) == 0 { + view.status.SetText("") + } else if len(users) < 2 { + view.status.SetText("Typing: " + strings.Join(users, " and ")) + } else { + view.status.SetText(fmt.Sprintf( + "Typing: %s and %s", + strings.Join(users[:len(users)-1], ", "), users[len(users)-1])) + } +} + +func (view *RoomView) AutocompleteUser(existingText string) (completions []string) { + for _, user := range view.Room.GetMembers() { + if strings.HasPrefix(user.DisplayName, existingText) { + completions = append(completions, user.DisplayName) + } else if strings.HasPrefix(user.UserID, existingText) { + completions = append(completions, user.UserID) + } + } + return +} + +func (view *RoomView) MessageView() *MessageView { + return view.content +} + +func (view *RoomView) UpdateUserList() { + var joined strings.Builder + var invited strings.Builder + for _, user := range view.Room.GetMembers() { + if user.Membership == "join" { + joined.WriteString(AddHashColor(user.DisplayName)) + joined.WriteRune('\n') + } else if user.Membership == "invite" { + invited.WriteString(AddHashColor(user.DisplayName)) + invited.WriteRune('\n') + } + } + view.userList.Clear() + fmt.Fprintf(view.userList, "%s\n", joined.String()) + if invited.Len() > 0 { + fmt.Fprintf(view.userList, "\nInvited:\n%s", invited.String()) + } +} + +func (view *RoomView) NewMessage(id, sender, text string, timestamp time.Time) *types.Message { + member := view.Room.GetMember(sender) + if member != nil { + sender = member.DisplayName + } + return view.content.NewMessage(id, sender, text, timestamp) +} + +func (view *RoomView) AddMessage(message *types.Message, direction int) { + view.content.AddMessage(message, direction) + view.parent.Render() +} -- cgit v1.2.3