diff options
51 files changed, 3026 insertions, 974 deletions
diff --git a/config/config.go b/config/config.go index 4ad6793..85160c6 100644 --- a/config/config.go +++ b/config/config.go @@ -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"), } } diff --git a/config/session.go b/config/session.go index 912fed0..26d0daa 100644 --- a/config/session.go +++ b/config/session.go @@ -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 { diff --git a/ui/debug/debug.go b/debug/debug.go index 3f47980..c6f367e 100644 --- a/ui/debug/debug.go +++ b/debug/debug.go @@ -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{}) -} - -type Pane struct { - *tview.TextView - Height int - Width int - num int -} +var writer io.Writer -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 +func Printf(text string, args ...interface{}) { + if writer != nil { + fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) + fmt.Fprintf(writer, text+"\n", args...) } - 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...) +func Print(text ...interface{}) { + if writer != nil { + fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) + fmt.Fprintln(writer, text...) } } -func Print(text ...interface{}) { - if RedirectAllExt { - ExtPrint(text...) - } else if Default != nil { - Default.Print(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 { diff --git a/debug/doc.go b/debug/doc.go new file mode 100644 index 0000000..253441c --- /dev/null +++ b/debug/doc.go @@ -0,0 +1,2 @@ +// Package debug contains utilities to log debug messages and display panics nicely. +package debug @@ -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) diff --git a/interface/matrix.go b/interface/matrix.go index f811dff..3a1ec14 100644 --- a/interface/matrix.go +++ b/interface/matrix.go @@ -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 } diff --git a/interface/ui.go b/interface/ui.go index c0ddf53..e6071fc 100644 --- a/interface/ui.go +++ b/interface/ui.go @@ -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 +} diff --git a/lib/ansimage/LICENSE b/lib/ansimage/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/lib/ansimage/LICENSE @@ -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. diff --git a/lib/ansimage/ansimage.go b/lib/ansimage/ansimage.go new file mode 100644 index 0000000..b117b3d --- /dev/null +++ b/lib/ansimage/ansimage.go @@ -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 +} diff --git a/lib/ansimage/doc.go b/lib/ansimage/doc.go new file mode 100644 index 0000000..56bc0c5 --- /dev/null +++ b/lib/ansimage/doc.go @@ -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 + diff --git a/lib/htmlparser/doc.go b/lib/htmlparser/doc.go new file mode 100644 index 0000000..0e31960 --- /dev/null +++ b/lib/htmlparser/doc.go @@ -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 diff --git a/lib/htmlparser/htmlparser.go b/lib/htmlparser/htmlparser.go new file mode 100644 index 0000000..5ef8d98 --- /dev/null +++ b/lib/htmlparser/htmlparser.go @@ -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() +} diff --git a/notification/doc.go b/lib/notification/doc.go index 05295c6..05295c6 100644 --- a/notification/doc.go +++ b/lib/notification/doc.go diff --git a/notification/notify_darwin.go b/lib/notification/notify_darwin.go index 1e09ef8..1e09ef8 100644 --- a/notification/notify_darwin.go +++ b/lib/notification/notify_darwin.go diff --git a/notification/notify_linux.go b/lib/notification/notify_linux.go index f8ecdaf..f8ecdaf 100644 --- a/notification/notify_linux.go +++ b/lib/notification/notify_linux.go diff --git a/notification/notify_unsupported.go b/lib/notification/notify_unsupported.go index 0b350e8..0b350e8 100644 --- a/notification/notify_unsupported.go +++ b/lib/notification/notify_unsupported.go diff --git a/notification/notify_windows.go b/lib/notification/notify_windows.go index d927ee1..d927ee1 100644 --- a/notification/notify_windows.go +++ b/lib/notification/notify_windows.go diff --git a/lib/open/doc.go b/lib/open/doc.go new file mode 100644 index 0000000..367ffb7 --- /dev/null +++ b/lib/open/doc.go @@ -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 diff --git a/lib/open/open.go b/lib/open/open.go new file mode 100644 index 0000000..3fde8e3 --- /dev/null +++ b/lib/open/open.go @@ -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() +} diff --git a/lib/open/open_darwin.go b/lib/open/open_darwin.go new file mode 100644 index 0000000..9f53ef5 --- /dev/null +++ b/lib/open/open_darwin.go @@ -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() +} diff --git a/ui/debug/external.go b/lib/open/open_windows.go index faabbcc..aced662 100644 --- a/ui/debug/external.go +++ b/lib/open/open_windows.go @@ -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 - } -} - -func ExtPrintf(text string, args ...interface{}) { - if writer != nil { - fmt.Fprintf(writer, text+"\n", args...) - } -} +var RunDLL32 = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe") -func ExtPrint(text ...interface{}) { - if writer != nil { - fmt.Fprintln(writer, text...) - } +func Open(input string) error { + return exec.Command(RunDLL32, FileProtocolHandler, input).Start() } diff --git a/matrix/matrix.go b/matrix/matrix.go index 7391ca0..48bb57f 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -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) +} diff --git a/matrix/rooms/room.go b/matrix/rooms/room.go index 7dd2af4..2e12a28 100644 --- a/matrix/rooms/room.go +++ b/matrix/rooms/room.go @@ -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 = "" diff --git a/ui/debug/doc.go b/ui/debug/doc.go deleted file mode 100644 index a321689..0000000 --- a/ui/debug/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package debug contains utilities to display debug messages while running an interactive tview program. -package debug diff --git a/ui/message-view.go b/ui/message-view.go new file mode 100644 index 0000000..80add59 --- /dev/null +++ b/ui/message-view.go @@ -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) + } +} diff --git a/ui/messages/base.go b/ui/messages/base.go new file mode 100644 index 0000000..aed7903 --- /dev/null +++ b/ui/messages/base.go @@ -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 +} diff --git a/ui/messages/doc.go b/ui/messages/doc.go new file mode 100644 index 0000000..289c308 --- /dev/null +++ b/ui/messages/doc.go @@ -0,0 +1,2 @@ +// Package messages contains different message types and code to generate and render them. +package messages diff --git a/ui/messages/expandedtextmessage.go b/ui/messages/expandedtextmessage.go new file mode 100644 index 0000000..3ee15ad --- /dev/null +++ b/ui/messages/expandedtextmessage.go @@ -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) +} diff --git a/ui/messages/imagemessage.go b/ui/messages/imagemessage.go new file mode 100644 index 0000000..2fbf6ae --- /dev/null +++ b/ui/messages/imagemessage.go @@ -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) +} + diff --git a/ui/messages/message.go b/ui/messages/message.go new file mode 100644 index 0000000..6ebfb6d --- /dev/null +++ b/ui/messages/message.go @@ -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" diff --git a/ui/messages/meta.go b/ui/messages/meta.go new file mode 100644 index 0000000..7e2f29f --- /dev/null +++ b/ui/messages/meta.go @@ -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() +} diff --git a/ui/messages/parser/htmlparser.go b/ui/messages/parser/htmlparser.go new file mode 100644 index 0000000..9ca707f --- /dev/null +++ b/ui/messages/parser/htmlparser.go @@ -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 +} diff --git a/ui/messages/parser/htmltagarray.go b/ui/messages/parser/htmltagarray.go new file mode 100644 index 0000000..4cd4245 --- /dev/null +++ b/ui/messages/parser/htmltagarray.go @@ -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 +} diff --git a/ui/messages/parser/parser.go b/ui/messages/parser/parser.go new file mode 100644 index 0000000..939dd10 --- /dev/null +++ b/ui/messages/parser/parser.go @@ -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) +} diff --git a/ui/messages/textbase.go b/ui/messages/textbase.go new file mode 100644 index 0000000..d7eb16c --- /dev/null +++ b/ui/messages/textbase.go @@ -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 +} diff --git a/ui/messages/textmessage.go b/ui/messages/textmessage.go new file mode 100644 index 0000000..4c99e5b --- /dev/null +++ b/ui/messages/textmessage.go @@ -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) +} diff --git a/ui/messages/tstring/cell.go b/ui/messages/tstring/cell.go new file mode 100644 index 0000000..8a400ee --- /dev/null +++ b/ui/messages/tstring/cell.go @@ -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 +} diff --git a/ui/messages/tstring/string.go b/ui/messages/tstring/string.go new file mode 100644 index 0000000..a87d16a --- /dev/null +++ b/ui/messages/tstring/string.go @@ -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] +} diff --git a/ui/widget/room-list.go b/ui/room-list.go index d2fb543..b85ed24 100644 --- a/ui/widget/room-list.go +++ b/ui/room-list.go @@ -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 { diff --git a/ui/widget/room-view.go b/ui/room-view.go index 4bab779..d7824fe 100644 --- a/ui/widget/room-view.go +++ b/ui/room-view.go @@ -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) 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) *types.Message { +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) } diff --git a/ui/types/doc.go b/ui/types/doc.go deleted file mode 100644 index 5bc229c..0000000 --- a/ui/types/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package types contains common type definitions used mostly by the UI, but also other parts of gomuks. -package types diff --git a/ui/types/message.go b/ui/types/message.go deleted file mode 100644 index fa3b6ef..0000000 --- a/ui/types/message.go +++ /dev/null @@ -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 -} diff --git a/ui/types/meta.go b/ui/types/meta.go deleted file mode 100644 index fdc6dba..0000000 --- a/ui/types/meta.go +++ /dev/null @@ -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 -} @@ -17,7 +17,7 @@ package ui import ( - "github.com/gdamore/tcell" + "maunium.net/go/tcell" "maunium.net/go/gomuks/interface" "maunium.net/go/tview" ) diff --git a/ui/view-login.go b/ui/view-login.go index ff0e44e..8343aaa 100644 --- a/ui/view-login.go +++ b/ui/view-login.go @@ -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" ) diff --git a/ui/view-main.go b/ui/view-main.go index fd05492..d4ffd39 100644 --- a/ui/view-main.go +++ b/ui/view-main.go @@ -23,26 +23,26 @@ import ( "time" "unicode" - "github.com/gdamore/tcell" "github.com/mattn/go-runewidth" "maunium.net/go/gomatrix" "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/lib/notification" "maunium.net/go/gomuks/matrix/pushrules" "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/notification" - "maunium.net/go/gomuks/ui/debug" - "maunium.net/go/gomuks/ui/types" + "maunium.net/go/gomuks/ui/messages/parser" "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tcell" "maunium.net/go/tview" ) type MainView struct { *tview.Flex - roomList *widget.RoomList + roomList *RoomList roomView *tview.Pages - rooms map[string]*widget.RoomView + rooms map[string]*RoomView currentRoomIndex int roomIDs []string @@ -57,9 +57,9 @@ type MainView struct { func (ui *GomuksUI) NewMainView() tview.Primitive { mainView := &MainView{ Flex: tview.NewFlex(), - roomList: widget.NewRoomList(), + roomList: NewRoomList(), roomView: tview.NewPages(), - rooms: make(map[string]*widget.RoomView), + rooms: make(map[string]*RoomView), matrix: ui.gmx.Matrix(), gmx: ui.gmx, @@ -81,7 +81,7 @@ func (view *MainView) BumpFocus() { view.lastFocusTime = time.Now() } -func (view *MainView) InputChanged(roomView *widget.RoomView, text string) { +func (view *MainView) InputChanged(roomView *RoomView, text string) { if len(text) == 0 { go view.matrix.SendTyping(roomView.Room.ID, false) } else if text[0] != '/' { @@ -101,7 +101,7 @@ func findWordToTabComplete(text string) string { return output } -func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, cursorOffset int) string { +func (view *MainView) InputTabComplete(roomView *RoomView, text string, cursorOffset int) string { str := runewidth.Truncate(text, cursorOffset, "") word := findWordToTabComplete(str) userCompletions := roomView.AutocompleteUser(word) @@ -118,7 +118,7 @@ func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, c return text } -func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) { +func (view *MainView) InputSubmit(roomView *RoomView, text string) { if len(text) == 0 { return } else if text[0] == '/' { @@ -132,29 +132,30 @@ func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) { roomView.SetInputText("") } -func (view *MainView) SendMessage(roomView *widget.RoomView, text string) { +func (view *MainView) SendMessage(roomView *RoomView, text string) { tempMessage := roomView.NewTempMessage("m.text", text) - go view.sendTempMessage(roomView, tempMessage) + go view.sendTempMessage(roomView, tempMessage, text) } -func (view *MainView) sendTempMessage(roomView *widget.RoomView, tempMessage *types.Message) { +func (view *MainView) sendTempMessage(roomView *RoomView, tempMessage ifc.Message, text string) { defer view.gmx.Recover() - eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type, tempMessage.Text) + eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type(), text) if err != nil { - tempMessage.State = types.MessageStateFailed + tempMessage.SetState(ifc.MessageStateFailed) roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err)) } else { roomView.MessageView().UpdateMessageID(tempMessage, eventID) } } -func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, args []string) { +func (view *MainView) HandleCommand(roomView *RoomView, command string, args []string) { defer view.gmx.Recover() debug.Print("Handling command", command, args) switch command { case "/me": - tempMessage := roomView.NewTempMessage("m.emote", strings.Join(args, " ")) - go view.sendTempMessage(roomView, tempMessage) + text := strings.Join(args, " ") + tempMessage := roomView.NewTempMessage("m.emote", text) + go view.sendTempMessage(roomView, tempMessage, text) view.parent.Render() case "/quit": view.gmx.Stop() @@ -163,22 +164,20 @@ func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, a view.gmx.Stop() case "/panic": panic("This is a test panic.") - case "/part": - fallthrough - case "/leave": + case "/part", "/leave": debug.Print("Leave room result:", view.matrix.LeaveRoom(roomView.Room.ID)) case "/join": if len(args) == 0 { - view.AddServiceMessage(roomView, "Usage: /join <room>") + roomView.AddServiceMessage("Usage: /join <room>") break } debug.Print("Join room result:", view.matrix.JoinRoom(args[0])) default: - view.AddServiceMessage(roomView, "Unknown command.") + roomView.AddServiceMessage("Unknown command.") } } -func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.EventKey) *tcell.EventKey { +func (view *MainView) KeyEventHandler(roomView *RoomView, key *tcell.EventKey) *tcell.EventKey { view.BumpFocus() k := key.Key() @@ -220,8 +219,8 @@ func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.Even const WheelScrollOffsetDiff = 3 -func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.EventMouse) *tcell.EventMouse { - if event.Buttons() == tcell.ButtonNone { +func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMouse) *tcell.EventMouse { + if event.Buttons() == tcell.ButtonNone || event.HasMotion() { return event } view.BumpFocus() @@ -247,7 +246,14 @@ func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell. roomView.Room.MarkRead() } default: - debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y) + mx, my, mw, mh := msgView.GetRect() + if x >= mx && y >= my && x < mx+mw && y < my+mh { + if msgView.HandleClick(x-mx, y-my, event.Buttons()) { + view.parent.Render() + } + } else { + debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y) + } return event } @@ -300,7 +306,7 @@ func (view *MainView) addRoom(index int, room string) { view.roomList.Add(roomStore) if !view.roomView.HasPage(room) { - roomView := widget.NewRoomView(roomStore). + roomView := NewRoomView(roomStore). SetInputSubmitFunc(view.InputSubmit). SetInputChangedFunc(view.InputChanged). SetTabCompleteFunc(view.InputTabComplete). @@ -310,7 +316,7 @@ func (view *MainView) addRoom(index int, room string) { view.roomView.AddPage(room, roomView, true, false) roomView.UpdateUserList() - count, err := roomView.LoadHistory(view.config.HistoryDir) + count, err := roomView.LoadHistory(view.gmx, view.config.HistoryDir) if err != nil { debug.Printf("Failed to load history of %s: %v", roomView.Room.GetTitle(), err) } else if count <= 0 { @@ -319,7 +325,7 @@ func (view *MainView) addRoom(index int, room string) { } } -func (view *MainView) GetRoom(id string) *widget.RoomView { +func (view *MainView) GetRoom(id string) ifc.RoomView { return view.rooms[id] } @@ -352,7 +358,7 @@ func (view *MainView) RemoveRoom(room string) { } else { removeIndex = sort.StringSlice(view.roomIDs).Search(room) } - view.roomList.Remove(roomView.Room) + view.roomList.Remove(roomView.MxRoom()) view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...) view.roomView.RemovePage(room) delete(view.rooms, room) @@ -363,7 +369,7 @@ func (view *MainView) SetRooms(rooms []string) { view.roomIDs = rooms view.roomList.Clear() view.roomView.Clear() - view.rooms = make(map[string]*widget.RoomView) + view.rooms = make(map[string]*RoomView) for index, room := range rooms { view.addRoom(index, room) } @@ -385,14 +391,14 @@ func sendNotification(room *rooms.Room, sender, text string, critical, sound boo notification.Send(sender, text, critical, sound) } -func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould) { +func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) { // Whether or not the room where the message came is the currently shown room. isCurrent := room.ID == view.CurrentRoomID() // Whether or not the terminal window is focused. isFocused := view.lastFocusTime.Add(30 * time.Second).Before(time.Now()) // Whether or not the push rules say this message should be notified about. - shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender != view.config.Session.UserID + shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender() != view.config.Session.UserID if !isCurrent { // The message is not in the current room, show new message status in room list. @@ -406,21 +412,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, sh if shouldNotify && !isFocused { // Push rules say notify and the terminal is not focused, send desktop notification. shouldPlaySound := should.PlaySound && should.SoundName == "default" - sendNotification(room, message.Sender, message.Text, should.Highlight, shouldPlaySound) + sendNotification(room, message.Sender(), message.NotificationContent(), should.Highlight, shouldPlaySound) } - if should.Highlight { - // Message is highlight, set color. - message.TextColor = tcell.ColorYellow - } -} - -func (view *MainView) AddServiceMessage(roomView *widget.RoomView, text string) { - message := roomView.NewMessage("", "*", "gomuks.service", text, time.Now()) - message.TextColor = tcell.ColorGray - message.SenderColor = tcell.ColorGray - roomView.AddMessage(message, widget.AppendMessage) - view.parent.Render() + message.SetIsHighlight(should.Highlight) } func (view *MainView) LoadHistory(room string, initial bool) { @@ -452,20 +447,15 @@ func (view *MainView) LoadHistory(room string, initial bool) { } history, prevBatch, err := view.matrix.GetHistory(roomView.Room.ID, batch, 50) if err != nil { - view.AddServiceMessage(roomView, "Failed to fetch history") + roomView.AddServiceMessage("Failed to fetch history") debug.Print("Failed to fetch history for", roomView.Room.ID, err) return } roomView.Room.PrevBatch = prevBatch for _, evt := range history { - var message *types.Message - if evt.Type == "m.room.message" { - message = view.ProcessMessageEvent(roomView, &evt) - } else if evt.Type == "m.room.member" { - message = view.ProcessMembershipEvent(roomView, &evt) - } + message := view.ParseEvent(roomView, &evt) if message != nil { - roomView.AddMessage(message, widget.PrependMessage) + roomView.AddMessage(message, ifc.PrependMessage) } } err = roomView.SaveHistory(view.config.HistoryDir) @@ -476,63 +466,6 @@ func (view *MainView) LoadHistory(room string, initial bool) { view.parent.Render() } -func (view *MainView) ProcessMessageEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) { - text, _ := evt.Content["body"].(string) - msgtype, _ := evt.Content["msgtype"].(string) - return room.NewMessage(evt.ID, evt.Sender, msgtype, text, unixToTime(evt.Timestamp)) -} - -func (view *MainView) getMembershipEventContent(evt *gomatrix.Event) (sender, text string) { - membership, _ := evt.Content["membership"].(string) - displayname, _ := evt.Content["displayname"].(string) - if len(displayname) == 0 { - displayname = *evt.StateKey - } - prevMembership := "leave" - prevDisplayname := "" - if evt.Unsigned.PrevContent != nil { - prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string) - prevDisplayname, _ = evt.Unsigned.PrevContent["displayname"].(string) - } - - if membership != prevMembership { - switch membership { - case "invite": - sender = "---" - text = fmt.Sprintf("%s invited %s.", evt.Sender, displayname) - case "join": - sender = "-->" - text = fmt.Sprintf("%s joined the room.", displayname) - case "leave": - sender = "<--" - if evt.Sender != *evt.StateKey { - reason, _ := evt.Content["reason"].(string) - text = fmt.Sprintf("%s kicked %s: %s", evt.Sender, displayname, reason) - } else { - text = fmt.Sprintf("%s left the room.", displayname) - } - } - } else if displayname != prevDisplayname { - sender = "---" - text = fmt.Sprintf("%s changed their display name to %s.", prevDisplayname, displayname) - } - return -} - -func (view *MainView) ProcessMembershipEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) { - sender, text := view.getMembershipEventContent(evt) - if len(text) == 0 { - return - } - message = room.NewMessage(evt.ID, sender, "m.room.member", text, unixToTime(evt.Timestamp)) - message.TextColor = tcell.ColorGreen - return -} - -func unixToTime(unix int64) time.Time { - timestamp := time.Now() - if unix != 0 { - timestamp = time.Unix(unix/1000, unix%1000*1000) - } - return timestamp +func (view *MainView) ParseEvent(roomView ifc.RoomView, evt *gomatrix.Event) ifc.Message { + return parser.ParseEvent(view.gmx, roomView.MxRoom(), evt) } diff --git a/ui/widget/advanced-inputfield.go b/ui/widget/advanced-inputfield.go index f74ce29..7e01478 100644 --- a/ui/widget/advanced-inputfield.go +++ b/ui/widget/advanced-inputfield.go @@ -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" diff --git a/ui/widget/border.go b/ui/widget/border.go index 7c42f3d..b3eb65d 100644 --- a/ui/widget/border.go +++ b/ui/widget/border.go @@ -17,7 +17,7 @@ package widget import ( - "github.com/gdamore/tcell" + "maunium.net/go/tcell" "maunium.net/go/tview" ) diff --git a/ui/widget/color.go b/ui/widget/color.go index 12ee791..c4f1abf 100644 --- a/ui/widget/color.go +++ b/ui/widget/color.go @@ -21,7 +21,7 @@ import ( "hash/fnv" "sort" - "github.com/gdamore/tcell" + "maunium.net/go/tcell" ) var colorNames []string diff --git a/ui/widget/message-view.go b/ui/widget/message-view.go deleted file mode 100644 index f0bdbad..0000000 --- a/ui/widget/message-view.go +++ /dev/null @@ -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()) - } -} diff --git a/ui/widget/util.go b/ui/widget/util.go index 0888210..920afad 100644 --- a/ui/widget/util.go +++ b/ui/widget/util.go @@ -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) |