Merge pull request #18 from tulir/ui-refactor

Refactor UI to use interfaces and add advanced message rendering
This commit is contained in:
Tulir Asokan 2018-04-14 18:09:02 +03:00 committed by GitHub
commit 53cdfb64c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 3027 additions and 975 deletions

View File

@ -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"),
}
}

View File

@ -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 {

View File

@ -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
View File

@ -0,0 +1,2 @@
// Package debug contains utilities to log debug messages and display panics nicely.
package debug

View File

@ -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)

View File

@ -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
}

View File

@ -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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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()
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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 = ""

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,2 @@
// Package messages contains different message types and code to generate and render them.
package messages

View 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
View 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
View 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
View 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()
}

View 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
}

View 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
}

View 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
View 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
View 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)
}

View 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
}

View 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]
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -1,2 +0,0 @@
// Package types contains common type definitions used mostly by the UI, but also other parts of gomuks.
package types

View File

@ -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
}

View File

@ -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
}

View File

@ -17,7 +17,7 @@
package ui
import (
"github.com/gdamore/tcell"
"maunium.net/go/tcell"
"maunium.net/go/gomuks/interface"
"maunium.net/go/tview"
)

View File

@ -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"
)

View File

@ -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)
}

View File

@ -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"

View File

@ -17,7 +17,7 @@
package widget
import (
"github.com/gdamore/tcell"
"maunium.net/go/tcell"
"maunium.net/go/tview"
)

View File

@ -21,7 +21,7 @@ import (
"hash/fnv"
"sort"
"github.com/gdamore/tcell"
"maunium.net/go/tcell"
)
var colorNames []string

View File

@ -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())
}
}

View File

@ -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)