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"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
"maunium.net/go/gomuks/ui/debug"
|
||||
"maunium.net/go/gomuks/debug"
|
||||
)
|
||||
|
||||
// Config contains the main config of gomuks.
|
||||
@ -33,6 +33,7 @@ type Config struct {
|
||||
|
||||
Dir string `yaml:"-"`
|
||||
HistoryDir string `yaml:"history_dir"`
|
||||
MediaDir string `yaml:"media_dir"`
|
||||
Session *Session `yaml:"-"`
|
||||
}
|
||||
|
||||
@ -41,6 +42,7 @@ func NewConfig(dir string) *Config {
|
||||
return &Config{
|
||||
Dir: dir,
|
||||
HistoryDir: filepath.Join(dir, "history"),
|
||||
MediaDir: filepath.Join(dir, "media"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ import (
|
||||
"maunium.net/go/gomatrix"
|
||||
"maunium.net/go/gomuks/matrix/pushrules"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
"maunium.net/go/gomuks/ui/debug"
|
||||
"maunium.net/go/gomuks/debug"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
|
@ -17,105 +17,45 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"runtime/debug"
|
||||
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
type Printer interface {
|
||||
Printf(text string, args ...interface{})
|
||||
Print(text ...interface{})
|
||||
}
|
||||
var writer io.Writer
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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{}) {
|
||||
if RedirectAllExt {
|
||||
ExtPrintf(text, args...)
|
||||
} else if Default != nil {
|
||||
Default.Printf(text, args...)
|
||||
if writer != nil {
|
||||
fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
|
||||
fmt.Fprintf(writer, text+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func Print(text ...interface{}) {
|
||||
if RedirectAllExt {
|
||||
ExtPrint(text...)
|
||||
} else if Default != nil {
|
||||
Default.Print(text...)
|
||||
if writer != nil {
|
||||
fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
|
||||
fmt.Fprintln(writer, text...)
|
||||
}
|
||||
}
|
||||
|
||||
func PrintStack() {
|
||||
if writer != nil {
|
||||
data := debug.Stack()
|
||||
writer.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,14 +68,18 @@ const Oops = ` __________
|
||||
U ||----W |
|
||||
|| ||`
|
||||
|
||||
func PrettyPanic() {
|
||||
func PrettyPanic(panic interface{}) {
|
||||
fmt.Println(Oops)
|
||||
fmt.Println("")
|
||||
fmt.Println("A fatal error has occurred.")
|
||||
fmt.Println("")
|
||||
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 {
|
||||
fmt.Println("Saving the stack trace to", traceFile, "failed:")
|
||||
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("")
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
fmt.Println(panic)
|
||||
debug.PrintStack()
|
||||
fmt.Println("--------------------------------------------------------------------------------")
|
||||
} 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
|
39
gomuks.go
39
gomuks.go
@ -23,10 +23,10 @@ import (
|
||||
"time"
|
||||
|
||||
"maunium.net/go/gomuks/config"
|
||||
"maunium.net/go/gomuks/debug"
|
||||
"maunium.net/go/gomuks/interface"
|
||||
"maunium.net/go/gomuks/matrix"
|
||||
"maunium.net/go/gomuks/ui"
|
||||
"maunium.net/go/gomuks/ui/debug"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
@ -35,7 +35,6 @@ type Gomuks struct {
|
||||
app *tview.Application
|
||||
ui *ui.GomuksUI
|
||||
matrix *matrix.Container
|
||||
debug *debug.Pane
|
||||
debugMode bool
|
||||
config *config.Config
|
||||
stop chan bool
|
||||
@ -43,19 +42,14 @@ type Gomuks struct {
|
||||
|
||||
// NewGomuks creates a new Gomuks instance with everything initialized,
|
||||
// but does not start it.
|
||||
func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
||||
func NewGomuks(enableDebug bool) *Gomuks {
|
||||
configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks")
|
||||
gmx := &Gomuks{
|
||||
app: tview.NewApplication(),
|
||||
stop: make(chan bool, 1),
|
||||
app: tview.NewApplication(),
|
||||
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.ui = ui.NewGomuksUI(gmx)
|
||||
gmx.matrix = matrix.NewContainer(gmx)
|
||||
@ -68,15 +62,6 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
||||
_ = gmx.matrix.InitClient()
|
||||
|
||||
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)
|
||||
|
||||
return gmx
|
||||
@ -85,10 +70,10 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
|
||||
// Save saves the active session and message history.
|
||||
func (gmx *Gomuks) Save() {
|
||||
if gmx.config.Session != nil {
|
||||
gmx.debug.Print("Saving session...")
|
||||
debug.Print("Saving session...")
|
||||
_ = gmx.config.Session.Save()
|
||||
}
|
||||
gmx.debug.Print("Saving history...")
|
||||
debug.Print("Saving history...")
|
||||
gmx.ui.MainView().SaveAllHistory()
|
||||
}
|
||||
|
||||
@ -112,9 +97,9 @@ func (gmx *Gomuks) StartAutosave() {
|
||||
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
|
||||
// then saves everything and calls os.Exit(0).
|
||||
func (gmx *Gomuks) Stop() {
|
||||
gmx.debug.Print("Disconnecting from Matrix...")
|
||||
debug.Print("Disconnecting from Matrix...")
|
||||
gmx.matrix.Stop()
|
||||
gmx.debug.Print("Cleaning up UI...")
|
||||
debug.Print("Cleaning up UI...")
|
||||
gmx.app.Stop()
|
||||
gmx.stop <- true
|
||||
gmx.Save()
|
||||
@ -132,7 +117,7 @@ func (gmx *Gomuks) Recover() {
|
||||
if gmx.debugMode {
|
||||
panic(p)
|
||||
} else {
|
||||
debug.PrettyPanic()
|
||||
debug.PrettyPanic(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -170,8 +155,8 @@ func (gmx *Gomuks) UI() ifc.GomuksUI {
|
||||
}
|
||||
|
||||
func main() {
|
||||
debugVar := os.Getenv("DEBUG")
|
||||
NewGomuks(len(debugVar) > 0, debugVar == "ext").Start()
|
||||
enableDebug := len(os.Getenv("DEBUG")) > 0
|
||||
NewGomuks(enableDebug).Start()
|
||||
|
||||
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
|
||||
time.Sleep(5 * time.Second)
|
||||
|
@ -34,4 +34,6 @@ type MatrixContainer interface {
|
||||
LeaveRoom(roomID string) error
|
||||
GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error)
|
||||
GetRoom(roomID string) *rooms.Room
|
||||
Download(mxcURL string) ([]byte, string, string, error)
|
||||
GetCachePath(homeserver, fileID string) string
|
||||
}
|
||||
|
@ -17,11 +17,12 @@
|
||||
package ifc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/gomatrix"
|
||||
"maunium.net/go/gomuks/matrix/pushrules"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
"maunium.net/go/gomuks/ui/types"
|
||||
"maunium.net/go/gomuks/ui/widget"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
@ -42,7 +43,7 @@ type GomuksUI interface {
|
||||
}
|
||||
|
||||
type MainView interface {
|
||||
GetRoom(roomID string) *widget.RoomView
|
||||
GetRoom(roomID string) RoomView
|
||||
HasRoom(roomID string) bool
|
||||
AddRoom(roomID string)
|
||||
RemoveRoom(roomID string)
|
||||
@ -50,11 +51,76 @@ type MainView interface {
|
||||
SaveAllHistory()
|
||||
|
||||
SetTyping(roomID string, users []string)
|
||||
AddServiceMessage(roomID *widget.RoomView, message string)
|
||||
ProcessMessageEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
|
||||
ProcessMembershipEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
|
||||
NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould)
|
||||
ParseEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||
//ProcessMessageEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||
//ProcessMembershipEvent(roomView RoomView, evt *gomatrix.Event) Message
|
||||
NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould)
|
||||
}
|
||||
|
||||
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
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package debug
|
||||
package open
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
var writer io.Writer
|
||||
const FileProtocolHandler = "url.dll,FileProtocolHandler"
|
||||
|
||||
func EnableExternal() {
|
||||
var err error
|
||||
writer, err = os.OpenFile("/tmp/gomuks-debug.log", os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
writer = nil
|
||||
}
|
||||
}
|
||||
var RunDLL32 = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe")
|
||||
|
||||
func ExtPrintf(text string, args ...interface{}) {
|
||||
if writer != nil {
|
||||
fmt.Fprintf(writer, text+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func ExtPrint(text ...interface{}) {
|
||||
if writer != nil {
|
||||
fmt.Fprintln(writer, text...)
|
||||
}
|
||||
func Open(input string) error {
|
||||
return exec.Command(RunDLL32, FileProtocolHandler, input).Start()
|
||||
}
|
@ -17,18 +17,26 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/gomatrix"
|
||||
"maunium.net/go/gomuks/config"
|
||||
"maunium.net/go/gomuks/debug"
|
||||
"maunium.net/go/gomuks/interface"
|
||||
"maunium.net/go/gomuks/matrix/pushrules"
|
||||
"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.
|
||||
@ -221,13 +229,13 @@ func (c *Container) HandleMessage(evt *gomatrix.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
message := mainView.ProcessMessageEvent(roomView, evt)
|
||||
message := mainView.ParseEvent(roomView, evt)
|
||||
if message != nil {
|
||||
if c.syncer.FirstSyncDone {
|
||||
pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
|
||||
mainView.NotifyMessage(roomView.Room, message, pushRules)
|
||||
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
|
||||
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
|
||||
}
|
||||
roomView.AddMessage(message, widget.AppendMessage)
|
||||
roomView.AddMessage(message, ifc.AppendMessage)
|
||||
c.ui.Render()
|
||||
}
|
||||
}
|
||||
@ -255,8 +263,7 @@ func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) {
|
||||
if evt.Unsigned.PrevContent != nil {
|
||||
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
|
||||
}
|
||||
const Hour = 1 * 60 * 60 * 1000
|
||||
if membership == prevMembership || evt.Unsigned.Age > Hour {
|
||||
if membership == prevMembership {
|
||||
return
|
||||
}
|
||||
switch membership {
|
||||
@ -279,18 +286,18 @@ func (c *Container) HandleMembership(evt *gomatrix.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
message := mainView.ProcessMembershipEvent(roomView, evt)
|
||||
message := mainView.ParseEvent(roomView, evt)
|
||||
if message != nil {
|
||||
// TODO this shouldn't be necessary
|
||||
roomView.Room.UpdateState(evt)
|
||||
roomView.MxRoom().UpdateState(evt)
|
||||
// TODO This should probably also be in a different place
|
||||
roomView.UpdateUserList()
|
||||
|
||||
if c.syncer.FirstSyncDone {
|
||||
pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
|
||||
mainView.NotifyMessage(roomView.Room, message, pushRules)
|
||||
pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
|
||||
mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
|
||||
}
|
||||
roomView.AddMessage(message, widget.AppendMessage)
|
||||
roomView.AddMessage(message, ifc.AppendMessage)
|
||||
c.ui.Render()
|
||||
}
|
||||
}
|
||||
@ -403,3 +410,55 @@ func (c *Container) GetRoom(roomID string) *rooms.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.firstMemberCache = ""
|
||||
fallthrough
|
||||
case "m.room.name":
|
||||
fallthrough
|
||||
case "m.room.canonical_alias":
|
||||
fallthrough
|
||||
case "m.room.alias":
|
||||
case "m.room.name", "m.room.canonical_alias", "m.room.alias":
|
||||
room.nameCache = ""
|
||||
case "m.room.topic":
|
||||
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
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package widget
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
"maunium.net/go/gomuks/ui/widget"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
@ -126,11 +127,11 @@ func (list *RoomList) Draw(screen tcell.Screen) {
|
||||
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
|
||||
}
|
||||
|
||||
writeLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
|
||||
widget.WriteLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
|
||||
|
||||
y++
|
||||
if y >= bottomLimit {
|
@ -14,7 +14,7 @@
|
||||
// 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
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -23,9 +23,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/gomuks/interface"
|
||||
"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"
|
||||
)
|
||||
|
||||
@ -36,8 +38,8 @@ type RoomView struct {
|
||||
content *MessageView
|
||||
status *tview.TextView
|
||||
userList *tview.TextView
|
||||
ulBorder *Border
|
||||
input *AdvancedInputField
|
||||
ulBorder *widget.Border
|
||||
input *widget.AdvancedInputField
|
||||
Room *rooms.Room
|
||||
}
|
||||
|
||||
@ -45,13 +47,13 @@ func NewRoomView(room *rooms.Room) *RoomView {
|
||||
view := &RoomView{
|
||||
Box: tview.NewBox(),
|
||||
topic: tview.NewTextView(),
|
||||
content: NewMessageView(),
|
||||
status: tview.NewTextView(),
|
||||
userList: tview.NewTextView(),
|
||||
ulBorder: NewBorder(),
|
||||
input: NewAdvancedInputField(),
|
||||
ulBorder: widget.NewBorder(),
|
||||
input: widget.NewAdvancedInputField(),
|
||||
Room: room,
|
||||
}
|
||||
view.content = NewMessageView(view)
|
||||
|
||||
view.input.
|
||||
SetFieldBackgroundColor(tcell.ColorDefault).
|
||||
@ -79,8 +81,8 @@ func (view *RoomView) SaveHistory(dir string) error {
|
||||
return view.MessageView().SaveHistory(view.logPath(dir))
|
||||
}
|
||||
|
||||
func (view *RoomView) LoadHistory(dir string) (int, error) {
|
||||
return view.MessageView().LoadHistory(view.logPath(dir))
|
||||
func (view *RoomView) LoadHistory(gmx ifc.Gomuks, dir string) (int, error) {
|
||||
return view.MessageView().LoadHistory(gmx, view.logPath(dir))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func (view *RoomView) GetInputField() *AdvancedInputField {
|
||||
func (view *RoomView) GetInputField() *widget.AdvancedInputField {
|
||||
return view.input
|
||||
}
|
||||
|
||||
@ -230,15 +232,19 @@ func (view *RoomView) MessageView() *MessageView {
|
||||
return view.content
|
||||
}
|
||||
|
||||
func (view *RoomView) MxRoom() *rooms.Room {
|
||||
return view.Room
|
||||
}
|
||||
|
||||
func (view *RoomView) UpdateUserList() {
|
||||
var joined strings.Builder
|
||||
var invited strings.Builder
|
||||
for _, user := range view.Room.GetMembers() {
|
||||
if user.Membership == "join" {
|
||||
joined.WriteString(AddHashColor(user.DisplayName))
|
||||
joined.WriteString(widget.AddHashColor(user.DisplayName))
|
||||
joined.WriteRune('\n')
|
||||
} else if user.Membership == "invite" {
|
||||
invited.WriteString(AddHashColor(user.DisplayName))
|
||||
invited.WriteString(widget.AddHashColor(user.DisplayName))
|
||||
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)
|
||||
if member != nil {
|
||||
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()
|
||||
id := strconv.FormatInt(now.UnixNano(), 10)
|
||||
sender := ""
|
||||
if ownerMember := view.Room.GetSessionOwner(); ownerMember != nil {
|
||||
sender = ownerMember.DisplayName
|
||||
}
|
||||
message := view.NewMessage(id, sender, msgtype, text, now)
|
||||
message.State = types.MessageStateSending
|
||||
view.AddMessage(message, AppendMessage)
|
||||
message := view.newUIMessage(id, sender, msgtype, text, now)
|
||||
message.SetState(ifc.MessageStateSending)
|
||||
view.AddMessage(message, ifc.AppendMessage)
|
||||
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)
|
||||
}
|
@ -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
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/gomuks/interface"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
@ -19,7 +19,7 @@ package ui
|
||||
import (
|
||||
"maunium.net/go/gomuks/config"
|
||||
"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/tview"
|
||||
)
|
||||
|
163
ui/view-main.go
163
ui/view-main.go
@ -23,26 +23,26 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"maunium.net/go/gomatrix"
|
||||
"maunium.net/go/gomuks/config"
|
||||
"maunium.net/go/gomuks/debug"
|
||||
"maunium.net/go/gomuks/interface"
|
||||
"maunium.net/go/gomuks/lib/notification"
|
||||
"maunium.net/go/gomuks/matrix/pushrules"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
"maunium.net/go/gomuks/notification"
|
||||
"maunium.net/go/gomuks/ui/debug"
|
||||
"maunium.net/go/gomuks/ui/types"
|
||||
"maunium.net/go/gomuks/ui/messages/parser"
|
||||
"maunium.net/go/gomuks/ui/widget"
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
type MainView struct {
|
||||
*tview.Flex
|
||||
|
||||
roomList *widget.RoomList
|
||||
roomList *RoomList
|
||||
roomView *tview.Pages
|
||||
rooms map[string]*widget.RoomView
|
||||
rooms map[string]*RoomView
|
||||
currentRoomIndex int
|
||||
roomIDs []string
|
||||
|
||||
@ -57,9 +57,9 @@ type MainView struct {
|
||||
func (ui *GomuksUI) NewMainView() tview.Primitive {
|
||||
mainView := &MainView{
|
||||
Flex: tview.NewFlex(),
|
||||
roomList: widget.NewRoomList(),
|
||||
roomList: NewRoomList(),
|
||||
roomView: tview.NewPages(),
|
||||
rooms: make(map[string]*widget.RoomView),
|
||||
rooms: make(map[string]*RoomView),
|
||||
|
||||
matrix: ui.gmx.Matrix(),
|
||||
gmx: ui.gmx,
|
||||
@ -81,7 +81,7 @@ func (view *MainView) BumpFocus() {
|
||||
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 {
|
||||
go view.matrix.SendTyping(roomView.Room.ID, false)
|
||||
} else if text[0] != '/' {
|
||||
@ -101,7 +101,7 @@ func findWordToTabComplete(text string) string {
|
||||
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, "")
|
||||
word := findWordToTabComplete(str)
|
||||
userCompletions := roomView.AutocompleteUser(word)
|
||||
@ -118,7 +118,7 @@ func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, c
|
||||
return text
|
||||
}
|
||||
|
||||
func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
|
||||
func (view *MainView) InputSubmit(roomView *RoomView, text string) {
|
||||
if len(text) == 0 {
|
||||
return
|
||||
} else if text[0] == '/' {
|
||||
@ -132,29 +132,30 @@ func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
|
||||
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)
|
||||
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()
|
||||
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 {
|
||||
tempMessage.State = types.MessageStateFailed
|
||||
tempMessage.SetState(ifc.MessageStateFailed)
|
||||
roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err))
|
||||
} else {
|
||||
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()
|
||||
debug.Print("Handling command", command, args)
|
||||
switch command {
|
||||
case "/me":
|
||||
tempMessage := roomView.NewTempMessage("m.emote", strings.Join(args, " "))
|
||||
go view.sendTempMessage(roomView, tempMessage)
|
||||
text := strings.Join(args, " ")
|
||||
tempMessage := roomView.NewTempMessage("m.emote", text)
|
||||
go view.sendTempMessage(roomView, tempMessage, text)
|
||||
view.parent.Render()
|
||||
case "/quit":
|
||||
view.gmx.Stop()
|
||||
@ -163,22 +164,20 @@ func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, a
|
||||
view.gmx.Stop()
|
||||
case "/panic":
|
||||
panic("This is a test panic.")
|
||||
case "/part":
|
||||
fallthrough
|
||||
case "/leave":
|
||||
case "/part", "/leave":
|
||||
debug.Print("Leave room result:", view.matrix.LeaveRoom(roomView.Room.ID))
|
||||
case "/join":
|
||||
if len(args) == 0 {
|
||||
view.AddServiceMessage(roomView, "Usage: /join <room>")
|
||||
roomView.AddServiceMessage("Usage: /join <room>")
|
||||
break
|
||||
}
|
||||
debug.Print("Join room result:", view.matrix.JoinRoom(args[0]))
|
||||
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()
|
||||
|
||||
k := key.Key()
|
||||
@ -220,8 +219,8 @@ func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.Even
|
||||
|
||||
const WheelScrollOffsetDiff = 3
|
||||
|
||||
func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.EventMouse) *tcell.EventMouse {
|
||||
if event.Buttons() == tcell.ButtonNone {
|
||||
func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMouse) *tcell.EventMouse {
|
||||
if event.Buttons() == tcell.ButtonNone || event.HasMotion() {
|
||||
return event
|
||||
}
|
||||
view.BumpFocus()
|
||||
@ -247,7 +246,14 @@ func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.
|
||||
roomView.Room.MarkRead()
|
||||
}
|
||||
default:
|
||||
debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y)
|
||||
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)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
@ -300,7 +306,7 @@ func (view *MainView) addRoom(index int, room string) {
|
||||
|
||||
view.roomList.Add(roomStore)
|
||||
if !view.roomView.HasPage(room) {
|
||||
roomView := widget.NewRoomView(roomStore).
|
||||
roomView := NewRoomView(roomStore).
|
||||
SetInputSubmitFunc(view.InputSubmit).
|
||||
SetInputChangedFunc(view.InputChanged).
|
||||
SetTabCompleteFunc(view.InputTabComplete).
|
||||
@ -310,7 +316,7 @@ func (view *MainView) addRoom(index int, room string) {
|
||||
view.roomView.AddPage(room, roomView, true, false)
|
||||
roomView.UpdateUserList()
|
||||
|
||||
count, err := roomView.LoadHistory(view.config.HistoryDir)
|
||||
count, err := roomView.LoadHistory(view.gmx, view.config.HistoryDir)
|
||||
if err != nil {
|
||||
debug.Printf("Failed to load history of %s: %v", roomView.Room.GetTitle(), err)
|
||||
} 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]
|
||||
}
|
||||
|
||||
@ -352,7 +358,7 @@ func (view *MainView) RemoveRoom(room string) {
|
||||
} else {
|
||||
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.roomView.RemovePage(room)
|
||||
delete(view.rooms, room)
|
||||
@ -363,7 +369,7 @@ func (view *MainView) SetRooms(rooms []string) {
|
||||
view.roomIDs = rooms
|
||||
view.roomList.Clear()
|
||||
view.roomView.Clear()
|
||||
view.rooms = make(map[string]*widget.RoomView)
|
||||
view.rooms = make(map[string]*RoomView)
|
||||
for index, room := range rooms {
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
isCurrent := room.ID == view.CurrentRoomID()
|
||||
// Whether or not the terminal window is focused.
|
||||
isFocused := view.lastFocusTime.Add(30 * time.Second).Before(time.Now())
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
// Push rules say notify and the terminal is not focused, send desktop notification.
|
||||
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 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()
|
||||
message.SetIsHighlight(should.Highlight)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
roomView.Room.PrevBatch = prevBatch
|
||||
for _, evt := range history {
|
||||
var message *types.Message
|
||||
if evt.Type == "m.room.message" {
|
||||
message = view.ProcessMessageEvent(roomView, &evt)
|
||||
} else if evt.Type == "m.room.member" {
|
||||
message = view.ProcessMembershipEvent(roomView, &evt)
|
||||
}
|
||||
message := view.ParseEvent(roomView, &evt)
|
||||
if message != nil {
|
||||
roomView.AddMessage(message, widget.PrependMessage)
|
||||
roomView.AddMessage(message, ifc.PrependMessage)
|
||||
}
|
||||
}
|
||||
err = roomView.SaveHistory(view.config.HistoryDir)
|
||||
@ -476,63 +466,6 @@ func (view *MainView) LoadHistory(room string, initial bool) {
|
||||
view.parent.Render()
|
||||
}
|
||||
|
||||
func (view *MainView) ProcessMessageEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) {
|
||||
text, _ := evt.Content["body"].(string)
|
||||
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
|
||||
func (view *MainView) ParseEvent(roomView ifc.RoomView, evt *gomatrix.Event) ifc.Message {
|
||||
return parser.ParseEvent(view.gmx, roomView.MxRoom(), evt)
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/zyedidia/clipboard"
|
||||
"maunium.net/go/tview"
|
||||
|
@ -17,7 +17,7 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
|
@ -21,7 +21,7 @@ import (
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell"
|
||||
"maunium.net/go/tcell"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"maunium.net/go/tview"
|
||||
)
|
||||
|
||||
func writeLineSimple(screen tcell.Screen, line string, x, y int) {
|
||||
writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
||||
func WriteLineSimple(screen tcell.Screen, line string, x, y int) {
|
||||
WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
||||
}
|
||||
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
if align == tview.AlignRight {
|
||||
offsetX = maxWidth - runewidth.StringWidth(line)
|
||||
|
Loading…
Reference in New Issue
Block a user