Merge pull request #18 from tulir/ui-refactor
Refactor UI to use interfaces and add advanced message rendering
This commit is contained in:
commit
53cdfb64c1
@ -23,7 +23,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
"maunium.net/go/gomuks/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config contains the main config of gomuks.
|
// Config contains the main config of gomuks.
|
||||||
@ -33,6 +33,7 @@ type Config struct {
|
|||||||
|
|
||||||
Dir string `yaml:"-"`
|
Dir string `yaml:"-"`
|
||||||
HistoryDir string `yaml:"history_dir"`
|
HistoryDir string `yaml:"history_dir"`
|
||||||
|
MediaDir string `yaml:"media_dir"`
|
||||||
Session *Session `yaml:"-"`
|
Session *Session `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ func NewConfig(dir string) *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
HistoryDir: filepath.Join(dir, "history"),
|
HistoryDir: filepath.Join(dir, "history"),
|
||||||
|
MediaDir: filepath.Join(dir, "media"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ import (
|
|||||||
"maunium.net/go/gomatrix"
|
"maunium.net/go/gomatrix"
|
||||||
"maunium.net/go/gomuks/matrix/pushrules"
|
"maunium.net/go/gomuks/matrix/pushrules"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
"maunium.net/go/gomuks/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
|
@ -17,105 +17,45 @@
|
|||||||
package debug
|
package debug
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
"maunium.net/go/tview"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Printer interface {
|
var writer io.Writer
|
||||||
Printf(text string, args ...interface{})
|
|
||||||
Print(text ...interface{})
|
func init() {
|
||||||
|
var err error
|
||||||
|
writer, err = os.OpenFile("/tmp/gomuks-debug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
writer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Pane struct {
|
|
||||||
*tview.TextView
|
|
||||||
Height int
|
|
||||||
Width int
|
|
||||||
num int
|
|
||||||
}
|
|
||||||
|
|
||||||
var Default Printer
|
|
||||||
var RedirectAllExt bool
|
|
||||||
|
|
||||||
func NewPane() *Pane {
|
|
||||||
pane := tview.NewTextView()
|
|
||||||
pane.
|
|
||||||
SetScrollable(true).
|
|
||||||
SetWrap(true).
|
|
||||||
SetBorder(true).
|
|
||||||
SetTitle("Debug output")
|
|
||||||
fmt.Fprintln(pane, "[0] Debug pane initialized")
|
|
||||||
|
|
||||||
return &Pane{
|
|
||||||
TextView: pane,
|
|
||||||
Height: 35,
|
|
||||||
Width: 80,
|
|
||||||
num: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Pane) Printf(text string, args ...interface{}) {
|
|
||||||
db.WriteString(fmt.Sprintf(text, args...) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Pane) Print(text ...interface{}) {
|
|
||||||
db.WriteString(fmt.Sprintln(text...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Pane) WriteString(text string) {
|
|
||||||
db.num++
|
|
||||||
fmt.Fprintf(db, "[%d] %s", db.num, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaneSide int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Top PaneSide = iota
|
|
||||||
Bottom
|
|
||||||
Left
|
|
||||||
Right
|
|
||||||
)
|
|
||||||
|
|
||||||
func (db *Pane) Wrap(main tview.Primitive, side PaneSide) tview.Primitive {
|
|
||||||
rows, columns := []int{0}, []int{0}
|
|
||||||
mainRow, mainColumn, paneRow, paneColumn := 0, 0, 0, 0
|
|
||||||
switch side {
|
|
||||||
case Top:
|
|
||||||
rows = []int{db.Height, 0}
|
|
||||||
mainRow = 1
|
|
||||||
case Bottom:
|
|
||||||
rows = []int{0, db.Height}
|
|
||||||
paneRow = 1
|
|
||||||
case Left:
|
|
||||||
columns = []int{db.Width, 0}
|
|
||||||
mainColumn = 1
|
|
||||||
case Right:
|
|
||||||
columns = []int{0, db.Width}
|
|
||||||
paneColumn = 1
|
|
||||||
}
|
|
||||||
return tview.NewGrid().SetRows(rows...).SetColumns(columns...).
|
|
||||||
AddItem(main, mainRow, mainColumn, 1, 1, 1, 1, true).
|
|
||||||
AddItem(db, paneRow, paneColumn, 1, 1, 1, 1, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Printf(text string, args ...interface{}) {
|
func Printf(text string, args ...interface{}) {
|
||||||
if RedirectAllExt {
|
if writer != nil {
|
||||||
ExtPrintf(text, args...)
|
fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
|
||||||
} else if Default != nil {
|
fmt.Fprintf(writer, text+"\n", args...)
|
||||||
Default.Printf(text, args...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Print(text ...interface{}) {
|
func Print(text ...interface{}) {
|
||||||
if RedirectAllExt {
|
if writer != nil {
|
||||||
ExtPrint(text...)
|
fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
|
||||||
} else if Default != nil {
|
fmt.Fprintln(writer, text...)
|
||||||
Default.Print(text...)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintStack() {
|
||||||
|
if writer != nil {
|
||||||
|
data := debug.Stack()
|
||||||
|
writer.Write(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,14 +68,18 @@ const Oops = ` __________
|
|||||||
U ||----W |
|
U ||----W |
|
||||||
|| ||`
|
|| ||`
|
||||||
|
|
||||||
func PrettyPanic() {
|
func PrettyPanic(panic interface{}) {
|
||||||
fmt.Println(Oops)
|
fmt.Println(Oops)
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("A fatal error has occurred.")
|
fmt.Println("A fatal error has occurred.")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
traceFile := fmt.Sprintf("/tmp/gomuks-panic-%s.txt", time.Now().Format("2006-01-02--15-04-05"))
|
traceFile := fmt.Sprintf("/tmp/gomuks-panic-%s.txt", time.Now().Format("2006-01-02--15-04-05"))
|
||||||
data := debug.Stack()
|
|
||||||
err := ioutil.WriteFile(traceFile, data, 0644)
|
var buf bytes.Buffer
|
||||||
|
fmt.Fprintln(&buf, panic)
|
||||||
|
buf.Write(debug.Stack())
|
||||||
|
err := ioutil.WriteFile(traceFile, buf.Bytes(), 0644)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Saving the stack trace to", traceFile, "failed:")
|
fmt.Println("Saving the stack trace to", traceFile, "failed:")
|
||||||
fmt.Println("--------------------------------------------------------------------------------")
|
fmt.Println("--------------------------------------------------------------------------------")
|
||||||
@ -146,6 +90,7 @@ func PrettyPanic() {
|
|||||||
fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.")
|
fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("--------------------------------------------------------------------------------")
|
fmt.Println("--------------------------------------------------------------------------------")
|
||||||
|
fmt.Println(panic)
|
||||||
debug.PrintStack()
|
debug.PrintStack()
|
||||||
fmt.Println("--------------------------------------------------------------------------------")
|
fmt.Println("--------------------------------------------------------------------------------")
|
||||||
} else {
|
} else {
|
2
debug/doc.go
Normal file
2
debug/doc.go
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Package debug contains utilities to log debug messages and display panics nicely.
|
||||||
|
package debug
|
35
gomuks.go
35
gomuks.go
@ -23,10 +23,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/gomuks/config"
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
"maunium.net/go/gomuks/interface"
|
"maunium.net/go/gomuks/interface"
|
||||||
"maunium.net/go/gomuks/matrix"
|
"maunium.net/go/gomuks/matrix"
|
||||||
"maunium.net/go/gomuks/ui"
|
"maunium.net/go/gomuks/ui"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ type Gomuks struct {
|
|||||||
app *tview.Application
|
app *tview.Application
|
||||||
ui *ui.GomuksUI
|
ui *ui.GomuksUI
|
||||||
matrix *matrix.Container
|
matrix *matrix.Container
|
||||||
debug *debug.Pane
|
|
||||||
debugMode bool
|
debugMode bool
|
||||||
config *config.Config
|
config *config.Config
|
||||||
stop chan bool
|
stop chan bool
|
||||||
@ -43,19 +42,14 @@ type Gomuks struct {
|
|||||||
|
|
||||||
// NewGomuks creates a new Gomuks instance with everything initialized,
|
// NewGomuks creates a new Gomuks instance with everything initialized,
|
||||||
// but does not start it.
|
// but does not start it.
|
||||||
func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
func NewGomuks(enableDebug bool) *Gomuks {
|
||||||
configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks")
|
configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks")
|
||||||
gmx := &Gomuks{
|
gmx := &Gomuks{
|
||||||
app: tview.NewApplication(),
|
app: tview.NewApplication(),
|
||||||
stop: make(chan bool, 1),
|
stop: make(chan bool, 1),
|
||||||
|
debugMode: enableDebug,
|
||||||
}
|
}
|
||||||
|
|
||||||
gmx.debug = debug.NewPane()
|
|
||||||
gmx.debug.SetChangedFunc(func() {
|
|
||||||
gmx.ui.Render()
|
|
||||||
})
|
|
||||||
debug.Default = gmx.debug
|
|
||||||
|
|
||||||
gmx.config = config.NewConfig(configDir)
|
gmx.config = config.NewConfig(configDir)
|
||||||
gmx.ui = ui.NewGomuksUI(gmx)
|
gmx.ui = ui.NewGomuksUI(gmx)
|
||||||
gmx.matrix = matrix.NewContainer(gmx)
|
gmx.matrix = matrix.NewContainer(gmx)
|
||||||
@ -68,15 +62,6 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
|||||||
_ = gmx.matrix.InitClient()
|
_ = gmx.matrix.InitClient()
|
||||||
|
|
||||||
main := gmx.ui.InitViews()
|
main := gmx.ui.InitViews()
|
||||||
if enableDebug {
|
|
||||||
debug.EnableExternal()
|
|
||||||
if forceExternalDebug {
|
|
||||||
debug.RedirectAllExt = true
|
|
||||||
} else {
|
|
||||||
main = gmx.debug.Wrap(main, debug.Right)
|
|
||||||
}
|
|
||||||
gmx.debugMode = true
|
|
||||||
}
|
|
||||||
gmx.app.SetRoot(main, true)
|
gmx.app.SetRoot(main, true)
|
||||||
|
|
||||||
return gmx
|
return gmx
|
||||||
@ -85,10 +70,10 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
|||||||
// Save saves the active session and message history.
|
// Save saves the active session and message history.
|
||||||
func (gmx *Gomuks) Save() {
|
func (gmx *Gomuks) Save() {
|
||||||
if gmx.config.Session != nil {
|
if gmx.config.Session != nil {
|
||||||
gmx.debug.Print("Saving session...")
|
debug.Print("Saving session...")
|
||||||
_ = gmx.config.Session.Save()
|
_ = gmx.config.Session.Save()
|
||||||
}
|
}
|
||||||
gmx.debug.Print("Saving history...")
|
debug.Print("Saving history...")
|
||||||
gmx.ui.MainView().SaveAllHistory()
|
gmx.ui.MainView().SaveAllHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,9 +97,9 @@ func (gmx *Gomuks) StartAutosave() {
|
|||||||
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
|
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
|
||||||
// then saves everything and calls os.Exit(0).
|
// then saves everything and calls os.Exit(0).
|
||||||
func (gmx *Gomuks) Stop() {
|
func (gmx *Gomuks) Stop() {
|
||||||
gmx.debug.Print("Disconnecting from Matrix...")
|
debug.Print("Disconnecting from Matrix...")
|
||||||
gmx.matrix.Stop()
|
gmx.matrix.Stop()
|
||||||
gmx.debug.Print("Cleaning up UI...")
|
debug.Print("Cleaning up UI...")
|
||||||
gmx.app.Stop()
|
gmx.app.Stop()
|
||||||
gmx.stop <- true
|
gmx.stop <- true
|
||||||
gmx.Save()
|
gmx.Save()
|
||||||
@ -132,7 +117,7 @@ func (gmx *Gomuks) Recover() {
|
|||||||
if gmx.debugMode {
|
if gmx.debugMode {
|
||||||
panic(p)
|
panic(p)
|
||||||
} else {
|
} else {
|
||||||
debug.PrettyPanic()
|
debug.PrettyPanic(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,8 +155,8 @@ func (gmx *Gomuks) UI() ifc.GomuksUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
debugVar := os.Getenv("DEBUG")
|
enableDebug := len(os.Getenv("DEBUG")) > 0
|
||||||
NewGomuks(len(debugVar) > 0, debugVar == "ext").Start()
|
NewGomuks(enableDebug).Start()
|
||||||
|
|
||||||
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
|
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
|
@ -34,4 +34,6 @@ type MatrixContainer interface {
|
|||||||
LeaveRoom(roomID string) error
|
LeaveRoom(roomID string) error
|
||||||
GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error)
|
GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error)
|
||||||
GetRoom(roomID string) *rooms.Room
|
GetRoom(roomID string) *rooms.Room
|
||||||
|
Download(mxcURL string) ([]byte, string, string, error)
|
||||||
|
GetCachePath(homeserver, fileID string) string
|
||||||
}
|
}
|
||||||
|
@ -17,11 +17,12 @@
|
|||||||
package ifc
|
package ifc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/tcell"
|
||||||
"maunium.net/go/gomatrix"
|
"maunium.net/go/gomatrix"
|
||||||
"maunium.net/go/gomuks/matrix/pushrules"
|
"maunium.net/go/gomuks/matrix/pushrules"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
"maunium.net/go/gomuks/ui/types"
|
|
||||||
"maunium.net/go/gomuks/ui/widget"
|
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -42,7 +43,7 @@ type GomuksUI interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MainView interface {
|
type MainView interface {
|
||||||
GetRoom(roomID string) *widget.RoomView
|
GetRoom(roomID string) RoomView
|
||||||
HasRoom(roomID string) bool
|
HasRoom(roomID string) bool
|
||||||
AddRoom(roomID string)
|
AddRoom(roomID string)
|
||||||
RemoveRoom(roomID string)
|
RemoveRoom(roomID string)
|
||||||
@ -50,11 +51,76 @@ type MainView interface {
|
|||||||
SaveAllHistory()
|
SaveAllHistory()
|
||||||
|
|
||||||
SetTyping(roomID string, users []string)
|
SetTyping(roomID string, users []string)
|
||||||
AddServiceMessage(roomID *widget.RoomView, message string)
|
ParseEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||||
ProcessMessageEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
|
//ProcessMessageEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||||
ProcessMembershipEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
|
//ProcessMembershipEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||||
NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould)
|
NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginView interface {
|
type LoginView interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppendMessage MessageDirection = iota
|
||||||
|
PrependMessage
|
||||||
|
IgnoreMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomView interface {
|
||||||
|
MxRoom() *rooms.Room
|
||||||
|
SaveHistory(dir string) error
|
||||||
|
LoadHistory(gmx Gomuks, dir string) (int, error)
|
||||||
|
|
||||||
|
SetStatus(status string)
|
||||||
|
SetTyping(users []string)
|
||||||
|
UpdateUserList()
|
||||||
|
|
||||||
|
NewMessage(id, sender, msgtype, text string, timestamp time.Time) Message
|
||||||
|
NewTempMessage(msgtype, text string) Message
|
||||||
|
AddMessage(message Message, direction MessageDirection)
|
||||||
|
AddServiceMessage(message string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageMeta interface {
|
||||||
|
Sender() string
|
||||||
|
SenderColor() tcell.Color
|
||||||
|
TextColor() tcell.Color
|
||||||
|
TimestampColor() tcell.Color
|
||||||
|
Timestamp() time.Time
|
||||||
|
FormatTime() string
|
||||||
|
FormatDate() string
|
||||||
|
CopyFrom(from MessageMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageState is an enum to specify if a Message is being sent, failed to send or was successfully sent.
|
||||||
|
type MessageState int
|
||||||
|
|
||||||
|
// Allowed MessageStates.
|
||||||
|
const (
|
||||||
|
MessageStateSending MessageState = iota
|
||||||
|
MessageStateDefault
|
||||||
|
MessageStateFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message interface {
|
||||||
|
MessageMeta
|
||||||
|
|
||||||
|
SetIsHighlight(isHighlight bool)
|
||||||
|
IsHighlight() bool
|
||||||
|
|
||||||
|
SetIsService(isService bool)
|
||||||
|
IsService() bool
|
||||||
|
|
||||||
|
SetID(id string)
|
||||||
|
ID() string
|
||||||
|
|
||||||
|
SetType(msgtype string)
|
||||||
|
Type() string
|
||||||
|
|
||||||
|
NotificationContent() string
|
||||||
|
|
||||||
|
SetState(state MessageState)
|
||||||
|
State() MessageState
|
||||||
|
}
|
||||||
|
373
lib/ansimage/LICENSE
Normal file
373
lib/ansimage/LICENSE
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
287
lib/ansimage/ansimage.go
Normal file
287
lib/ansimage/ansimage.go
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
// ___ _____ ____
|
||||||
|
// / _ \/ _/ |/_/ /____ ______ _
|
||||||
|
// / ___// /_> </ __/ -_) __/ ' \
|
||||||
|
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
|
||||||
|
//
|
||||||
|
// Copyright 2017 Eliuk Blau
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
package ansimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/gif" // initialize decoder
|
||||||
|
_ "image/jpeg" // initialize decoder
|
||||||
|
_ "image/png" // initialize decoder
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
_ "golang.org/x/image/bmp" // initialize decoder
|
||||||
|
_ "golang.org/x/image/tiff" // initialize decoder
|
||||||
|
_ "golang.org/x/image/webp" // initialize decoder
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrHeightNonMoT happens when ANSImage height is not a Multiple of Two value.
|
||||||
|
ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
|
||||||
|
|
||||||
|
// ErrInvalidBoundsMoT happens when ANSImage height or width are invalid values (Multiple of Two).
|
||||||
|
ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=2")
|
||||||
|
|
||||||
|
// ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds.
|
||||||
|
ErrOutOfBounds = errors.New("ANSImage: out of bounds")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ANSIpixel represents a pixel of an ANSImage.
|
||||||
|
type ANSIpixel struct {
|
||||||
|
Brightness uint8
|
||||||
|
R, G, B uint8
|
||||||
|
upper bool
|
||||||
|
source *ANSImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSImage represents an image encoded in ANSI escape codes.
|
||||||
|
type ANSImage struct {
|
||||||
|
h, w int
|
||||||
|
maxprocs int
|
||||||
|
bgR uint8
|
||||||
|
bgG uint8
|
||||||
|
bgB uint8
|
||||||
|
pixmap [][]*ANSIpixel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ai *ANSImage) Pixmap() [][]*ANSIpixel {
|
||||||
|
return ai.pixmap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height gets total rows of ANSImage.
|
||||||
|
func (ai *ANSImage) Height() int {
|
||||||
|
return ai.h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width gets total columns of ANSImage.
|
||||||
|
func (ai *ANSImage) Width() int {
|
||||||
|
return ai.w
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage
|
||||||
|
// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect).
|
||||||
|
func (ai *ANSImage) SetMaxProcs(max int) {
|
||||||
|
ai.maxprocs = max
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage.
|
||||||
|
func (ai *ANSImage) GetMaxProcs() int {
|
||||||
|
return ai.maxprocs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x).
|
||||||
|
func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error {
|
||||||
|
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
|
||||||
|
ai.pixmap[y][x].R = r
|
||||||
|
ai.pixmap[y][x].G = g
|
||||||
|
ai.pixmap[y][x].B = b
|
||||||
|
ai.pixmap[y][x].Brightness = brightness
|
||||||
|
ai.pixmap[y][x].upper = y%2 == 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrOutOfBounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAt gets ANSI-pixel in coordinates (y,x).
|
||||||
|
func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) {
|
||||||
|
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
|
||||||
|
return &ANSIpixel{
|
||||||
|
R: ai.pixmap[y][x].R,
|
||||||
|
G: ai.pixmap[y][x].G,
|
||||||
|
B: ai.pixmap[y][x].B,
|
||||||
|
Brightness: ai.pixmap[y][x].Brightness,
|
||||||
|
upper: ai.pixmap[y][x].upper,
|
||||||
|
source: ai.pixmap[y][x].source,
|
||||||
|
},
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return nil, ErrOutOfBounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render returns the ANSI-compatible string form of ANSImage.
|
||||||
|
// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728)
|
||||||
|
func (ai *ANSImage) Render() []tstring.TString {
|
||||||
|
type renderData struct {
|
||||||
|
row int
|
||||||
|
render tstring.TString
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]tstring.TString, ai.h/2)
|
||||||
|
for y := 0; y < ai.h; y += ai.maxprocs {
|
||||||
|
ch := make(chan renderData, ai.maxprocs)
|
||||||
|
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
|
||||||
|
go func(row, y int) {
|
||||||
|
str := make(tstring.TString, ai.w)
|
||||||
|
for x := 0; x < ai.w; x++ {
|
||||||
|
topPixel := ai.pixmap[y][x]
|
||||||
|
topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B))
|
||||||
|
|
||||||
|
bottomPixel := ai.pixmap[y+1][x]
|
||||||
|
bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B))
|
||||||
|
|
||||||
|
str[x] = tstring.Cell{
|
||||||
|
Char: '▄',
|
||||||
|
Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- renderData{row: row, render: str}
|
||||||
|
}(row, 2*row)
|
||||||
|
}
|
||||||
|
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
|
||||||
|
data := <-ch
|
||||||
|
rows[data.row] = data.render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new empty ANSImage ready to draw on it.
|
||||||
|
func New(h, w int, bg color.Color) (*ANSImage, error) {
|
||||||
|
if h%2 != 0 {
|
||||||
|
return nil, ErrHeightNonMoT
|
||||||
|
}
|
||||||
|
|
||||||
|
if h < 2 || w < 2 {
|
||||||
|
return nil, ErrInvalidBoundsMoT
|
||||||
|
}
|
||||||
|
|
||||||
|
r, g, b, _ := bg.RGBA()
|
||||||
|
ansimage := &ANSImage{
|
||||||
|
h: h, w: w,
|
||||||
|
maxprocs: 1,
|
||||||
|
bgR: uint8(r),
|
||||||
|
bgG: uint8(g),
|
||||||
|
bgB: uint8(b),
|
||||||
|
pixmap: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
ansimage.pixmap = func() [][]*ANSIpixel {
|
||||||
|
v := make([][]*ANSIpixel, h)
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
v[y] = make([]*ANSIpixel, w)
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
v[y][x] = &ANSIpixel{
|
||||||
|
R: 0,
|
||||||
|
G: 0,
|
||||||
|
B: 0,
|
||||||
|
Brightness: 0,
|
||||||
|
source: ansimage,
|
||||||
|
upper: y%2 == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ansimage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromReader creates a new ANSImage from an io.Reader.
|
||||||
|
// Background color is used to fill when image has transparency or dithering mode is enabled
|
||||||
|
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
|
||||||
|
func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) {
|
||||||
|
img, _, err := image.Decode(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return createANSImage(img, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScaledFromReader creates a new scaled ANSImage from an io.Reader.
|
||||||
|
// Background color is used to fill when image has transparency or dithering mode is enabled
|
||||||
|
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
|
||||||
|
func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) {
|
||||||
|
img, _, err := image.Decode(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
img = imaging.Resize(img, x, y, imaging.Lanczos)
|
||||||
|
|
||||||
|
return createANSImage(img, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromFile creates a new ANSImage from a file.
|
||||||
|
// Background color is used to fill when image has transparency or dithering mode is enabled
|
||||||
|
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
|
||||||
|
func NewFromFile(name string, bg color.Color) (*ANSImage, error) {
|
||||||
|
reader, err := os.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
return NewFromReader(reader, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScaledFromFile creates a new scaled ANSImage from a file.
|
||||||
|
// Background color is used to fill when image has transparency or dithering mode is enabled
|
||||||
|
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
|
||||||
|
func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) {
|
||||||
|
reader, err := os.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
return NewScaledFromReader(reader, y, x, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createANSImage loads data from an image and returns an ANSImage.
|
||||||
|
// Background color is used to fill when image has transparency or dithering mode is enabled
|
||||||
|
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
|
||||||
|
func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) {
|
||||||
|
var rgbaOut *image.RGBA
|
||||||
|
bounds := img.Bounds()
|
||||||
|
|
||||||
|
// do compositing only if background color has no transparency (thank you @disq for the idea!)
|
||||||
|
// (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image)
|
||||||
|
if _, _, _, a := bg.RGBA(); a >= 0xffff {
|
||||||
|
rgbaOut = image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src)
|
||||||
|
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over)
|
||||||
|
} else {
|
||||||
|
if v, ok := img.(*image.RGBA); ok {
|
||||||
|
rgbaOut = v
|
||||||
|
} else {
|
||||||
|
rgbaOut = image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yMin, xMin := bounds.Min.Y, bounds.Min.X
|
||||||
|
yMax, xMax := bounds.Max.Y, bounds.Max.X
|
||||||
|
|
||||||
|
// always sets an even number of ANSIPixel rows...
|
||||||
|
yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering
|
||||||
|
|
||||||
|
ansimage, err := New(yMax, xMax, bg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := yMin; y < yMax; y++ {
|
||||||
|
for x := xMin; x < xMax; x++ {
|
||||||
|
v := rgbaOut.RGBAAt(x, y)
|
||||||
|
if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ansimage, nil
|
||||||
|
}
|
12
lib/ansimage/doc.go
Normal file
12
lib/ansimage/doc.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Package ansimage is a simplified version of the ansimage package
|
||||||
|
// in https://github.com/eliukblau/pixterm focused in rendering images
|
||||||
|
// to a tcell-based TUI app.
|
||||||
|
//
|
||||||
|
// ___ _____ ____
|
||||||
|
// / _ \/ _/ |/_/ /____ ______ _
|
||||||
|
// / ___// /_> </ __/ -_) __/ ' \
|
||||||
|
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
|
||||||
|
//
|
||||||
|
// This package is licensed under the Mozilla Public License v2.0.
|
||||||
|
package ansimage
|
||||||
|
|
3
lib/htmlparser/doc.go
Normal file
3
lib/htmlparser/doc.go
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// Package htmlparser contains a HTML parsing system similar to html.parser.HTMLParser in Python 3.
|
||||||
|
// The parser uses x/net/html.Tokenizer in the background.
|
||||||
|
package htmlparser
|
142
lib/htmlparser/htmlparser.go
Normal file
142
lib/htmlparser/htmlparser.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package htmlparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLProcessor contains the functions to process parsed HTML data.
|
||||||
|
type HTMLProcessor interface {
|
||||||
|
// Preprocess is called before the parsing is started.
|
||||||
|
Preprocess()
|
||||||
|
|
||||||
|
// HandleStartTag is called with the tag name and attributes when
|
||||||
|
// the parser encounters a StartTagToken, except if the tag is
|
||||||
|
// always self-closing.
|
||||||
|
HandleStartTag(tagName string, attrs map[string]string)
|
||||||
|
// HandleSelfClosingTag is called with the tag name and attributes
|
||||||
|
// when the parser encounters a SelfClosingTagToken OR a StartTagToken
|
||||||
|
// with a tag that's always self-closing.
|
||||||
|
HandleSelfClosingTag(tagName string, attrs map[string]string)
|
||||||
|
// HandleText is called with the text when the parser encounters
|
||||||
|
// a TextToken.
|
||||||
|
HandleText(text string)
|
||||||
|
// HandleEndTag is called with the tag name when the parser encounters
|
||||||
|
// an EndTagToken.
|
||||||
|
HandleEndTag(tagName string)
|
||||||
|
|
||||||
|
// ReceiveError is called with the error when the parser encounters
|
||||||
|
// an ErrorToken that IS NOT io.EOF.
|
||||||
|
ReceiveError(err error)
|
||||||
|
|
||||||
|
// Postprocess is called after parsing is completed successfully.
|
||||||
|
// An unsuccessful parsing will trigger a ReceiveError() call.
|
||||||
|
Postprocess()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMLParser wraps a net/html.Tokenizer and a HTMLProcessor to call
|
||||||
|
// the HTMLProcessor with data from the Tokenizer.
|
||||||
|
type HTMLParser struct {
|
||||||
|
*html.Tokenizer
|
||||||
|
processor HTMLProcessor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLParserFromTokenizer creates a new HTMLParser from an existing html Tokenizer.
|
||||||
|
func NewHTMLParserFromTokenizer(z *html.Tokenizer, processor HTMLProcessor) HTMLParser {
|
||||||
|
return HTMLParser{
|
||||||
|
z,
|
||||||
|
processor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLParserFromReader creates a Tokenizer with the given io.Reader and
|
||||||
|
// then uses that to create a new HTMLParser.
|
||||||
|
func NewHTMLParserFromReader(reader io.Reader, processor HTMLProcessor) HTMLParser {
|
||||||
|
return NewHTMLParserFromTokenizer(html.NewTokenizer(reader), processor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLParserFromString creates a Tokenizer with a reader of the given
|
||||||
|
// string and then uses that to create a new HTMLParser.
|
||||||
|
func NewHTMLParserFromString(html string, processor HTMLProcessor) HTMLParser {
|
||||||
|
return NewHTMLParserFromReader(strings.NewReader(html), processor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfClosingTags is the list of tags that always call
|
||||||
|
// HTMLProcessor.HandleSelfClosingTag() even if it is encountered
|
||||||
|
// as a html.StartTagToken rather than html.SelfClosingTagToken.
|
||||||
|
var SelfClosingTags = []string{"img", "br", "hr", "area", "base", "basefont", "input", "link", "meta"}
|
||||||
|
|
||||||
|
func (parser HTMLParser) mapAttrs() map[string]string {
|
||||||
|
attrs := make(map[string]string)
|
||||||
|
hasMore := true
|
||||||
|
for hasMore {
|
||||||
|
var key, val []byte
|
||||||
|
key, val, hasMore = parser.TagAttr()
|
||||||
|
attrs[string(key)] = string(val)
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser HTMLParser) isSelfClosing(tag string) bool {
|
||||||
|
for _, selfClosingTag := range SelfClosingTags {
|
||||||
|
if tag == selfClosingTag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process parses the HTML using the tokenizer in this parser and
|
||||||
|
// calls the appropriate functions of the HTML processor.
|
||||||
|
func (parser HTMLParser) Process() {
|
||||||
|
parser.processor.Preprocess()
|
||||||
|
Loop:
|
||||||
|
for {
|
||||||
|
tt := parser.Next()
|
||||||
|
switch tt {
|
||||||
|
case html.ErrorToken:
|
||||||
|
if parser.Err() != io.EOF {
|
||||||
|
parser.processor.ReceiveError(parser.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break Loop
|
||||||
|
case html.TextToken:
|
||||||
|
parser.processor.HandleText(string(parser.Text()))
|
||||||
|
case html.StartTagToken, html.SelfClosingTagToken:
|
||||||
|
tagb, _ := parser.TagName()
|
||||||
|
attrs := parser.mapAttrs()
|
||||||
|
tag := string(tagb)
|
||||||
|
|
||||||
|
selfClosing := tt == html.SelfClosingTagToken || parser.isSelfClosing(tag)
|
||||||
|
|
||||||
|
if selfClosing {
|
||||||
|
parser.processor.HandleSelfClosingTag(tag, attrs)
|
||||||
|
} else {
|
||||||
|
parser.processor.HandleStartTag(tag, attrs)
|
||||||
|
}
|
||||||
|
case html.EndTagToken:
|
||||||
|
tagb, _ := parser.TagName()
|
||||||
|
parser.processor.HandleEndTag(string(tagb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.processor.Postprocess()
|
||||||
|
}
|
4
lib/open/doc.go
Normal file
4
lib/open/doc.go
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Package open contains a simple cross-platform way to open files in the program the OS wants to use.
|
||||||
|
//
|
||||||
|
// Based on https://github.com/skratchdot/open-golang
|
||||||
|
package open
|
27
lib/open/open.go
Normal file
27
lib/open/open.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// +build !windows,!darwin
|
||||||
|
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(input string) error {
|
||||||
|
return exec.Command("xdg-open", input).Start()
|
||||||
|
}
|
25
lib/open/open_darwin.go
Normal file
25
lib/open/open_darwin.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(input string) error {
|
||||||
|
return exec.Command("open", input).Start()
|
||||||
|
}
|
@ -14,32 +14,16 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package debug
|
package open
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"os/exec"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var writer io.Writer
|
const FileProtocolHandler = "url.dll,FileProtocolHandler"
|
||||||
|
|
||||||
func EnableExternal() {
|
var RunDLL32 = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe")
|
||||||
var err error
|
|
||||||
writer, err = os.OpenFile("/tmp/gomuks-debug.log", os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
writer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtPrintf(text string, args ...interface{}) {
|
func Open(input string) error {
|
||||||
if writer != nil {
|
return exec.Command(RunDLL32, FileProtocolHandler, input).Start()
|
||||||
fmt.Fprintf(writer, text+"\n", args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtPrint(text ...interface{}) {
|
|
||||||
if writer != nil {
|
|
||||||
fmt.Fprintln(writer, text...)
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -17,18 +17,26 @@
|
|||||||
package matrix
|
package matrix
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/gomatrix"
|
"maunium.net/go/gomatrix"
|
||||||
"maunium.net/go/gomuks/config"
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
"maunium.net/go/gomuks/interface"
|
"maunium.net/go/gomuks/interface"
|
||||||
"maunium.net/go/gomuks/matrix/pushrules"
|
"maunium.net/go/gomuks/matrix/pushrules"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
|
||||||
"maunium.net/go/gomuks/ui/widget"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Container is a wrapper for a gomatrix Client and some other stuff.
|
// Container is a wrapper for a gomatrix Client and some other stuff.
|
||||||
@ -221,13 +229,13 @@ func (c *Container) HandleMessage(evt *gomatrix.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
message := mainView.ProcessMessageEvent(roomView, evt)
|
message := mainView.ParseEvent(roomView, evt)
|
||||||
if message != nil {
|
if message != nil {
|
||||||
if c.syncer.FirstSyncDone {
|
if c.syncer.FirstSyncDone {
|
||||||
pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
|
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
|
||||||
mainView.NotifyMessage(roomView.Room, message, pushRules)
|
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
|
||||||
}
|
}
|
||||||
roomView.AddMessage(message, widget.AppendMessage)
|
roomView.AddMessage(message, ifc.AppendMessage)
|
||||||
c.ui.Render()
|
c.ui.Render()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,8 +263,7 @@ func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) {
|
|||||||
if evt.Unsigned.PrevContent != nil {
|
if evt.Unsigned.PrevContent != nil {
|
||||||
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
|
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
|
||||||
}
|
}
|
||||||
const Hour = 1 * 60 * 60 * 1000
|
if membership == prevMembership {
|
||||||
if membership == prevMembership || evt.Unsigned.Age > Hour {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch membership {
|
switch membership {
|
||||||
@ -279,18 +286,18 @@ func (c *Container) HandleMembership(evt *gomatrix.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
message := mainView.ProcessMembershipEvent(roomView, evt)
|
message := mainView.ParseEvent(roomView, evt)
|
||||||
if message != nil {
|
if message != nil {
|
||||||
// TODO this shouldn't be necessary
|
// TODO this shouldn't be necessary
|
||||||
roomView.Room.UpdateState(evt)
|
roomView.MxRoom().UpdateState(evt)
|
||||||
// TODO This should probably also be in a different place
|
// TODO This should probably also be in a different place
|
||||||
roomView.UpdateUserList()
|
roomView.UpdateUserList()
|
||||||
|
|
||||||
if c.syncer.FirstSyncDone {
|
if c.syncer.FirstSyncDone {
|
||||||
pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
|
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
|
||||||
mainView.NotifyMessage(roomView.Room, message, pushRules)
|
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
|
||||||
}
|
}
|
||||||
roomView.AddMessage(message, widget.AppendMessage)
|
roomView.AddMessage(message, ifc.AppendMessage)
|
||||||
c.ui.Render()
|
c.ui.Render()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,3 +410,55 @@ func (c *Container) GetRoom(roomID string) *rooms.Room {
|
|||||||
}
|
}
|
||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mxcRegex = regexp.MustCompile("mxc://(.+)/(.+)")
|
||||||
|
|
||||||
|
func (c *Container) Download(mxcURL string) (data []byte, hs, id string, err error) {
|
||||||
|
parts := mxcRegex.FindStringSubmatch(mxcURL)
|
||||||
|
if parts == nil || len(parts) != 3 {
|
||||||
|
err = fmt.Errorf("invalid matrix content URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hs = parts[1]
|
||||||
|
id = parts[2]
|
||||||
|
|
||||||
|
cacheFile := c.GetCachePath(hs, id)
|
||||||
|
if _, err = os.Stat(cacheFile); err != nil {
|
||||||
|
data, err = ioutil.ReadFile(cacheFile)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dlURL, _ := url.Parse(c.client.HomeserverURL.String())
|
||||||
|
dlURL.Path = path.Join(dlURL.Path, "/_matrix/media/v1/download", hs, id)
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
resp, err = c.client.Client.Get(dlURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data = buf.Bytes()
|
||||||
|
|
||||||
|
err = ioutil.WriteFile(cacheFile, data, 0600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) GetCachePath(homeserver, fileID string) string {
|
||||||
|
dir := filepath.Join(c.config.MediaDir, homeserver)
|
||||||
|
|
||||||
|
err := os.MkdirAll(dir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, fileID)
|
||||||
|
}
|
||||||
|
@ -94,11 +94,7 @@ func (room *Room) UpdateState(event *gomatrix.Event) {
|
|||||||
room.memberCache = nil
|
room.memberCache = nil
|
||||||
room.firstMemberCache = ""
|
room.firstMemberCache = ""
|
||||||
fallthrough
|
fallthrough
|
||||||
case "m.room.name":
|
case "m.room.name", "m.room.canonical_alias", "m.room.alias":
|
||||||
fallthrough
|
|
||||||
case "m.room.canonical_alias":
|
|
||||||
fallthrough
|
|
||||||
case "m.room.alias":
|
|
||||||
room.nameCache = ""
|
room.nameCache = ""
|
||||||
case "m.room.topic":
|
case "m.room.topic":
|
||||||
room.topicCache = ""
|
room.topicCache = ""
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
// Package debug contains utilities to display debug messages while running an interactive tview program.
|
|
||||||
package debug
|
|
464
ui/message-view.go
Normal file
464
ui/message-view.go
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/lib/open"
|
||||||
|
"maunium.net/go/gomuks/ui/messages"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
"maunium.net/go/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageView struct {
|
||||||
|
*tview.Box
|
||||||
|
|
||||||
|
parent *RoomView
|
||||||
|
|
||||||
|
ScrollOffset int
|
||||||
|
MaxSenderWidth int
|
||||||
|
DateFormat string
|
||||||
|
TimestampFormat string
|
||||||
|
TimestampWidth int
|
||||||
|
LoadingMessages bool
|
||||||
|
|
||||||
|
widestSender int
|
||||||
|
prevWidth int
|
||||||
|
prevHeight int
|
||||||
|
prevMsgCount int
|
||||||
|
|
||||||
|
messageIDs map[string]messages.UIMessage
|
||||||
|
messages []messages.UIMessage
|
||||||
|
|
||||||
|
textBuffer []tstring.TString
|
||||||
|
metaBuffer []ifc.MessageMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessageView(parent *RoomView) *MessageView {
|
||||||
|
return &MessageView{
|
||||||
|
Box: tview.NewBox(),
|
||||||
|
parent: parent,
|
||||||
|
|
||||||
|
MaxSenderWidth: 15,
|
||||||
|
TimestampWidth: len(messages.TimeFormat),
|
||||||
|
ScrollOffset: 0,
|
||||||
|
|
||||||
|
messages: make([]messages.UIMessage, 0),
|
||||||
|
messageIDs: make(map[string]messages.UIMessage),
|
||||||
|
textBuffer: make([]tstring.TString, 0),
|
||||||
|
metaBuffer: make([]ifc.MessageMeta, 0),
|
||||||
|
|
||||||
|
widestSender: 5,
|
||||||
|
prevWidth: -1,
|
||||||
|
prevHeight: -1,
|
||||||
|
prevMsgCount: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) SaveHistory(path string) error {
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
enc := gob.NewEncoder(file)
|
||||||
|
err = enc.Encode(view.messages)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) LoadHistory(gmx ifc.Gomuks, path string) (int, error) {
|
||||||
|
file, err := os.OpenFile(path, os.O_RDONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var msgs []messages.UIMessage
|
||||||
|
|
||||||
|
dec := gob.NewDecoder(file)
|
||||||
|
err = dec.Decode(&msgs)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
view.messages = make([]messages.UIMessage, len(msgs))
|
||||||
|
indexOffset := 0
|
||||||
|
for index, message := range msgs {
|
||||||
|
if message != nil {
|
||||||
|
view.messages[index-indexOffset] = message
|
||||||
|
view.updateWidestSender(message.Sender())
|
||||||
|
message.RegisterGomuks(gmx)
|
||||||
|
} else {
|
||||||
|
indexOffset++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(view.messages), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) updateWidestSender(sender string) {
|
||||||
|
if len(sender) > view.widestSender {
|
||||||
|
view.widestSender = len(sender)
|
||||||
|
if view.widestSender > view.MaxSenderWidth {
|
||||||
|
view.widestSender = view.MaxSenderWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) UpdateMessageID(ifcMessage ifc.Message, newID string) {
|
||||||
|
message, ok := ifcMessage.(messages.UIMessage)
|
||||||
|
if !ok {
|
||||||
|
debug.Print("[Warning] Passed non-UIMessage ifc.Message object to UpdateMessageID().")
|
||||||
|
debug.PrintStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(view.messageIDs, message.ID())
|
||||||
|
message.SetID(newID)
|
||||||
|
view.messageIDs[message.ID()] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction ifc.MessageDirection) {
|
||||||
|
if ifcMessage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message, ok := ifcMessage.(messages.UIMessage)
|
||||||
|
if !ok {
|
||||||
|
debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().")
|
||||||
|
debug.PrintStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldMsg, messageExists := view.messageIDs[message.ID()]
|
||||||
|
if messageExists {
|
||||||
|
oldMsg.CopyFrom(message)
|
||||||
|
message = oldMsg
|
||||||
|
direction = ifc.IgnoreMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
view.updateWidestSender(message.Sender())
|
||||||
|
|
||||||
|
_, _, width, _ := view.GetInnerRect()
|
||||||
|
width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
|
||||||
|
message.CalculateBuffer(width)
|
||||||
|
|
||||||
|
if direction == ifc.AppendMessage {
|
||||||
|
if view.ScrollOffset > 0 {
|
||||||
|
view.ScrollOffset += message.Height()
|
||||||
|
}
|
||||||
|
view.messages = append(view.messages, message)
|
||||||
|
view.appendBuffer(message)
|
||||||
|
} else if direction == ifc.PrependMessage {
|
||||||
|
view.messages = append([]messages.UIMessage{message}, view.messages...)
|
||||||
|
} else {
|
||||||
|
view.replaceBuffer(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
view.messageIDs[message.ID()] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) appendBuffer(message messages.UIMessage) {
|
||||||
|
if len(view.metaBuffer) > 0 {
|
||||||
|
prevMeta := view.metaBuffer[len(view.metaBuffer)-1]
|
||||||
|
if prevMeta != nil && prevMeta.FormatDate() != message.FormatDate() {
|
||||||
|
view.textBuffer = append(view.textBuffer, tstring.NewColorTString(
|
||||||
|
fmt.Sprintf("Date changed to %s", message.FormatDate()),
|
||||||
|
tcell.ColorGreen))
|
||||||
|
view.metaBuffer = append(view.metaBuffer, &messages.BasicMeta{
|
||||||
|
BTimestampColor: tcell.ColorDefault, BTextColor: tcell.ColorGreen})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view.textBuffer = append(view.textBuffer, message.Buffer()...)
|
||||||
|
for range message.Buffer() {
|
||||||
|
view.metaBuffer = append(view.metaBuffer, message)
|
||||||
|
}
|
||||||
|
view.prevMsgCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) replaceBuffer(message messages.UIMessage) {
|
||||||
|
start := -1
|
||||||
|
end := -1
|
||||||
|
for index, meta := range view.metaBuffer {
|
||||||
|
if meta == message {
|
||||||
|
if start == -1 {
|
||||||
|
start = index
|
||||||
|
}
|
||||||
|
end = index
|
||||||
|
} else if start != -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(view.textBuffer) > end {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
view.textBuffer = append(append(view.textBuffer[0:start], message.Buffer()...), view.textBuffer[end:]...)
|
||||||
|
if len(message.Buffer()) != end-start+1 {
|
||||||
|
metaBuffer := view.metaBuffer[0:start]
|
||||||
|
for range message.Buffer() {
|
||||||
|
metaBuffer = append(metaBuffer, message)
|
||||||
|
}
|
||||||
|
view.metaBuffer = append(metaBuffer, view.metaBuffer[end:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) recalculateBuffers() {
|
||||||
|
_, _, width, height := view.GetInnerRect()
|
||||||
|
|
||||||
|
width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
|
||||||
|
recalculateMessageBuffers := width != view.prevWidth
|
||||||
|
if height != view.prevHeight || recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
|
||||||
|
view.textBuffer = []tstring.TString{}
|
||||||
|
view.metaBuffer = []ifc.MessageMeta{}
|
||||||
|
view.prevMsgCount = 0
|
||||||
|
for i, message := range view.messages {
|
||||||
|
if message == nil {
|
||||||
|
debug.Print("O.o found nil message at", i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if recalculateMessageBuffers {
|
||||||
|
message.CalculateBuffer(width)
|
||||||
|
}
|
||||||
|
view.appendBuffer(message)
|
||||||
|
}
|
||||||
|
view.prevHeight = height
|
||||||
|
view.prevWidth = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) HandleClick(x, y int, button tcell.ButtonMask) bool {
|
||||||
|
if button != tcell.Button1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, height := view.GetRect()
|
||||||
|
line := view.TotalHeight() - view.ScrollOffset - height + y
|
||||||
|
if line < 0 || line >= view.TotalHeight() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
message := view.metaBuffer[line]
|
||||||
|
var prevMessage ifc.MessageMeta
|
||||||
|
if line > 0 {
|
||||||
|
prevMessage = view.metaBuffer[line-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameX := view.TimestampWidth + TimestampSenderGap
|
||||||
|
messageX := usernameX + view.widestSender + SenderMessageGap
|
||||||
|
if x >= messageX {
|
||||||
|
switch message := message.(type) {
|
||||||
|
case *messages.ImageMessage:
|
||||||
|
open.Open(message.Path())
|
||||||
|
case messages.UIMessage:
|
||||||
|
debug.Print("Message clicked:", message.NotificationContent())
|
||||||
|
}
|
||||||
|
} else if x >= usernameX {
|
||||||
|
uiMessage, ok := message.(messages.UIMessage)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
prevUIMessage, _ := prevMessage.(messages.UIMessage)
|
||||||
|
if prevUIMessage != nil && prevUIMessage.Sender() == uiMessage.Sender() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := []rune(uiMessage.Sender())
|
||||||
|
if len(sender) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorPos := view.parent.input.GetCursorOffset()
|
||||||
|
text := []rune(view.parent.input.GetText())
|
||||||
|
var newText []rune
|
||||||
|
if cursorPos == 0 {
|
||||||
|
newText = append(sender, ':', ' ')
|
||||||
|
newText = append(newText, text...)
|
||||||
|
} else {
|
||||||
|
newText = append(text[0:cursorPos], sender...)
|
||||||
|
newText = append(newText, ' ')
|
||||||
|
newText = append(newText, text[cursorPos:]...)
|
||||||
|
}
|
||||||
|
view.parent.input.SetText(string(newText))
|
||||||
|
view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaddingAtTop = 5
|
||||||
|
|
||||||
|
func (view *MessageView) AddScrollOffset(diff int) {
|
||||||
|
_, _, _, height := view.GetInnerRect()
|
||||||
|
|
||||||
|
totalHeight := view.TotalHeight()
|
||||||
|
if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop {
|
||||||
|
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
||||||
|
} else {
|
||||||
|
view.ScrollOffset += diff
|
||||||
|
}
|
||||||
|
|
||||||
|
if view.ScrollOffset > totalHeight-height+PaddingAtTop {
|
||||||
|
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
||||||
|
}
|
||||||
|
if view.ScrollOffset < 0 {
|
||||||
|
view.ScrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) Height() int {
|
||||||
|
_, _, _, height := view.GetInnerRect()
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) TotalHeight() int {
|
||||||
|
return len(view.textBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) IsAtTop() bool {
|
||||||
|
_, _, _, height := view.GetInnerRect()
|
||||||
|
totalHeight := len(view.textBuffer)
|
||||||
|
return view.ScrollOffset >= totalHeight-height+PaddingAtTop
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
TimestampSenderGap = 1
|
||||||
|
SenderSeparatorGap = 1
|
||||||
|
SenderMessageGap = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) {
|
||||||
|
char = '│'
|
||||||
|
style = tcell.StyleDefault
|
||||||
|
if scrollbarHere {
|
||||||
|
style = style.Foreground(tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
if isTop {
|
||||||
|
if scrollbarHere {
|
||||||
|
char = '╥'
|
||||||
|
} else {
|
||||||
|
char = '┬'
|
||||||
|
}
|
||||||
|
} else if isBottom {
|
||||||
|
if scrollbarHere {
|
||||||
|
char = '╨'
|
||||||
|
} else {
|
||||||
|
char = '┴'
|
||||||
|
}
|
||||||
|
} else if scrollbarHere {
|
||||||
|
char = '║'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *MessageView) Draw(screen tcell.Screen) {
|
||||||
|
view.Box.Draw(screen)
|
||||||
|
|
||||||
|
x, y, _, height := view.GetInnerRect()
|
||||||
|
view.recalculateBuffers()
|
||||||
|
|
||||||
|
if view.TotalHeight() == 0 {
|
||||||
|
widget.WriteLineSimple(screen, "It's quite empty in here.", x, y+height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameX := x + view.TimestampWidth + TimestampSenderGap
|
||||||
|
messageX := usernameX + view.widestSender + SenderMessageGap
|
||||||
|
separatorX := usernameX + view.widestSender + SenderSeparatorGap
|
||||||
|
|
||||||
|
indexOffset := view.TotalHeight() - view.ScrollOffset - height
|
||||||
|
if indexOffset <= -PaddingAtTop {
|
||||||
|
message := "Scroll up to load more messages."
|
||||||
|
if view.LoadingMessages {
|
||||||
|
message = "Loading more messages..."
|
||||||
|
}
|
||||||
|
widget.WriteLineSimpleColor(screen, message, messageX, y, tcell.ColorGreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(view.textBuffer) != len(view.metaBuffer) {
|
||||||
|
debug.Printf("Unexpected text/meta buffer length mismatch: %d != %d.", len(view.textBuffer), len(view.metaBuffer))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollBarHeight, scrollBarPos int
|
||||||
|
// Black magic (aka math) used to figure out where the scroll bar should be put.
|
||||||
|
{
|
||||||
|
viewportHeight := float64(height)
|
||||||
|
contentHeight := float64(view.TotalHeight())
|
||||||
|
|
||||||
|
scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight)))
|
||||||
|
|
||||||
|
scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevMeta ifc.MessageMeta
|
||||||
|
firstLine := true
|
||||||
|
skippedLines := 0
|
||||||
|
|
||||||
|
for line := 0; line < height; line++ {
|
||||||
|
index := indexOffset + line
|
||||||
|
if index < 0 {
|
||||||
|
skippedLines++
|
||||||
|
continue
|
||||||
|
} else if index >= view.TotalHeight() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
showScrollbar := line-skippedLines >= scrollBarPos-scrollBarHeight && line-skippedLines < scrollBarPos
|
||||||
|
isTop := firstLine && view.ScrollOffset+height >= view.TotalHeight()
|
||||||
|
isBottom := line == height-1 && view.ScrollOffset == 0
|
||||||
|
|
||||||
|
borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom)
|
||||||
|
|
||||||
|
firstLine = false
|
||||||
|
|
||||||
|
screen.SetContent(separatorX, y+line, borderChar, nil, borderStyle)
|
||||||
|
|
||||||
|
text, meta := view.textBuffer[index], view.metaBuffer[index]
|
||||||
|
if meta != prevMeta {
|
||||||
|
if len(meta.FormatTime()) > 0 {
|
||||||
|
widget.WriteLineSimpleColor(screen, meta.FormatTime(), x, y+line, meta.TimestampColor())
|
||||||
|
}
|
||||||
|
if prevMeta == nil || meta.Sender() != prevMeta.Sender() {
|
||||||
|
widget.WriteLineColor(
|
||||||
|
screen, tview.AlignRight, meta.Sender(),
|
||||||
|
usernameX, y+line, view.widestSender,
|
||||||
|
meta.SenderColor())
|
||||||
|
}
|
||||||
|
prevMeta = meta
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Draw(screen, messageX, y+line)
|
||||||
|
}
|
||||||
|
}
|
234
ui/messages/base.go
Normal file
234
ui/messages/base.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(&BaseMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseMessage struct {
|
||||||
|
MsgID string
|
||||||
|
MsgType string
|
||||||
|
MsgSender string
|
||||||
|
MsgSenderColor tcell.Color
|
||||||
|
MsgTimestamp time.Time
|
||||||
|
MsgState ifc.MessageState
|
||||||
|
MsgIsHighlight bool
|
||||||
|
MsgIsService bool
|
||||||
|
buffer []tstring.TString
|
||||||
|
prevBufferWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBaseMessage(id, sender, msgtype string, timestamp time.Time) BaseMessage {
|
||||||
|
return BaseMessage{
|
||||||
|
MsgSender: sender,
|
||||||
|
MsgTimestamp: timestamp,
|
||||||
|
MsgSenderColor: widget.GetHashColor(sender),
|
||||||
|
MsgType: msgtype,
|
||||||
|
MsgID: id,
|
||||||
|
prevBufferWidth: 0,
|
||||||
|
MsgState: ifc.MessageStateDefault,
|
||||||
|
MsgIsHighlight: false,
|
||||||
|
MsgIsService: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) RegisterGomuks(gmx ifc.Gomuks) {}
|
||||||
|
|
||||||
|
// CopyFrom replaces the content of this message object with the content of the given object.
|
||||||
|
func (msg *BaseMessage) CopyFrom(from ifc.MessageMeta) {
|
||||||
|
msg.MsgSender = from.Sender()
|
||||||
|
msg.MsgSenderColor = from.SenderColor()
|
||||||
|
|
||||||
|
fromMsg, ok := from.(UIMessage)
|
||||||
|
if ok {
|
||||||
|
msg.MsgSender = fromMsg.RealSender()
|
||||||
|
msg.MsgID = fromMsg.ID()
|
||||||
|
msg.MsgType = fromMsg.Type()
|
||||||
|
msg.MsgTimestamp = fromMsg.Timestamp()
|
||||||
|
msg.MsgState = fromMsg.State()
|
||||||
|
msg.MsgIsService = fromMsg.IsService()
|
||||||
|
msg.MsgIsHighlight = fromMsg.IsHighlight()
|
||||||
|
msg.buffer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender gets the string that should be displayed as the sender of this message.
|
||||||
|
//
|
||||||
|
// If the message is being sent, the sender is "Sending...".
|
||||||
|
// If sending has failed, the sender is "Error".
|
||||||
|
// If the message is an emote, the sender is blank.
|
||||||
|
// In any other case, the sender is the display name of the user who sent the message.
|
||||||
|
func (msg *BaseMessage) Sender() string {
|
||||||
|
switch msg.MsgState {
|
||||||
|
case ifc.MessageStateSending:
|
||||||
|
return "Sending..."
|
||||||
|
case ifc.MessageStateFailed:
|
||||||
|
return "Error"
|
||||||
|
}
|
||||||
|
switch msg.MsgType {
|
||||||
|
case "m.emote":
|
||||||
|
// Emotes don't show a separate sender, it's included in the buffer.
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return msg.MsgSender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) RealSender() string {
|
||||||
|
return msg.MsgSender
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) getStateSpecificColor() tcell.Color {
|
||||||
|
switch msg.MsgState {
|
||||||
|
case ifc.MessageStateSending:
|
||||||
|
return tcell.ColorGray
|
||||||
|
case ifc.MessageStateFailed:
|
||||||
|
return tcell.ColorRed
|
||||||
|
case ifc.MessageStateDefault:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return tcell.ColorDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderColor returns the color the name of the sender should be shown in.
|
||||||
|
//
|
||||||
|
// If the message is being sent, the color is gray.
|
||||||
|
// If sending has failed, the color is red.
|
||||||
|
//
|
||||||
|
// In any other case, the color is whatever is specified in the Message struct.
|
||||||
|
// Usually that means it is the hash-based color of the sender (see ui/widget/color.go)
|
||||||
|
func (msg *BaseMessage) SenderColor() tcell.Color {
|
||||||
|
stateColor := msg.getStateSpecificColor()
|
||||||
|
switch {
|
||||||
|
case stateColor != tcell.ColorDefault:
|
||||||
|
return stateColor
|
||||||
|
case msg.MsgIsService:
|
||||||
|
return tcell.ColorGray
|
||||||
|
default:
|
||||||
|
return msg.MsgSenderColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextColor returns the color the actual content of the message should be shown in.
|
||||||
|
func (msg *BaseMessage) TextColor() tcell.Color {
|
||||||
|
stateColor := msg.getStateSpecificColor()
|
||||||
|
switch {
|
||||||
|
case stateColor != tcell.ColorDefault:
|
||||||
|
return stateColor
|
||||||
|
case msg.MsgIsService, msg.MsgType == "m.notice":
|
||||||
|
return tcell.ColorGray
|
||||||
|
case msg.MsgIsHighlight:
|
||||||
|
return tcell.ColorYellow
|
||||||
|
case msg.MsgType == "m.room.member":
|
||||||
|
return tcell.ColorGreen
|
||||||
|
default:
|
||||||
|
return tcell.ColorDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampColor returns the color the timestamp should be shown in.
|
||||||
|
//
|
||||||
|
// As with SenderColor(), messages being sent and messages that failed to be sent are
|
||||||
|
// gray and red respectively.
|
||||||
|
//
|
||||||
|
// However, other messages are the default color instead of a color stored in the struct.
|
||||||
|
func (msg *BaseMessage) TimestampColor() tcell.Color {
|
||||||
|
return msg.getStateSpecificColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer returns the computed text buffer.
|
||||||
|
//
|
||||||
|
// The buffer contains the text of the message split into lines with a maximum
|
||||||
|
// width of whatever was provided to CalculateBuffer().
|
||||||
|
//
|
||||||
|
// N.B. This will NOT automatically calculate the buffer if it hasn't been
|
||||||
|
// calculated already, as that requires the target width.
|
||||||
|
func (msg *BaseMessage) Buffer() []tstring.TString {
|
||||||
|
return msg.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height returns the number of rows in the computed buffer (see Buffer()).
|
||||||
|
func (msg *BaseMessage) Height() int {
|
||||||
|
return len(msg.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp returns the full timestamp when the message was sent.
|
||||||
|
func (msg *BaseMessage) Timestamp() time.Time {
|
||||||
|
return msg.MsgTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime returns the formatted time when the message was sent.
|
||||||
|
func (msg *BaseMessage) FormatTime() string {
|
||||||
|
return msg.MsgTimestamp.Format(TimeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDate returns the formatted date when the message was sent.
|
||||||
|
func (msg *BaseMessage) FormatDate() string {
|
||||||
|
return msg.MsgTimestamp.Format(DateFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) ID() string {
|
||||||
|
return msg.MsgID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) SetID(id string) {
|
||||||
|
msg.MsgID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) Type() string {
|
||||||
|
return msg.MsgType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) SetType(msgtype string) {
|
||||||
|
msg.MsgType = msgtype
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) State() ifc.MessageState {
|
||||||
|
return msg.MsgState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) SetState(state ifc.MessageState) {
|
||||||
|
msg.MsgState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) IsHighlight() bool {
|
||||||
|
return msg.MsgIsHighlight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) SetIsHighlight(isHighlight bool) {
|
||||||
|
msg.MsgIsHighlight = isHighlight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) IsService() bool {
|
||||||
|
return msg.MsgIsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *BaseMessage) SetIsService(isService bool) {
|
||||||
|
msg.MsgIsService = isService
|
||||||
|
}
|
2
ui/messages/doc.go
Normal file
2
ui/messages/doc.go
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Package messages contains different message types and code to generate and render them.
|
||||||
|
package messages
|
71
ui/messages/expandedtextmessage.go
Normal file
71
ui/messages/expandedtextmessage.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(&ExpandedTextMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpandedTextMessage struct {
|
||||||
|
BaseTextMessage
|
||||||
|
MsgText tstring.TString
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state.
|
||||||
|
func NewExpandedTextMessage(id, sender, msgtype string, text tstring.TString, timestamp time.Time) UIMessage {
|
||||||
|
return &ExpandedTextMessage{
|
||||||
|
BaseTextMessage: newBaseTextMessage(id, sender, msgtype, timestamp),
|
||||||
|
MsgText: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) GenerateText() tstring.TString {
|
||||||
|
return msg.MsgText
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFrom replaces the content of this message object with the content of the given object.
|
||||||
|
func (msg *ExpandedTextMessage) CopyFrom(from ifc.MessageMeta) {
|
||||||
|
msg.BaseTextMessage.CopyFrom(from)
|
||||||
|
|
||||||
|
fromExpandedMsg, ok := from.(*ExpandedTextMessage)
|
||||||
|
if ok {
|
||||||
|
msg.MsgText = fromExpandedMsg.MsgText
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.RecalculateBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) NotificationContent() string {
|
||||||
|
return msg.MsgText.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ExpandedTextMessage) CalculateBuffer(width int) {
|
||||||
|
msg.BaseTextMessage.calculateBufferWithText(msg.MsgText, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecalculateBuffer calculates the buffer again with the previously provided width.
|
||||||
|
func (msg *ExpandedTextMessage) RecalculateBuffer() {
|
||||||
|
msg.CalculateBuffer(msg.prevBufferWidth)
|
||||||
|
}
|
123
ui/messages/imagemessage.go
Normal file
123
ui/messages/imagemessage.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/lib/ansimage"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(&ImageMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageMessage struct {
|
||||||
|
BaseMessage
|
||||||
|
Homeserver string
|
||||||
|
FileID string
|
||||||
|
data []byte
|
||||||
|
|
||||||
|
gmx ifc.Gomuks
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewImageMessage creates a new ImageMessage object with the provided values and the default state.
|
||||||
|
func NewImageMessage(gmx ifc.Gomuks, id, sender, msgtype, homeserver, fileID string, data []byte, timestamp time.Time) UIMessage {
|
||||||
|
return &ImageMessage{
|
||||||
|
newBaseMessage(id, sender, msgtype, timestamp),
|
||||||
|
homeserver,
|
||||||
|
fileID,
|
||||||
|
data,
|
||||||
|
gmx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ImageMessage) RegisterGomuks(gmx ifc.Gomuks) {
|
||||||
|
msg.gmx = gmx
|
||||||
|
|
||||||
|
debug.Print(len(msg.data), msg.data)
|
||||||
|
if len(msg.data) == 0 {
|
||||||
|
go func() {
|
||||||
|
defer gmx.Recover()
|
||||||
|
msg.updateData()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ImageMessage) NotificationContent() string {
|
||||||
|
return "Sent an image"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ImageMessage) updateData() {
|
||||||
|
debug.Print("Loading image:", msg.Homeserver, msg.FileID)
|
||||||
|
data, _, _, err := msg.gmx.Matrix().Download(fmt.Sprintf("mxc://%s/%s", msg.Homeserver, msg.FileID))
|
||||||
|
if err != nil {
|
||||||
|
debug.Print("Failed to download image %s/%s: %v", msg.Homeserver, msg.FileID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *ImageMessage) Path() string {
|
||||||
|
return msg.gmx.Matrix().GetCachePath(msg.Homeserver, msg.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFrom replaces the content of this message object with the content of the given object.
|
||||||
|
func (msg *ImageMessage) CopyFrom(from ifc.MessageMeta) {
|
||||||
|
msg.BaseMessage.CopyFrom(from)
|
||||||
|
|
||||||
|
fromImgMsg, ok := from.(*ImageMessage)
|
||||||
|
if ok {
|
||||||
|
msg.data = fromImgMsg.data
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.RecalculateBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateBuffer generates the internal buffer for this message that consists
|
||||||
|
// of the text of this message split into lines at most as wide as the width
|
||||||
|
// parameter.
|
||||||
|
func (msg *ImageMessage) CalculateBuffer(width int) {
|
||||||
|
if width < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.data), 0, width, color.Black)
|
||||||
|
if err != nil {
|
||||||
|
msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)}
|
||||||
|
debug.Print("Failed to display image:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.buffer = image.Render()
|
||||||
|
msg.prevBufferWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecalculateBuffer calculates the buffer again with the previously provided width.
|
||||||
|
func (msg *ImageMessage) RecalculateBuffer() {
|
||||||
|
msg.CalculateBuffer(msg.prevBufferWidth)
|
||||||
|
}
|
||||||
|
|
38
ui/messages/message.go
Normal file
38
ui/messages/message.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is a wrapper for the content and metadata of a Matrix message intended to be displayed.
|
||||||
|
type UIMessage interface {
|
||||||
|
ifc.Message
|
||||||
|
|
||||||
|
CalculateBuffer(width int)
|
||||||
|
RecalculateBuffer()
|
||||||
|
Buffer() []tstring.TString
|
||||||
|
Height() int
|
||||||
|
|
||||||
|
RealSender() string
|
||||||
|
RegisterGomuks(gmx ifc.Gomuks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateFormat = "January _2, 2006"
|
||||||
|
const TimeFormat = "15:04:05"
|
77
ui/messages/meta.go
Normal file
77
ui/messages/meta.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BasicMeta is a simple variable store implementation of MessageMeta.
|
||||||
|
type BasicMeta struct {
|
||||||
|
BSender string
|
||||||
|
BTimestamp time.Time
|
||||||
|
BSenderColor, BTextColor, BTimestampColor tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender gets the string that should be displayed as the sender of this message.
|
||||||
|
func (meta *BasicMeta) Sender() string {
|
||||||
|
return meta.BSender
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderColor returns the color the name of the sender should be shown in.
|
||||||
|
func (meta *BasicMeta) SenderColor() tcell.Color {
|
||||||
|
return meta.BSenderColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp returns the full time when the message was sent.
|
||||||
|
func (meta *BasicMeta) Timestamp() time.Time {
|
||||||
|
return meta.BTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime returns the formatted time when the message was sent.
|
||||||
|
func (meta *BasicMeta) FormatTime() string {
|
||||||
|
return meta.BTimestamp.Format(TimeFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDate returns the formatted date when the message was sent.
|
||||||
|
func (meta *BasicMeta) FormatDate() string {
|
||||||
|
return meta.BTimestamp.Format(DateFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextColor returns the color the actual content of the message should be shown in.
|
||||||
|
func (meta *BasicMeta) TextColor() tcell.Color {
|
||||||
|
return meta.BTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampColor returns the color the timestamp should be shown in.
|
||||||
|
//
|
||||||
|
// This usually does not apply to the date, as it is rendered separately from the message.
|
||||||
|
func (meta *BasicMeta) TimestampColor() tcell.Color {
|
||||||
|
return meta.BTimestampColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFrom replaces the content of this meta object with the content of the given object.
|
||||||
|
func (meta *BasicMeta) CopyFrom(from ifc.MessageMeta) {
|
||||||
|
meta.BSender = from.Sender()
|
||||||
|
meta.BTimestamp = from.Timestamp()
|
||||||
|
meta.BSenderColor = from.SenderColor()
|
||||||
|
meta.BTextColor = from.TextColor()
|
||||||
|
meta.BTimestampColor = from.TimestampColor()
|
||||||
|
}
|
186
ui/messages/parser/htmlparser.go
Normal file
186
ui/messages/parser/htmlparser.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/gomatrix"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/lib/htmlparser"
|
||||||
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
var matrixToURL = regexp.MustCompile("^(?:https?://)?(?:www\\.)?matrix\\.to/#/([#@!].*)")
|
||||||
|
|
||||||
|
type MatrixHTMLProcessor struct {
|
||||||
|
text tstring.TString
|
||||||
|
|
||||||
|
indent string
|
||||||
|
listType string
|
||||||
|
lineIsNew bool
|
||||||
|
openTags *TagArray
|
||||||
|
|
||||||
|
room *rooms.Room
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) newline() {
|
||||||
|
if !parser.lineIsNew {
|
||||||
|
parser.text = parser.text.Append("\n" + parser.indent)
|
||||||
|
parser.lineIsNew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) Preprocess() {}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) HandleText(text string) {
|
||||||
|
style := tcell.StyleDefault
|
||||||
|
for _, tag := range *parser.openTags {
|
||||||
|
switch tag.Tag {
|
||||||
|
case "b", "strong":
|
||||||
|
style = style.Bold(true)
|
||||||
|
case "i", "em":
|
||||||
|
style = style.Italic(true)
|
||||||
|
case "s", "del":
|
||||||
|
style = style.Strikethrough(true)
|
||||||
|
case "u", "ins":
|
||||||
|
style = style.Underline(true)
|
||||||
|
case "a":
|
||||||
|
tag.Text += text
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !parser.openTags.Has("pre", "code") {
|
||||||
|
text = strings.Replace(text, "\n", "", -1)
|
||||||
|
}
|
||||||
|
parser.text = parser.text.AppendStyle(text, style)
|
||||||
|
parser.lineIsNew = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) HandleStartTag(tagName string, attrs map[string]string) {
|
||||||
|
tag := &TagWithMeta{Tag: tagName}
|
||||||
|
switch tag.Tag {
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
length := int(tag.Tag[1] - '0')
|
||||||
|
parser.text = parser.text.Append(strings.Repeat("#", length) + " ")
|
||||||
|
parser.lineIsNew = false
|
||||||
|
case "a":
|
||||||
|
tag.Meta, _ = attrs["href"]
|
||||||
|
case "ol", "ul":
|
||||||
|
parser.listType = tag.Tag
|
||||||
|
case "li":
|
||||||
|
indentSize := 2
|
||||||
|
if parser.listType == "ol" {
|
||||||
|
list := parser.openTags.Get(parser.listType)
|
||||||
|
list.Counter++
|
||||||
|
parser.text = parser.text.Append(fmt.Sprintf("%d. ", list.Counter))
|
||||||
|
indentSize = int(math.Log10(float64(list.Counter))+1) + len(". ")
|
||||||
|
} else {
|
||||||
|
parser.text = parser.text.Append("* ")
|
||||||
|
}
|
||||||
|
parser.indent += strings.Repeat(" ", indentSize)
|
||||||
|
parser.lineIsNew = false
|
||||||
|
case "blockquote":
|
||||||
|
parser.indent += "> "
|
||||||
|
parser.text = parser.text.Append("> ")
|
||||||
|
parser.lineIsNew = false
|
||||||
|
}
|
||||||
|
parser.openTags.PushMeta(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) HandleSelfClosingTag(tagName string, attrs map[string]string) {
|
||||||
|
if tagName == "br" {
|
||||||
|
parser.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) HandleEndTag(tagName string) {
|
||||||
|
tag := parser.openTags.Pop(tagName)
|
||||||
|
|
||||||
|
switch tag.Tag {
|
||||||
|
case "li", "blockquote":
|
||||||
|
indentSize := 2
|
||||||
|
if tag.Tag == "li" && parser.listType == "ol" {
|
||||||
|
list := parser.openTags.Get(parser.listType)
|
||||||
|
indentSize = int(math.Log10(float64(list.Counter))+1) + len(". ")
|
||||||
|
}
|
||||||
|
if len(parser.indent) >= indentSize {
|
||||||
|
parser.indent = parser.indent[0 : len(parser.indent)-indentSize]
|
||||||
|
}
|
||||||
|
// TODO this newline is sometimes not good
|
||||||
|
parser.newline()
|
||||||
|
case "a":
|
||||||
|
match := matrixToURL.FindStringSubmatch(tag.Meta)
|
||||||
|
if len(match) == 2 {
|
||||||
|
pillTarget := match[1]
|
||||||
|
if pillTarget[0] == '@' {
|
||||||
|
if member := parser.room.GetMember(pillTarget); member != nil {
|
||||||
|
parser.text = parser.text.AppendColor(member.DisplayName, widget.GetHashColor(member.DisplayName))
|
||||||
|
} else {
|
||||||
|
parser.text = parser.text.Append(pillTarget)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parser.text = parser.text.Append(pillTarget)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO make text clickable rather than printing URL
|
||||||
|
parser.text = parser.text.Append(fmt.Sprintf("%s (%s)", tag.Text, tag.Meta))
|
||||||
|
}
|
||||||
|
parser.lineIsNew = false
|
||||||
|
case "p", "pre", "ol", "ul", "h1", "h2", "h3", "h4", "h5", "h6", "div":
|
||||||
|
// parser.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) ReceiveError(err error) {
|
||||||
|
if err != io.EOF {
|
||||||
|
debug.Print("Unexpected error parsing HTML:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *MatrixHTMLProcessor) Postprocess() {
|
||||||
|
if len(parser.text) > 0 && parser.text[len(parser.text)-1].Char == '\n' {
|
||||||
|
parser.text = parser.text[:len(parser.text)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHTMLMessage parses a HTML-formatted Matrix event into a UIMessage.
|
||||||
|
func ParseHTMLMessage(room *rooms.Room, evt *gomatrix.Event) tstring.TString {
|
||||||
|
htmlData, _ := evt.Content["formatted_body"].(string)
|
||||||
|
|
||||||
|
processor := &MatrixHTMLProcessor{
|
||||||
|
room: room,
|
||||||
|
text: tstring.NewBlankTString(),
|
||||||
|
indent: "",
|
||||||
|
listType: "",
|
||||||
|
lineIsNew: true,
|
||||||
|
openTags: &TagArray{},
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := htmlparser.NewHTMLParserFromString(htmlData, processor)
|
||||||
|
parser.Process()
|
||||||
|
|
||||||
|
return processor.text
|
||||||
|
}
|
118
ui/messages/parser/htmltagarray.go
Normal file
118
ui/messages/parser/htmltagarray.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
// TagWithMeta is an open HTML tag with some metadata (e.g. list index, a href value).
|
||||||
|
type TagWithMeta struct {
|
||||||
|
Tag string
|
||||||
|
Counter int
|
||||||
|
Meta string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlankTag is a blank TagWithMeta object.
|
||||||
|
var BlankTag = &TagWithMeta{}
|
||||||
|
|
||||||
|
// TagArray is a reversed queue for remembering what HTML tags are open.
|
||||||
|
type TagArray []*TagWithMeta
|
||||||
|
|
||||||
|
// Pushb converts the given byte array into a string and calls Push().
|
||||||
|
func (ta *TagArray) Pushb(tag []byte) {
|
||||||
|
ta.Push(string(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popb converts the given byte array into a string and calls Pop().
|
||||||
|
func (ta *TagArray) Popb(tag []byte) *TagWithMeta {
|
||||||
|
return ta.Pop(string(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexb converts the given byte array into a string and calls Index().
|
||||||
|
func (ta *TagArray) Indexb(tag []byte) {
|
||||||
|
ta.Index(string(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexAfterb converts the given byte array into a string and calls IndexAfter().
|
||||||
|
func (ta *TagArray) IndexAfterb(tag []byte, after int) {
|
||||||
|
ta.IndexAfter(string(tag), after)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push adds the given tag to the array.
|
||||||
|
func (ta *TagArray) Push(tag string) {
|
||||||
|
ta.PushMeta(&TagWithMeta{Tag: tag})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push adds the given tag to the array.
|
||||||
|
func (ta *TagArray) PushMeta(tag *TagWithMeta) {
|
||||||
|
*ta = append(*ta, BlankTag)
|
||||||
|
copy((*ta)[1:], *ta)
|
||||||
|
(*ta)[0] = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop removes the given tag from the array.
|
||||||
|
func (ta *TagArray) Pop(tag string) (removed *TagWithMeta) {
|
||||||
|
if (*ta)[0].Tag == tag {
|
||||||
|
// This is the default case and is lighter than append(), so we handle it separately.
|
||||||
|
removed = (*ta)[0]
|
||||||
|
*ta = (*ta)[1:]
|
||||||
|
} else if index := ta.Index(tag); index != -1 {
|
||||||
|
removed = (*ta)[index]
|
||||||
|
*ta = append((*ta)[:index], (*ta)[index+1:]...)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index returns the first index where the given tag is, or -1 if it's not in the list.
|
||||||
|
func (ta *TagArray) Index(tag string) int {
|
||||||
|
return ta.IndexAfter(tag, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexAfter returns the first index after the given index where the given tag is,
|
||||||
|
// or -1 if the given tag is not on the list after the given index.
|
||||||
|
func (ta *TagArray) IndexAfter(tag string, after int) int {
|
||||||
|
for i := after + 1; i < len(*ta); i++ {
|
||||||
|
if (*ta)[i].Tag == tag {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the first occurrence of the given tag, or nil if it's not in the list.
|
||||||
|
func (ta *TagArray) Get(tag string) *TagWithMeta {
|
||||||
|
return ta.GetAfter(tag, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexAfter returns the first occurrence of the given tag, or nil if the given
|
||||||
|
// tag is not on the list after the given index.
|
||||||
|
func (ta *TagArray) GetAfter(tag string, after int) *TagWithMeta {
|
||||||
|
for i := after + 1; i < len(*ta); i++ {
|
||||||
|
if (*ta)[i].Tag == tag {
|
||||||
|
return (*ta)[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has returns whether or not the list has at least one of the given tags.
|
||||||
|
func (ta *TagArray) Has(tags ...string) bool {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if index := ta.Index(tag); index != -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
128
ui/messages/parser/parser.go
Normal file
128
ui/messages/parser/parser.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/gomatrix"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
"maunium.net/go/gomuks/ui/messages"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseEvent(gmx ifc.Gomuks, room *rooms.Room, evt *gomatrix.Event) messages.UIMessage {
|
||||||
|
member := room.GetMember(evt.Sender)
|
||||||
|
if member != nil {
|
||||||
|
evt.Sender = member.DisplayName
|
||||||
|
}
|
||||||
|
switch evt.Type {
|
||||||
|
case "m.room.message":
|
||||||
|
return ParseMessage(gmx, room, evt)
|
||||||
|
case "m.room.member":
|
||||||
|
return ParseMembershipEvent(evt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixToTime(unix int64) time.Time {
|
||||||
|
timestamp := time.Now()
|
||||||
|
if unix != 0 {
|
||||||
|
timestamp = time.Unix(unix/1000, unix%1000*1000)
|
||||||
|
}
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMessage(gmx ifc.Gomuks, room *rooms.Room, evt *gomatrix.Event) messages.UIMessage {
|
||||||
|
msgtype, _ := evt.Content["msgtype"].(string)
|
||||||
|
ts := unixToTime(evt.Timestamp)
|
||||||
|
switch msgtype {
|
||||||
|
case "m.text", "m.notice", "m.emote":
|
||||||
|
format, hasFormat := evt.Content["format"].(string)
|
||||||
|
if hasFormat && format == "org.matrix.custom.html" {
|
||||||
|
text := ParseHTMLMessage(room, evt)
|
||||||
|
return messages.NewExpandedTextMessage(evt.ID, evt.Sender, msgtype, text, ts)
|
||||||
|
} else {
|
||||||
|
text, _ := evt.Content["body"].(string)
|
||||||
|
return messages.NewTextMessage(evt.ID, evt.Sender, msgtype, text, ts)
|
||||||
|
}
|
||||||
|
case "m.image":
|
||||||
|
url, _ := evt.Content["url"].(string)
|
||||||
|
data, hs, id, err := gmx.Matrix().Download(url)
|
||||||
|
if err != nil {
|
||||||
|
debug.Printf("Failed to download %s: %v", url, err)
|
||||||
|
}
|
||||||
|
return messages.NewImageMessage(gmx, evt.ID, evt.Sender, msgtype, hs, id, data, ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMembershipEventContent(evt *gomatrix.Event) (sender string, text tstring.TString) {
|
||||||
|
membership, _ := evt.Content["membership"].(string)
|
||||||
|
displayname, _ := evt.Content["displayname"].(string)
|
||||||
|
if len(displayname) == 0 {
|
||||||
|
displayname = *evt.StateKey
|
||||||
|
}
|
||||||
|
prevMembership := "leave"
|
||||||
|
prevDisplayname := ""
|
||||||
|
if evt.Unsigned.PrevContent != nil {
|
||||||
|
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
|
||||||
|
prevDisplayname, _ = evt.Unsigned.PrevContent["displayname"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if membership != prevMembership {
|
||||||
|
switch membership {
|
||||||
|
case "invite":
|
||||||
|
sender = "---"
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", evt.Sender, displayname), tcell.ColorGreen)
|
||||||
|
text.Colorize(0, len(evt.Sender), widget.GetHashColor(evt.Sender))
|
||||||
|
text.Colorize(len(evt.Sender)+len(" invited "), len(displayname), widget.GetHashColor(displayname))
|
||||||
|
case "join":
|
||||||
|
sender = "-->"
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen)
|
||||||
|
text.Colorize(0, len(displayname), widget.GetHashColor(displayname))
|
||||||
|
case "leave":
|
||||||
|
sender = "<--"
|
||||||
|
if evt.Sender != *evt.StateKey {
|
||||||
|
reason, _ := evt.Content["reason"].(string)
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", evt.Sender, displayname, reason), tcell.ColorRed)
|
||||||
|
text.Colorize(0, len(evt.Sender), widget.GetHashColor(evt.Sender))
|
||||||
|
text.Colorize(len(evt.Sender)+len(" kicked "), len(displayname), widget.GetHashColor(displayname))
|
||||||
|
} else {
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed)
|
||||||
|
text.Colorize(0, len(displayname), widget.GetHashColor(displayname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if displayname != prevDisplayname {
|
||||||
|
sender = "---"
|
||||||
|
text = tstring.NewColorTString(fmt.Sprintf("%s changed their display name to %s.", prevDisplayname, displayname), tcell.ColorYellow)
|
||||||
|
text.Colorize(0, len(prevDisplayname), widget.GetHashColor(prevDisplayname))
|
||||||
|
text.Colorize(len(prevDisplayname)+len(" changed their display name to "), len(displayname), widget.GetHashColor(displayname))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMembershipEvent(evt *gomatrix.Event) messages.UIMessage {
|
||||||
|
sender, text := getMembershipEventContent(evt)
|
||||||
|
ts := unixToTime(evt.Timestamp)
|
||||||
|
return messages.NewExpandedTextMessage(evt.ID, sender, "m.room.membership", text, ts)
|
||||||
|
}
|
84
ui/messages/textbase.go
Normal file
84
ui/messages/textbase.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(BaseTextMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseTextMessage struct {
|
||||||
|
BaseMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBaseTextMessage(id, sender, msgtype string, timestamp time.Time) BaseTextMessage {
|
||||||
|
return BaseTextMessage{newBaseMessage(id, sender, msgtype, timestamp)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular expressions used to split lines when calculating the buffer.
|
||||||
|
//
|
||||||
|
// From tview/textview.go
|
||||||
|
var (
|
||||||
|
boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)")
|
||||||
|
spacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculateBuffer generates the internal buffer for this message that consists
|
||||||
|
// of the text of this message split into lines at most as wide as the width
|
||||||
|
// parameter.
|
||||||
|
func (msg *BaseTextMessage) calculateBufferWithText(text tstring.TString, width int) {
|
||||||
|
if width < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.buffer = []tstring.TString{}
|
||||||
|
|
||||||
|
forcedLinebreaks := text.Split('\n')
|
||||||
|
newlines := 0
|
||||||
|
for _, str := range forcedLinebreaks {
|
||||||
|
if len(str) == 0 && newlines < 1 {
|
||||||
|
msg.buffer = append(msg.buffer, tstring.TString{})
|
||||||
|
newlines++
|
||||||
|
} else {
|
||||||
|
newlines = 0
|
||||||
|
}
|
||||||
|
// Mostly from tview/textview.go#reindexBuffer()
|
||||||
|
for len(str) > 0 {
|
||||||
|
extract := str.Truncate(width)
|
||||||
|
if len(extract) < len(str) {
|
||||||
|
if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 {
|
||||||
|
extract = str[:len(extract)+spaces[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := boundaryPattern.FindAllStringIndex(extract.String(), -1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
extract = extract[:matches[len(matches)-1][1]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.buffer = append(msg.buffer, extract)
|
||||||
|
str = str[len(extract):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.prevBufferWidth = width
|
||||||
|
}
|
102
ui/messages/textmessage.go
Normal file
102
ui/messages/textmessage.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(&TextMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextMessage struct {
|
||||||
|
BaseTextMessage
|
||||||
|
cache tstring.TString
|
||||||
|
MsgText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTextMessage creates a new UITextMessage object with the provided values and the default state.
|
||||||
|
func NewTextMessage(id, sender, msgtype, text string, timestamp time.Time) UIMessage {
|
||||||
|
return &TextMessage{
|
||||||
|
BaseTextMessage: newBaseTextMessage(id, sender, msgtype, timestamp),
|
||||||
|
MsgText: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) getCache() tstring.TString {
|
||||||
|
if msg.cache == nil {
|
||||||
|
switch msg.MsgType {
|
||||||
|
case "m.emote":
|
||||||
|
msg.cache = tstring.NewColorTString(fmt.Sprintf("* %s %s", msg.MsgSender, msg.MsgText), msg.TextColor())
|
||||||
|
msg.cache.Colorize(0, len(msg.MsgSender)+2, msg.SenderColor())
|
||||||
|
default:
|
||||||
|
msg.cache = tstring.NewColorTString(msg.MsgText, msg.TextColor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFrom replaces the content of this message object with the content of the given object.
|
||||||
|
func (msg *TextMessage) CopyFrom(from ifc.MessageMeta) {
|
||||||
|
msg.BaseTextMessage.CopyFrom(from)
|
||||||
|
|
||||||
|
fromTextMsg, ok := from.(*TextMessage)
|
||||||
|
if ok {
|
||||||
|
msg.MsgText = fromTextMsg.MsgText
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.cache = nil
|
||||||
|
msg.RecalculateBuffer()
|
||||||
|
}
|
||||||
|
func (msg *TextMessage) SetType(msgtype string) {
|
||||||
|
msg.BaseTextMessage.SetType(msgtype)
|
||||||
|
msg.cache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) SetState(state ifc.MessageState) {
|
||||||
|
msg.BaseTextMessage.SetState(state)
|
||||||
|
msg.cache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) SetIsHighlight(isHighlight bool) {
|
||||||
|
msg.BaseTextMessage.SetIsHighlight(isHighlight)
|
||||||
|
msg.cache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) SetIsService(isService bool) {
|
||||||
|
msg.BaseTextMessage.SetIsService(isService)
|
||||||
|
msg.cache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) NotificationContent() string {
|
||||||
|
return msg.MsgText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *TextMessage) CalculateBuffer(width int) {
|
||||||
|
msg.BaseTextMessage.calculateBufferWithText(msg.getCache(), width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecalculateBuffer calculates the buffer again with the previously provided width.
|
||||||
|
func (msg *TextMessage) RecalculateBuffer() {
|
||||||
|
msg.CalculateBuffer(msg.prevBufferWidth)
|
||||||
|
}
|
51
ui/messages/tstring/cell.go
Normal file
51
ui/messages/tstring/cell.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tstring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cell struct {
|
||||||
|
Char rune
|
||||||
|
Style tcell.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyleCell(char rune, style tcell.Style) Cell {
|
||||||
|
return Cell{char, style}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewColorCell(char rune, color tcell.Color) Cell {
|
||||||
|
return Cell{char, tcell.StyleDefault.Foreground(color)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCell(char rune) Cell {
|
||||||
|
return Cell{char, tcell.StyleDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cell Cell) RuneWidth() int {
|
||||||
|
return runewidth.RuneWidth(cell.Char)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cell Cell) Draw(screen tcell.Screen, x, y int) (chWidth int) {
|
||||||
|
chWidth = cell.RuneWidth()
|
||||||
|
for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ {
|
||||||
|
screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
173
ui/messages/tstring/string.go
Normal file
173
ui/messages/tstring/string.go
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package tstring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TString []Cell
|
||||||
|
|
||||||
|
func NewBlankTString() TString {
|
||||||
|
return make([]Cell, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTString(str string) TString {
|
||||||
|
newStr := make([]Cell, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewCell(char)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewColorTString(str string, color tcell.Color) TString {
|
||||||
|
newStr := make([]Cell, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewColorCell(char, color)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStyleTString(str string, style tcell.Style) TString {
|
||||||
|
newStr := make([]Cell, len(str))
|
||||||
|
for i, char := range str {
|
||||||
|
newStr[i] = NewStyleCell(char, style)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendTString(data TString) TString {
|
||||||
|
return append(str, data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Append(data string) TString {
|
||||||
|
newStr := make(TString, len(str)+len(data))
|
||||||
|
copy(newStr, str)
|
||||||
|
for i, char := range data {
|
||||||
|
newStr[i+len(str)] = NewCell(char)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendColor(data string, color tcell.Color) TString {
|
||||||
|
newStr := make(TString, len(str)+len(data))
|
||||||
|
copy(newStr, str)
|
||||||
|
for i, char := range data {
|
||||||
|
newStr[i+len(str)] = NewColorCell(char, color)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) AppendStyle(data string, style tcell.Style) TString {
|
||||||
|
newStr := make(TString, len(str)+len(data))
|
||||||
|
copy(newStr, str)
|
||||||
|
for i, char := range data {
|
||||||
|
newStr[i+len(str)] = NewStyleCell(char, style)
|
||||||
|
}
|
||||||
|
return newStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Colorize(from, length int, color tcell.Color) {
|
||||||
|
for i := from; i < from+length; i++ {
|
||||||
|
str[i].Style = str[i].Style.Foreground(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Draw(screen tcell.Screen, x, y int) {
|
||||||
|
offsetX := 0
|
||||||
|
for _, cell := range str {
|
||||||
|
offsetX += cell.Draw(screen, x+offsetX, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) RuneWidth() (width int) {
|
||||||
|
for _, cell := range str {
|
||||||
|
width += runewidth.RuneWidth(cell.Char)
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) String() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, cell := range str {
|
||||||
|
buf.WriteRune(cell.Char)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate return string truncated with w cells
|
||||||
|
func (str TString) Truncate(w int) TString {
|
||||||
|
if str.RuneWidth() <= w {
|
||||||
|
return str[:]
|
||||||
|
}
|
||||||
|
width := 0
|
||||||
|
i := 0
|
||||||
|
for ; i < len(str); i++ {
|
||||||
|
cw := runewidth.RuneWidth(str[i].Char)
|
||||||
|
if width+cw > w {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
width += cw
|
||||||
|
}
|
||||||
|
return str[0:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) IndexFrom(r rune, from int) int {
|
||||||
|
for i := from; i < len(str); i++ {
|
||||||
|
if str[i].Char == r {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Index(r rune) int {
|
||||||
|
return str.IndexFrom(r, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Count(r rune) (counter int) {
|
||||||
|
index := 0
|
||||||
|
for {
|
||||||
|
index = str.IndexFrom(r, index)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (str TString) Split(sep rune) []TString {
|
||||||
|
a := make([]TString, str.Count(sep)+1)
|
||||||
|
i := 0
|
||||||
|
orig := str
|
||||||
|
for {
|
||||||
|
m := orig.Index(sep)
|
||||||
|
if m < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a[i] = orig[:m]
|
||||||
|
orig = orig[m+1:]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
a[i] = orig
|
||||||
|
return a[:i+1]
|
||||||
|
}
|
@ -14,14 +14,15 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package widget
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -126,11 +127,11 @@ func (list *RoomList) Draw(screen tcell.Screen) {
|
|||||||
unreadMessageCount += "!"
|
unreadMessageCount += "!"
|
||||||
}
|
}
|
||||||
unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount)
|
unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount)
|
||||||
writeLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style)
|
widget.WriteLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style)
|
||||||
lineWidth -= len(unreadMessageCount) + 1
|
lineWidth -= len(unreadMessageCount) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
writeLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
|
widget.WriteLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
|
||||||
|
|
||||||
y++
|
y++
|
||||||
if y >= bottomLimit {
|
if y >= bottomLimit {
|
@ -14,7 +14,7 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package widget
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -23,9 +23,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
|
"maunium.net/go/gomuks/interface"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
"maunium.net/go/gomuks/ui/types"
|
"maunium.net/go/gomuks/ui/messages"
|
||||||
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -36,8 +38,8 @@ type RoomView struct {
|
|||||||
content *MessageView
|
content *MessageView
|
||||||
status *tview.TextView
|
status *tview.TextView
|
||||||
userList *tview.TextView
|
userList *tview.TextView
|
||||||
ulBorder *Border
|
ulBorder *widget.Border
|
||||||
input *AdvancedInputField
|
input *widget.AdvancedInputField
|
||||||
Room *rooms.Room
|
Room *rooms.Room
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,13 +47,13 @@ func NewRoomView(room *rooms.Room) *RoomView {
|
|||||||
view := &RoomView{
|
view := &RoomView{
|
||||||
Box: tview.NewBox(),
|
Box: tview.NewBox(),
|
||||||
topic: tview.NewTextView(),
|
topic: tview.NewTextView(),
|
||||||
content: NewMessageView(),
|
|
||||||
status: tview.NewTextView(),
|
status: tview.NewTextView(),
|
||||||
userList: tview.NewTextView(),
|
userList: tview.NewTextView(),
|
||||||
ulBorder: NewBorder(),
|
ulBorder: widget.NewBorder(),
|
||||||
input: NewAdvancedInputField(),
|
input: widget.NewAdvancedInputField(),
|
||||||
Room: room,
|
Room: room,
|
||||||
}
|
}
|
||||||
|
view.content = NewMessageView(view)
|
||||||
|
|
||||||
view.input.
|
view.input.
|
||||||
SetFieldBackgroundColor(tcell.ColorDefault).
|
SetFieldBackgroundColor(tcell.ColorDefault).
|
||||||
@ -79,8 +81,8 @@ func (view *RoomView) SaveHistory(dir string) error {
|
|||||||
return view.MessageView().SaveHistory(view.logPath(dir))
|
return view.MessageView().SaveHistory(view.logPath(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) LoadHistory(dir string) (int, error) {
|
func (view *RoomView) LoadHistory(gmx ifc.Gomuks, dir string) (int, error) {
|
||||||
return view.MessageView().LoadHistory(view.logPath(dir))
|
return view.MessageView().LoadHistory(gmx, view.logPath(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) SetTabCompleteFunc(fn func(room *RoomView, text string, cursorOffset int) string) *RoomView {
|
func (view *RoomView) SetTabCompleteFunc(fn func(room *RoomView, text string, cursorOffset int) string) *RoomView {
|
||||||
@ -129,7 +131,7 @@ func (view *RoomView) GetInputText() string {
|
|||||||
return view.input.GetText()
|
return view.input.GetText()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) GetInputField() *AdvancedInputField {
|
func (view *RoomView) GetInputField() *widget.AdvancedInputField {
|
||||||
return view.input
|
return view.input
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,15 +232,19 @@ func (view *RoomView) MessageView() *MessageView {
|
|||||||
return view.content
|
return view.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) MxRoom() *rooms.Room {
|
||||||
|
return view.Room
|
||||||
|
}
|
||||||
|
|
||||||
func (view *RoomView) UpdateUserList() {
|
func (view *RoomView) UpdateUserList() {
|
||||||
var joined strings.Builder
|
var joined strings.Builder
|
||||||
var invited strings.Builder
|
var invited strings.Builder
|
||||||
for _, user := range view.Room.GetMembers() {
|
for _, user := range view.Room.GetMembers() {
|
||||||
if user.Membership == "join" {
|
if user.Membership == "join" {
|
||||||
joined.WriteString(AddHashColor(user.DisplayName))
|
joined.WriteString(widget.AddHashColor(user.DisplayName))
|
||||||
joined.WriteRune('\n')
|
joined.WriteRune('\n')
|
||||||
} else if user.Membership == "invite" {
|
} else if user.Membership == "invite" {
|
||||||
invited.WriteString(AddHashColor(user.DisplayName))
|
invited.WriteString(widget.AddHashColor(user.DisplayName))
|
||||||
invited.WriteRune('\n')
|
invited.WriteRune('\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -249,27 +255,37 @@ func (view *RoomView) UpdateUserList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message {
|
func (view *RoomView) newUIMessage(id, sender, msgtype, text string, timestamp time.Time) messages.UIMessage {
|
||||||
member := view.Room.GetMember(sender)
|
member := view.Room.GetMember(sender)
|
||||||
if member != nil {
|
if member != nil {
|
||||||
sender = member.DisplayName
|
sender = member.DisplayName
|
||||||
}
|
}
|
||||||
return view.content.NewMessage(id, sender, msgtype, text, timestamp)
|
return messages.NewTextMessage(id, sender, msgtype, text, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) NewTempMessage(msgtype, text string) *types.Message {
|
func (view *RoomView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) ifc.Message {
|
||||||
|
return view.newUIMessage(id, sender, msgtype, text, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) NewTempMessage(msgtype, text string) ifc.Message {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
id := strconv.FormatInt(now.UnixNano(), 10)
|
id := strconv.FormatInt(now.UnixNano(), 10)
|
||||||
sender := ""
|
sender := ""
|
||||||
if ownerMember := view.Room.GetSessionOwner(); ownerMember != nil {
|
if ownerMember := view.Room.GetSessionOwner(); ownerMember != nil {
|
||||||
sender = ownerMember.DisplayName
|
sender = ownerMember.DisplayName
|
||||||
}
|
}
|
||||||
message := view.NewMessage(id, sender, msgtype, text, now)
|
message := view.newUIMessage(id, sender, msgtype, text, now)
|
||||||
message.State = types.MessageStateSending
|
message.SetState(ifc.MessageStateSending)
|
||||||
view.AddMessage(message, AppendMessage)
|
view.AddMessage(message, ifc.AppendMessage)
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *RoomView) AddMessage(message *types.Message, direction MessageDirection) {
|
func (view *RoomView) AddServiceMessage(text string) {
|
||||||
|
message := view.newUIMessage("", "*", "gomuks.service", text, time.Now())
|
||||||
|
message.SetIsService(true)
|
||||||
|
view.AddMessage(message, ifc.AppendMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (view *RoomView) AddMessage(message ifc.Message, direction ifc.MessageDirection) {
|
||||||
view.content.AddMessage(message, direction)
|
view.content.AddMessage(message, direction)
|
||||||
}
|
}
|
@ -1,2 +0,0 @@
|
|||||||
// Package types contains common type definitions used mostly by the UI, but also other parts of gomuks.
|
|
||||||
package types
|
|
@ -1,234 +0,0 @@
|
|||||||
// 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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
|
||||||
"github.com/mattn/go-runewidth"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MessageState is an enum to specify if a Message is being sent, failed to send or was successfully sent.
|
|
||||||
type MessageState int
|
|
||||||
|
|
||||||
// Allowed MessageStates.
|
|
||||||
const (
|
|
||||||
MessageStateSending MessageState = iota
|
|
||||||
MessageStateDefault
|
|
||||||
MessageStateFailed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Message is a wrapper for the content and metadata of a Matrix message intended to be displayed.
|
|
||||||
type Message struct {
|
|
||||||
ID string
|
|
||||||
Type string
|
|
||||||
Sender string
|
|
||||||
SenderColor tcell.Color
|
|
||||||
TextColor tcell.Color
|
|
||||||
Timestamp string
|
|
||||||
Date string
|
|
||||||
Text string
|
|
||||||
State MessageState
|
|
||||||
buffer []string
|
|
||||||
prevBufferWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage creates a new Message object with the provided values and the default state.
|
|
||||||
func NewMessage(id, sender, msgtype, text, timestamp, date string, senderColor tcell.Color) *Message {
|
|
||||||
return &Message{
|
|
||||||
Sender: sender,
|
|
||||||
Timestamp: timestamp,
|
|
||||||
Date: date,
|
|
||||||
SenderColor: senderColor,
|
|
||||||
TextColor: tcell.ColorDefault,
|
|
||||||
Type: msgtype,
|
|
||||||
Text: text,
|
|
||||||
ID: id,
|
|
||||||
prevBufferWidth: 0,
|
|
||||||
State: MessageStateDefault,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CopyTo copies the content of this message to the given message.
|
|
||||||
func (message *Message) CopyTo(to *Message) {
|
|
||||||
to.ID = message.ID
|
|
||||||
to.Type = message.Type
|
|
||||||
to.Sender = message.Sender
|
|
||||||
to.SenderColor = message.SenderColor
|
|
||||||
to.TextColor = message.TextColor
|
|
||||||
to.Timestamp = message.Timestamp
|
|
||||||
to.Date = message.Date
|
|
||||||
to.Text = message.Text
|
|
||||||
to.State = message.State
|
|
||||||
to.RecalculateBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSender gets the string that should be displayed as the sender of this message.
|
|
||||||
//
|
|
||||||
// If the message is being sent, the sender is "Sending...".
|
|
||||||
// If sending has failed, the sender is "Error".
|
|
||||||
// If the message is an emote, the sender is blank.
|
|
||||||
// In any other case, the sender is the display name of the user who sent the message.
|
|
||||||
func (message *Message) GetSender() string {
|
|
||||||
switch message.State {
|
|
||||||
case MessageStateSending:
|
|
||||||
return "Sending..."
|
|
||||||
case MessageStateFailed:
|
|
||||||
return "Error"
|
|
||||||
}
|
|
||||||
switch message.Type {
|
|
||||||
case "m.emote":
|
|
||||||
// Emotes don't show a separate sender, it's included in the buffer.
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return message.Sender
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (message *Message) getStateSpecificColor() tcell.Color {
|
|
||||||
switch message.State {
|
|
||||||
case MessageStateSending:
|
|
||||||
return tcell.ColorGray
|
|
||||||
case MessageStateFailed:
|
|
||||||
return tcell.ColorRed
|
|
||||||
case MessageStateDefault:
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
return tcell.ColorDefault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSenderColor returns the color the name of the sender should be shown in.
|
|
||||||
//
|
|
||||||
// If the message is being sent, the color is gray.
|
|
||||||
// If sending has failed, the color is red.
|
|
||||||
//
|
|
||||||
// In any other case, the color is whatever is specified in the Message struct.
|
|
||||||
// Usually that means it is the hash-based color of the sender (see ui/widget/color.go)
|
|
||||||
func (message *Message) GetSenderColor() (color tcell.Color) {
|
|
||||||
color = message.getStateSpecificColor()
|
|
||||||
if color == tcell.ColorDefault {
|
|
||||||
color = message.SenderColor
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTextColor returns the color the actual content of the message should be shown in.
|
|
||||||
//
|
|
||||||
// This returns the same colors as GetSenderColor(), but takes the default color from a different variable.
|
|
||||||
func (message *Message) GetTextColor() (color tcell.Color) {
|
|
||||||
color = message.getStateSpecificColor()
|
|
||||||
if color == tcell.ColorDefault {
|
|
||||||
color = message.TextColor
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTimestampColor returns the color the timestamp should be shown in.
|
|
||||||
//
|
|
||||||
// As with GetSenderColor(), messages being sent and messages that failed to be sent are
|
|
||||||
// gray and red respectively.
|
|
||||||
//
|
|
||||||
// However, other messages are the default color instead of a color stored in the struct.
|
|
||||||
func (message *Message) GetTimestampColor() tcell.Color {
|
|
||||||
return message.getStateSpecificColor()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecalculateBuffer calculates the buffer again with the previously provided width.
|
|
||||||
func (message *Message) RecalculateBuffer() {
|
|
||||||
message.CalculateBuffer(message.prevBufferWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer returns the computed text buffer.
|
|
||||||
//
|
|
||||||
// The buffer contains the text of the message split into lines with a maximum
|
|
||||||
// width of whatever was provided to CalculateBuffer().
|
|
||||||
//
|
|
||||||
// N.B. This will NOT automatically calculate the buffer if it hasn't been
|
|
||||||
// calculated already, as that requires the target width.
|
|
||||||
func (message *Message) Buffer() []string {
|
|
||||||
return message.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height returns the number of rows in the computed buffer (see Buffer()).
|
|
||||||
func (message *Message) Height() int {
|
|
||||||
return len(message.buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTimestamp returns the formatted time when the message was sent.
|
|
||||||
func (message *Message) GetTimestamp() string {
|
|
||||||
return message.Timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDate returns the formatted date when the message was sent.
|
|
||||||
func (message *Message) GetDate() string {
|
|
||||||
return message.Date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular expressions used to split lines when calculating the buffer.
|
|
||||||
//
|
|
||||||
// From tview/textview.go
|
|
||||||
var (
|
|
||||||
boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)")
|
|
||||||
spacePattern = regexp.MustCompile(`\s+`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// CalculateBuffer generates the internal buffer for this message that consists
|
|
||||||
// of the text of this message split into lines at most as wide as the width
|
|
||||||
// parameter.
|
|
||||||
func (message *Message) CalculateBuffer(width int) {
|
|
||||||
if width < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
message.buffer = []string{}
|
|
||||||
text := message.Text
|
|
||||||
if message.Type == "m.emote" {
|
|
||||||
text = fmt.Sprintf("* %s %s", message.Sender, message.Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
forcedLinebreaks := strings.Split(text, "\n")
|
|
||||||
newlines := 0
|
|
||||||
for _, str := range forcedLinebreaks {
|
|
||||||
if len(str) == 0 && newlines < 1 {
|
|
||||||
message.buffer = append(message.buffer, "")
|
|
||||||
newlines++
|
|
||||||
} else {
|
|
||||||
newlines = 0
|
|
||||||
}
|
|
||||||
// From tview/textview.go#reindexBuffer()
|
|
||||||
for len(str) > 0 {
|
|
||||||
extract := runewidth.Truncate(str, width, "")
|
|
||||||
if len(extract) < len(str) {
|
|
||||||
if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 {
|
|
||||||
extract = str[:len(extract)+spaces[1]]
|
|
||||||
}
|
|
||||||
|
|
||||||
matches := boundaryPattern.FindAllStringIndex(extract, -1)
|
|
||||||
if len(matches) > 0 {
|
|
||||||
extract = extract[:matches[len(matches)-1][1]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.buffer = append(message.buffer, extract)
|
|
||||||
str = str[len(extract):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.prevBufferWidth = width
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
// 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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MessageMeta is an interface to get the metadata of a message.
|
|
||||||
//
|
|
||||||
// See BasicMeta for a simple implementation and documentation of methods.
|
|
||||||
type MessageMeta interface {
|
|
||||||
GetSender() string
|
|
||||||
GetSenderColor() tcell.Color
|
|
||||||
GetTextColor() tcell.Color
|
|
||||||
GetTimestampColor() tcell.Color
|
|
||||||
GetTimestamp() string
|
|
||||||
GetDate() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BasicMeta is a simple variable store implementation of MessageMeta.
|
|
||||||
type BasicMeta struct {
|
|
||||||
Sender, Timestamp, Date string
|
|
||||||
SenderColor, TextColor, TimestampColor tcell.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSender gets the string that should be displayed as the sender of this message.
|
|
||||||
func (meta *BasicMeta) GetSender() string {
|
|
||||||
return meta.Sender
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSenderColor returns the color the name of the sender should be shown in.
|
|
||||||
func (meta *BasicMeta) GetSenderColor() tcell.Color {
|
|
||||||
return meta.SenderColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTimestamp returns the formatted time when the message was sent.
|
|
||||||
func (meta *BasicMeta) GetTimestamp() string {
|
|
||||||
return meta.Timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDate returns the formatted date when the message was sent.
|
|
||||||
func (meta *BasicMeta) GetDate() string {
|
|
||||||
return meta.Date
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTextColor returns the color the actual content of the message should be shown in.
|
|
||||||
func (meta *BasicMeta) GetTextColor() tcell.Color {
|
|
||||||
return meta.TextColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTimestampColor returns the color the timestamp should be shown in.
|
|
||||||
//
|
|
||||||
// This usually does not apply to the date, as it is rendered separately from the message.
|
|
||||||
func (meta *BasicMeta) GetTimestampColor() tcell.Color {
|
|
||||||
return meta.TimestampColor
|
|
||||||
}
|
|
2
ui/ui.go
2
ui/ui.go
@ -17,7 +17,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
"maunium.net/go/gomuks/interface"
|
"maunium.net/go/gomuks/interface"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"maunium.net/go/gomuks/config"
|
"maunium.net/go/gomuks/config"
|
||||||
"maunium.net/go/gomuks/interface"
|
"maunium.net/go/gomuks/interface"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
"maunium.net/go/gomuks/debug"
|
||||||
"maunium.net/go/gomuks/ui/widget"
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
161
ui/view-main.go
161
ui/view-main.go
@ -23,26 +23,26 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"maunium.net/go/gomatrix"
|
"maunium.net/go/gomatrix"
|
||||||
"maunium.net/go/gomuks/config"
|
"maunium.net/go/gomuks/config"
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
"maunium.net/go/gomuks/interface"
|
"maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/lib/notification"
|
||||||
"maunium.net/go/gomuks/matrix/pushrules"
|
"maunium.net/go/gomuks/matrix/pushrules"
|
||||||
"maunium.net/go/gomuks/matrix/rooms"
|
"maunium.net/go/gomuks/matrix/rooms"
|
||||||
"maunium.net/go/gomuks/notification"
|
"maunium.net/go/gomuks/ui/messages/parser"
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
|
||||||
"maunium.net/go/gomuks/ui/types"
|
|
||||||
"maunium.net/go/gomuks/ui/widget"
|
"maunium.net/go/gomuks/ui/widget"
|
||||||
|
"maunium.net/go/tcell"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainView struct {
|
type MainView struct {
|
||||||
*tview.Flex
|
*tview.Flex
|
||||||
|
|
||||||
roomList *widget.RoomList
|
roomList *RoomList
|
||||||
roomView *tview.Pages
|
roomView *tview.Pages
|
||||||
rooms map[string]*widget.RoomView
|
rooms map[string]*RoomView
|
||||||
currentRoomIndex int
|
currentRoomIndex int
|
||||||
roomIDs []string
|
roomIDs []string
|
||||||
|
|
||||||
@ -57,9 +57,9 @@ type MainView struct {
|
|||||||
func (ui *GomuksUI) NewMainView() tview.Primitive {
|
func (ui *GomuksUI) NewMainView() tview.Primitive {
|
||||||
mainView := &MainView{
|
mainView := &MainView{
|
||||||
Flex: tview.NewFlex(),
|
Flex: tview.NewFlex(),
|
||||||
roomList: widget.NewRoomList(),
|
roomList: NewRoomList(),
|
||||||
roomView: tview.NewPages(),
|
roomView: tview.NewPages(),
|
||||||
rooms: make(map[string]*widget.RoomView),
|
rooms: make(map[string]*RoomView),
|
||||||
|
|
||||||
matrix: ui.gmx.Matrix(),
|
matrix: ui.gmx.Matrix(),
|
||||||
gmx: ui.gmx,
|
gmx: ui.gmx,
|
||||||
@ -81,7 +81,7 @@ func (view *MainView) BumpFocus() {
|
|||||||
view.lastFocusTime = time.Now()
|
view.lastFocusTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) InputChanged(roomView *widget.RoomView, text string) {
|
func (view *MainView) InputChanged(roomView *RoomView, text string) {
|
||||||
if len(text) == 0 {
|
if len(text) == 0 {
|
||||||
go view.matrix.SendTyping(roomView.Room.ID, false)
|
go view.matrix.SendTyping(roomView.Room.ID, false)
|
||||||
} else if text[0] != '/' {
|
} else if text[0] != '/' {
|
||||||
@ -101,7 +101,7 @@ func findWordToTabComplete(text string) string {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, cursorOffset int) string {
|
func (view *MainView) InputTabComplete(roomView *RoomView, text string, cursorOffset int) string {
|
||||||
str := runewidth.Truncate(text, cursorOffset, "")
|
str := runewidth.Truncate(text, cursorOffset, "")
|
||||||
word := findWordToTabComplete(str)
|
word := findWordToTabComplete(str)
|
||||||
userCompletions := roomView.AutocompleteUser(word)
|
userCompletions := roomView.AutocompleteUser(word)
|
||||||
@ -118,7 +118,7 @@ func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, c
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
|
func (view *MainView) InputSubmit(roomView *RoomView, text string) {
|
||||||
if len(text) == 0 {
|
if len(text) == 0 {
|
||||||
return
|
return
|
||||||
} else if text[0] == '/' {
|
} else if text[0] == '/' {
|
||||||
@ -132,29 +132,30 @@ func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
|
|||||||
roomView.SetInputText("")
|
roomView.SetInputText("")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) SendMessage(roomView *widget.RoomView, text string) {
|
func (view *MainView) SendMessage(roomView *RoomView, text string) {
|
||||||
tempMessage := roomView.NewTempMessage("m.text", text)
|
tempMessage := roomView.NewTempMessage("m.text", text)
|
||||||
go view.sendTempMessage(roomView, tempMessage)
|
go view.sendTempMessage(roomView, tempMessage, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) sendTempMessage(roomView *widget.RoomView, tempMessage *types.Message) {
|
func (view *MainView) sendTempMessage(roomView *RoomView, tempMessage ifc.Message, text string) {
|
||||||
defer view.gmx.Recover()
|
defer view.gmx.Recover()
|
||||||
eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type, tempMessage.Text)
|
eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type(), text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tempMessage.State = types.MessageStateFailed
|
tempMessage.SetState(ifc.MessageStateFailed)
|
||||||
roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err))
|
roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err))
|
||||||
} else {
|
} else {
|
||||||
roomView.MessageView().UpdateMessageID(tempMessage, eventID)
|
roomView.MessageView().UpdateMessageID(tempMessage, eventID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, args []string) {
|
func (view *MainView) HandleCommand(roomView *RoomView, command string, args []string) {
|
||||||
defer view.gmx.Recover()
|
defer view.gmx.Recover()
|
||||||
debug.Print("Handling command", command, args)
|
debug.Print("Handling command", command, args)
|
||||||
switch command {
|
switch command {
|
||||||
case "/me":
|
case "/me":
|
||||||
tempMessage := roomView.NewTempMessage("m.emote", strings.Join(args, " "))
|
text := strings.Join(args, " ")
|
||||||
go view.sendTempMessage(roomView, tempMessage)
|
tempMessage := roomView.NewTempMessage("m.emote", text)
|
||||||
|
go view.sendTempMessage(roomView, tempMessage, text)
|
||||||
view.parent.Render()
|
view.parent.Render()
|
||||||
case "/quit":
|
case "/quit":
|
||||||
view.gmx.Stop()
|
view.gmx.Stop()
|
||||||
@ -163,22 +164,20 @@ func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, a
|
|||||||
view.gmx.Stop()
|
view.gmx.Stop()
|
||||||
case "/panic":
|
case "/panic":
|
||||||
panic("This is a test panic.")
|
panic("This is a test panic.")
|
||||||
case "/part":
|
case "/part", "/leave":
|
||||||
fallthrough
|
|
||||||
case "/leave":
|
|
||||||
debug.Print("Leave room result:", view.matrix.LeaveRoom(roomView.Room.ID))
|
debug.Print("Leave room result:", view.matrix.LeaveRoom(roomView.Room.ID))
|
||||||
case "/join":
|
case "/join":
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
view.AddServiceMessage(roomView, "Usage: /join <room>")
|
roomView.AddServiceMessage("Usage: /join <room>")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
debug.Print("Join room result:", view.matrix.JoinRoom(args[0]))
|
debug.Print("Join room result:", view.matrix.JoinRoom(args[0]))
|
||||||
default:
|
default:
|
||||||
view.AddServiceMessage(roomView, "Unknown command.")
|
roomView.AddServiceMessage("Unknown command.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.EventKey) *tcell.EventKey {
|
func (view *MainView) KeyEventHandler(roomView *RoomView, key *tcell.EventKey) *tcell.EventKey {
|
||||||
view.BumpFocus()
|
view.BumpFocus()
|
||||||
|
|
||||||
k := key.Key()
|
k := key.Key()
|
||||||
@ -220,8 +219,8 @@ func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.Even
|
|||||||
|
|
||||||
const WheelScrollOffsetDiff = 3
|
const WheelScrollOffsetDiff = 3
|
||||||
|
|
||||||
func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.EventMouse) *tcell.EventMouse {
|
func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMouse) *tcell.EventMouse {
|
||||||
if event.Buttons() == tcell.ButtonNone {
|
if event.Buttons() == tcell.ButtonNone || event.HasMotion() {
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
view.BumpFocus()
|
view.BumpFocus()
|
||||||
@ -247,7 +246,14 @@ func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.
|
|||||||
roomView.Room.MarkRead()
|
roomView.Room.MarkRead()
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
mx, my, mw, mh := msgView.GetRect()
|
||||||
|
if x >= mx && y >= my && x < mx+mw && y < my+mh {
|
||||||
|
if msgView.HandleClick(x-mx, y-my, event.Buttons()) {
|
||||||
|
view.parent.Render()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y)
|
debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y)
|
||||||
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,7 +306,7 @@ func (view *MainView) addRoom(index int, room string) {
|
|||||||
|
|
||||||
view.roomList.Add(roomStore)
|
view.roomList.Add(roomStore)
|
||||||
if !view.roomView.HasPage(room) {
|
if !view.roomView.HasPage(room) {
|
||||||
roomView := widget.NewRoomView(roomStore).
|
roomView := NewRoomView(roomStore).
|
||||||
SetInputSubmitFunc(view.InputSubmit).
|
SetInputSubmitFunc(view.InputSubmit).
|
||||||
SetInputChangedFunc(view.InputChanged).
|
SetInputChangedFunc(view.InputChanged).
|
||||||
SetTabCompleteFunc(view.InputTabComplete).
|
SetTabCompleteFunc(view.InputTabComplete).
|
||||||
@ -310,7 +316,7 @@ func (view *MainView) addRoom(index int, room string) {
|
|||||||
view.roomView.AddPage(room, roomView, true, false)
|
view.roomView.AddPage(room, roomView, true, false)
|
||||||
roomView.UpdateUserList()
|
roomView.UpdateUserList()
|
||||||
|
|
||||||
count, err := roomView.LoadHistory(view.config.HistoryDir)
|
count, err := roomView.LoadHistory(view.gmx, view.config.HistoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
debug.Printf("Failed to load history of %s: %v", roomView.Room.GetTitle(), err)
|
debug.Printf("Failed to load history of %s: %v", roomView.Room.GetTitle(), err)
|
||||||
} else if count <= 0 {
|
} else if count <= 0 {
|
||||||
@ -319,7 +325,7 @@ func (view *MainView) addRoom(index int, room string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) GetRoom(id string) *widget.RoomView {
|
func (view *MainView) GetRoom(id string) ifc.RoomView {
|
||||||
return view.rooms[id]
|
return view.rooms[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,7 +358,7 @@ func (view *MainView) RemoveRoom(room string) {
|
|||||||
} else {
|
} else {
|
||||||
removeIndex = sort.StringSlice(view.roomIDs).Search(room)
|
removeIndex = sort.StringSlice(view.roomIDs).Search(room)
|
||||||
}
|
}
|
||||||
view.roomList.Remove(roomView.Room)
|
view.roomList.Remove(roomView.MxRoom())
|
||||||
view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...)
|
view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...)
|
||||||
view.roomView.RemovePage(room)
|
view.roomView.RemovePage(room)
|
||||||
delete(view.rooms, room)
|
delete(view.rooms, room)
|
||||||
@ -363,7 +369,7 @@ func (view *MainView) SetRooms(rooms []string) {
|
|||||||
view.roomIDs = rooms
|
view.roomIDs = rooms
|
||||||
view.roomList.Clear()
|
view.roomList.Clear()
|
||||||
view.roomView.Clear()
|
view.roomView.Clear()
|
||||||
view.rooms = make(map[string]*widget.RoomView)
|
view.rooms = make(map[string]*RoomView)
|
||||||
for index, room := range rooms {
|
for index, room := range rooms {
|
||||||
view.addRoom(index, room)
|
view.addRoom(index, room)
|
||||||
}
|
}
|
||||||
@ -385,14 +391,14 @@ func sendNotification(room *rooms.Room, sender, text string, critical, sound boo
|
|||||||
notification.Send(sender, text, critical, sound)
|
notification.Send(sender, text, critical, sound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould) {
|
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
|
||||||
// Whether or not the room where the message came is the currently shown room.
|
// Whether or not the room where the message came is the currently shown room.
|
||||||
isCurrent := room.ID == view.CurrentRoomID()
|
isCurrent := room.ID == view.CurrentRoomID()
|
||||||
// Whether or not the terminal window is focused.
|
// Whether or not the terminal window is focused.
|
||||||
isFocused := view.lastFocusTime.Add(30 * time.Second).Before(time.Now())
|
isFocused := view.lastFocusTime.Add(30 * time.Second).Before(time.Now())
|
||||||
|
|
||||||
// Whether or not the push rules say this message should be notified about.
|
// Whether or not the push rules say this message should be notified about.
|
||||||
shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender != view.config.Session.UserID
|
shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender() != view.config.Session.UserID
|
||||||
|
|
||||||
if !isCurrent {
|
if !isCurrent {
|
||||||
// The message is not in the current room, show new message status in room list.
|
// The message is not in the current room, show new message status in room list.
|
||||||
@ -406,21 +412,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, sh
|
|||||||
if shouldNotify && !isFocused {
|
if shouldNotify && !isFocused {
|
||||||
// Push rules say notify and the terminal is not focused, send desktop notification.
|
// Push rules say notify and the terminal is not focused, send desktop notification.
|
||||||
shouldPlaySound := should.PlaySound && should.SoundName == "default"
|
shouldPlaySound := should.PlaySound && should.SoundName == "default"
|
||||||
sendNotification(room, message.Sender, message.Text, should.Highlight, shouldPlaySound)
|
sendNotification(room, message.Sender(), message.NotificationContent(), should.Highlight, shouldPlaySound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if should.Highlight {
|
message.SetIsHighlight(should.Highlight)
|
||||||
// Message is highlight, set color.
|
|
||||||
message.TextColor = tcell.ColorYellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MainView) AddServiceMessage(roomView *widget.RoomView, text string) {
|
|
||||||
message := roomView.NewMessage("", "*", "gomuks.service", text, time.Now())
|
|
||||||
message.TextColor = tcell.ColorGray
|
|
||||||
message.SenderColor = tcell.ColorGray
|
|
||||||
roomView.AddMessage(message, widget.AppendMessage)
|
|
||||||
view.parent.Render()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) LoadHistory(room string, initial bool) {
|
func (view *MainView) LoadHistory(room string, initial bool) {
|
||||||
@ -452,20 +447,15 @@ func (view *MainView) LoadHistory(room string, initial bool) {
|
|||||||
}
|
}
|
||||||
history, prevBatch, err := view.matrix.GetHistory(roomView.Room.ID, batch, 50)
|
history, prevBatch, err := view.matrix.GetHistory(roomView.Room.ID, batch, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
view.AddServiceMessage(roomView, "Failed to fetch history")
|
roomView.AddServiceMessage("Failed to fetch history")
|
||||||
debug.Print("Failed to fetch history for", roomView.Room.ID, err)
|
debug.Print("Failed to fetch history for", roomView.Room.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
roomView.Room.PrevBatch = prevBatch
|
roomView.Room.PrevBatch = prevBatch
|
||||||
for _, evt := range history {
|
for _, evt := range history {
|
||||||
var message *types.Message
|
message := view.ParseEvent(roomView, &evt)
|
||||||
if evt.Type == "m.room.message" {
|
|
||||||
message = view.ProcessMessageEvent(roomView, &evt)
|
|
||||||
} else if evt.Type == "m.room.member" {
|
|
||||||
message = view.ProcessMembershipEvent(roomView, &evt)
|
|
||||||
}
|
|
||||||
if message != nil {
|
if message != nil {
|
||||||
roomView.AddMessage(message, widget.PrependMessage)
|
roomView.AddMessage(message, ifc.PrependMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = roomView.SaveHistory(view.config.HistoryDir)
|
err = roomView.SaveHistory(view.config.HistoryDir)
|
||||||
@ -476,63 +466,6 @@ func (view *MainView) LoadHistory(room string, initial bool) {
|
|||||||
view.parent.Render()
|
view.parent.Render()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (view *MainView) ProcessMessageEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) {
|
func (view *MainView) ParseEvent(roomView ifc.RoomView, evt *gomatrix.Event) ifc.Message {
|
||||||
text, _ := evt.Content["body"].(string)
|
return parser.ParseEvent(view.gmx, roomView.MxRoom(), evt)
|
||||||
msgtype, _ := evt.Content["msgtype"].(string)
|
|
||||||
return room.NewMessage(evt.ID, evt.Sender, msgtype, text, unixToTime(evt.Timestamp))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MainView) getMembershipEventContent(evt *gomatrix.Event) (sender, text string) {
|
|
||||||
membership, _ := evt.Content["membership"].(string)
|
|
||||||
displayname, _ := evt.Content["displayname"].(string)
|
|
||||||
if len(displayname) == 0 {
|
|
||||||
displayname = *evt.StateKey
|
|
||||||
}
|
|
||||||
prevMembership := "leave"
|
|
||||||
prevDisplayname := ""
|
|
||||||
if evt.Unsigned.PrevContent != nil {
|
|
||||||
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
|
|
||||||
prevDisplayname, _ = evt.Unsigned.PrevContent["displayname"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
if membership != prevMembership {
|
|
||||||
switch membership {
|
|
||||||
case "invite":
|
|
||||||
sender = "---"
|
|
||||||
text = fmt.Sprintf("%s invited %s.", evt.Sender, displayname)
|
|
||||||
case "join":
|
|
||||||
sender = "-->"
|
|
||||||
text = fmt.Sprintf("%s joined the room.", displayname)
|
|
||||||
case "leave":
|
|
||||||
sender = "<--"
|
|
||||||
if evt.Sender != *evt.StateKey {
|
|
||||||
reason, _ := evt.Content["reason"].(string)
|
|
||||||
text = fmt.Sprintf("%s kicked %s: %s", evt.Sender, displayname, reason)
|
|
||||||
} else {
|
|
||||||
text = fmt.Sprintf("%s left the room.", displayname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if displayname != prevDisplayname {
|
|
||||||
sender = "---"
|
|
||||||
text = fmt.Sprintf("%s changed their display name to %s.", prevDisplayname, displayname)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MainView) ProcessMembershipEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) {
|
|
||||||
sender, text := view.getMembershipEventContent(evt)
|
|
||||||
if len(text) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message = room.NewMessage(evt.ID, sender, "m.room.member", text, unixToTime(evt.Timestamp))
|
|
||||||
message.TextColor = tcell.ColorGreen
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixToTime(unix int64) time.Time {
|
|
||||||
timestamp := time.Now()
|
|
||||||
if unix != 0 {
|
|
||||||
timestamp = time.Unix(unix/1000, unix%1000*1000)
|
|
||||||
}
|
|
||||||
return timestamp
|
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"github.com/zyedidia/clipboard"
|
"github.com/zyedidia/clipboard"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
package widget
|
package widget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ import (
|
|||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
)
|
)
|
||||||
|
|
||||||
var colorNames []string
|
var colorNames []string
|
||||||
|
@ -1,354 +0,0 @@
|
|||||||
// 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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package widget
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell"
|
|
||||||
"maunium.net/go/gomuks/ui/debug"
|
|
||||||
"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
|
|
||||||
LoadingMessages bool
|
|
||||||
|
|
||||||
widestSender int
|
|
||||||
prevWidth int
|
|
||||||
prevHeight int
|
|
||||||
prevMsgCount int
|
|
||||||
|
|
||||||
messageIDs map[string]*types.Message
|
|
||||||
messages []*types.Message
|
|
||||||
|
|
||||||
textBuffer []string
|
|
||||||
metaBuffer []types.MessageMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMessageView() *MessageView {
|
|
||||||
return &MessageView{
|
|
||||||
Box: tview.NewBox(),
|
|
||||||
MaxSenderWidth: 15,
|
|
||||||
DateFormat: "January _2, 2006",
|
|
||||||
TimestampFormat: "15:04:05",
|
|
||||||
TimestampWidth: 8,
|
|
||||||
ScrollOffset: 0,
|
|
||||||
|
|
||||||
messages: make([]*types.Message, 0),
|
|
||||||
messageIDs: make(map[string]*types.Message),
|
|
||||||
textBuffer: make([]string, 0),
|
|
||||||
metaBuffer: make([]types.MessageMeta, 0),
|
|
||||||
|
|
||||||
widestSender: 5,
|
|
||||||
prevWidth: -1,
|
|
||||||
prevHeight: -1,
|
|
||||||
prevMsgCount: -1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message {
|
|
||||||
return types.NewMessage(id, sender, msgtype, text,
|
|
||||||
timestamp.Format(view.TimestampFormat),
|
|
||||||
timestamp.Format(view.DateFormat),
|
|
||||||
GetHashColor(sender))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) SaveHistory(path string) error {
|
|
||||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
enc := gob.NewEncoder(file)
|
|
||||||
err = enc.Encode(view.messages)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) LoadHistory(path string) (int, error) {
|
|
||||||
file, err := os.OpenFile(path, os.O_RDONLY, 0600)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
dec := gob.NewDecoder(file)
|
|
||||||
err = dec.Decode(&view.messages)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, message := range view.messages {
|
|
||||||
view.updateWidestSender(message.Sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(view.messages), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) updateWidestSender(sender string) {
|
|
||||||
if len(sender) > view.widestSender {
|
|
||||||
view.widestSender = len(sender)
|
|
||||||
if view.widestSender > view.MaxSenderWidth {
|
|
||||||
view.widestSender = view.MaxSenderWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageDirection int
|
|
||||||
|
|
||||||
const (
|
|
||||||
AppendMessage MessageDirection = iota
|
|
||||||
PrependMessage
|
|
||||||
IgnoreMessage
|
|
||||||
)
|
|
||||||
|
|
||||||
func (view *MessageView) UpdateMessageID(message *types.Message, newID string) {
|
|
||||||
delete(view.messageIDs, message.ID)
|
|
||||||
message.ID = newID
|
|
||||||
view.messageIDs[message.ID] = message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) AddMessage(message *types.Message, direction MessageDirection) {
|
|
||||||
if message == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, messageExists := view.messageIDs[message.ID]
|
|
||||||
if msg != nil && messageExists {
|
|
||||||
message.CopyTo(msg)
|
|
||||||
message = msg
|
|
||||||
direction = IgnoreMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
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 += message.Height()
|
|
||||||
}
|
|
||||||
view.messages = append(view.messages, message)
|
|
||||||
view.appendBuffer(message)
|
|
||||||
} else if direction == PrependMessage {
|
|
||||||
view.messages = append([]*types.Message{message}, view.messages...)
|
|
||||||
}
|
|
||||||
|
|
||||||
view.messageIDs[message.ID] = message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) appendBuffer(message *types.Message) {
|
|
||||||
if len(view.metaBuffer) > 0 {
|
|
||||||
prevMeta := view.metaBuffer[len(view.metaBuffer)-1]
|
|
||||||
if prevMeta != nil && prevMeta.GetDate() != message.Date {
|
|
||||||
view.textBuffer = append(view.textBuffer, fmt.Sprintf("Date changed to %s", message.Date))
|
|
||||||
view.metaBuffer = append(view.metaBuffer, &types.BasicMeta{TextColor: tcell.ColorGreen})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
view.textBuffer = append(view.textBuffer, message.Buffer()...)
|
|
||||||
for range message.Buffer() {
|
|
||||||
view.metaBuffer = append(view.metaBuffer, message)
|
|
||||||
}
|
|
||||||
view.prevMsgCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) recalculateBuffers() {
|
|
||||||
_, _, width, height := view.GetInnerRect()
|
|
||||||
|
|
||||||
width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
|
|
||||||
recalculateMessageBuffers := width != view.prevWidth
|
|
||||||
if height != view.prevHeight || recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
|
|
||||||
view.textBuffer = []string{}
|
|
||||||
view.metaBuffer = []types.MessageMeta{}
|
|
||||||
view.prevMsgCount = 0
|
|
||||||
for _, message := range view.messages {
|
|
||||||
if recalculateMessageBuffers {
|
|
||||||
message.CalculateBuffer(width)
|
|
||||||
}
|
|
||||||
view.appendBuffer(message)
|
|
||||||
}
|
|
||||||
view.prevHeight = height
|
|
||||||
view.prevWidth = width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PaddingAtTop = 5
|
|
||||||
|
|
||||||
func (view *MessageView) AddScrollOffset(diff int) {
|
|
||||||
_, _, _, height := view.GetInnerRect()
|
|
||||||
|
|
||||||
totalHeight := len(view.textBuffer)
|
|
||||||
if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop {
|
|
||||||
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
|
||||||
} else {
|
|
||||||
view.ScrollOffset += diff
|
|
||||||
}
|
|
||||||
|
|
||||||
if view.ScrollOffset > totalHeight-height+PaddingAtTop {
|
|
||||||
view.ScrollOffset = totalHeight - height + PaddingAtTop
|
|
||||||
}
|
|
||||||
if view.ScrollOffset < 0 {
|
|
||||||
view.ScrollOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) Height() int {
|
|
||||||
_, _, _, height := view.GetInnerRect()
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) TotalHeight() int {
|
|
||||||
return len(view.textBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) IsAtTop() bool {
|
|
||||||
_, _, _, height := view.GetInnerRect()
|
|
||||||
totalHeight := len(view.textBuffer)
|
|
||||||
return view.ScrollOffset >= totalHeight-height+PaddingAtTop
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
TimestampSenderGap = 1
|
|
||||||
SenderSeparatorGap = 1
|
|
||||||
SenderMessageGap = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) {
|
|
||||||
char = '│'
|
|
||||||
style = tcell.StyleDefault
|
|
||||||
if scrollbarHere {
|
|
||||||
style = style.Foreground(tcell.ColorGreen)
|
|
||||||
}
|
|
||||||
if isTop {
|
|
||||||
if scrollbarHere {
|
|
||||||
char = '╥'
|
|
||||||
} else {
|
|
||||||
char = '┬'
|
|
||||||
}
|
|
||||||
} else if isBottom {
|
|
||||||
if scrollbarHere {
|
|
||||||
char = '╨'
|
|
||||||
} else {
|
|
||||||
char = '┴'
|
|
||||||
}
|
|
||||||
} else if scrollbarHere {
|
|
||||||
char = '║'
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (view *MessageView) Draw(screen tcell.Screen) {
|
|
||||||
view.Box.Draw(screen)
|
|
||||||
|
|
||||||
x, y, _, height := view.GetInnerRect()
|
|
||||||
view.recalculateBuffers()
|
|
||||||
|
|
||||||
if len(view.textBuffer) == 0 {
|
|
||||||
writeLineSimple(screen, "It's quite empty in here.", x, y+height)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameX := x + view.TimestampWidth + TimestampSenderGap
|
|
||||||
messageX := usernameX + view.widestSender + SenderMessageGap
|
|
||||||
separatorX := usernameX + view.widestSender + SenderSeparatorGap
|
|
||||||
|
|
||||||
indexOffset := len(view.textBuffer) - view.ScrollOffset - height
|
|
||||||
if indexOffset <= -PaddingAtTop {
|
|
||||||
message := "Scroll up to load more messages."
|
|
||||||
if view.LoadingMessages {
|
|
||||||
message = "Loading more messages..."
|
|
||||||
}
|
|
||||||
writeLineSimpleColor(screen, message, messageX, y, tcell.ColorGreen)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(view.textBuffer) != len(view.metaBuffer) {
|
|
||||||
debug.ExtPrintf("Unexpected text/meta buffer length mismatch: %d != %d.", len(view.textBuffer), len(view.metaBuffer))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var scrollBarHeight, scrollBarPos int
|
|
||||||
// Black magic (aka math) used to figure out where the scroll bar should be put.
|
|
||||||
{
|
|
||||||
viewportHeight := float64(height)
|
|
||||||
contentHeight := float64(len(view.textBuffer))
|
|
||||||
|
|
||||||
scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight)))
|
|
||||||
|
|
||||||
scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight))
|
|
||||||
}
|
|
||||||
|
|
||||||
var prevMeta types.MessageMeta
|
|
||||||
firstLine := true
|
|
||||||
skippedLines := 0
|
|
||||||
|
|
||||||
for line := 0; line < height; line++ {
|
|
||||||
index := indexOffset + line
|
|
||||||
if index < 0 {
|
|
||||||
skippedLines++
|
|
||||||
continue
|
|
||||||
} else if index >= len(view.textBuffer) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
showScrollbar := line-skippedLines >= scrollBarPos-scrollBarHeight && line-skippedLines < scrollBarPos
|
|
||||||
isTop := firstLine && view.ScrollOffset+height >= len(view.textBuffer)
|
|
||||||
isBottom := line == height-1 && view.ScrollOffset == 0
|
|
||||||
|
|
||||||
borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom)
|
|
||||||
|
|
||||||
firstLine = false
|
|
||||||
|
|
||||||
screen.SetContent(separatorX, y+line, borderChar, nil, borderStyle)
|
|
||||||
|
|
||||||
text, meta := view.textBuffer[index], view.metaBuffer[index]
|
|
||||||
if meta != prevMeta {
|
|
||||||
if len(meta.GetTimestamp()) > 0 {
|
|
||||||
writeLineSimpleColor(screen, meta.GetTimestamp(), x, y+line, meta.GetTimestampColor())
|
|
||||||
}
|
|
||||||
if prevMeta == nil || meta.GetSender() != prevMeta.GetSender() {
|
|
||||||
writeLineColor(
|
|
||||||
screen, tview.AlignRight, meta.GetSender(),
|
|
||||||
usernameX, y+line, view.widestSender,
|
|
||||||
meta.GetSenderColor())
|
|
||||||
}
|
|
||||||
prevMeta = meta
|
|
||||||
}
|
|
||||||
writeLineSimpleColor(screen, text, messageX, y+line, meta.GetTextColor())
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,24 +17,24 @@
|
|||||||
package widget
|
package widget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gdamore/tcell"
|
"maunium.net/go/tcell"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"maunium.net/go/tview"
|
"maunium.net/go/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeLineSimple(screen tcell.Screen, line string, x, y int) {
|
func WriteLineSimple(screen tcell.Screen, line string, x, y int) {
|
||||||
writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) {
|
func WriteLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) {
|
||||||
writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
|
WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
|
func WriteLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
|
||||||
writeLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
|
WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
func WriteLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
||||||
offsetX := 0
|
offsetX := 0
|
||||||
if align == tview.AlignRight {
|
if align == tview.AlignRight {
|
||||||
offsetX = maxWidth - runewidth.StringWidth(line)
|
offsetX = maxWidth - runewidth.StringWidth(line)
|
||||||
|
Loading…
Reference in New Issue
Block a user