diff --git a/tui/autocomplete.go b/tui/autocomplete.go
new file mode 100644
index 0000000..b4fce1b
--- /dev/null
+++ b/tui/autocomplete.go
@@ -0,0 +1,89 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) {
+ inputPath, err := filepath.Abs(cmd.RawArgs)
+ if err != nil {
+ return
+ }
+
+ var searchNamePrefix, searchDir string
+ if strings.HasSuffix(cmd.RawArgs, "/") {
+ searchDir = inputPath
+ } else {
+ searchNamePrefix = filepath.Base(inputPath)
+ searchDir = filepath.Dir(inputPath)
+ }
+ files, err := os.ReadDir(searchDir)
+ if err != nil {
+ return
+ }
+ for _, file := range files {
+ name := file.Name()
+ if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") {
+ continue
+ }
+ fullPath := filepath.Join(searchDir, name)
+ if file.IsDir() {
+ fullPath += "/"
+ }
+ completions = append(completions, fullPath)
+ }
+ if len(completions) == 1 {
+ newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0])
+ }
+ return
+}
+
+func autocompleteToggle(cmd *CommandAutocomplete) (completions []string, newText string) {
+ //??
+ completions = make([]string, 0, len(toggleMsg))
+ for k := range toggleMsg {
+ if strings.HasPrefix(k, cmd.RawArgs) {
+ completions = append(completions, k)
+ }
+ }
+ if len(completions) == 1 {
+ newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0])
+ }
+ return
+}
+
+var staticPowerLevelKeys = []string{"ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"}
+
+func autocompletePowerLevel(cmd *CommandAutocomplete) (completions []string, newText string) {
+ if len(cmd.Args) > 1 {
+ return
+ }
+ for _, staticKey := range staticPowerLevelKeys {
+ if strings.HasPrefix(staticKey, cmd.RawArgs) {
+ completions = append(completions, staticKey)
+ }
+ }
+ for _, cpl := range cmd.Room.AutocompleteUser(cmd.RawArgs) {
+ completions = append(completions, cpl.id)
+ }
+ return
+}
diff --git a/tui/command-processor.go b/tui/command-processor.go
index 2e3dcf5..2149ff8 100644
--- a/tui/command-processor.go
+++ b/tui/command-processor.go
@@ -22,17 +22,11 @@ import (
"github.com/mattn/go-runewidth"
- "maunium.net/go/gomuks/config"
- "maunium.net/go/gomuks/debug"
- ifc "maunium.net/go/gomuks/interface"
)
type gomuksPointerContainer struct {
MainView *MainView
- UI *GomuksUI
- Matrix ifc.MatrixContainer
- Config *config.Config
- Gomuks ifc.Gomuks
+ TUI *GomuksTUI
}
type Command struct {
@@ -54,7 +48,7 @@ func (cmd *Command) Reply(message string, args ...interface{}) {
message = fmt.Sprintf(message, args...)
}
cmd.Room.AddServiceMessage(message)
- cmd.UI.Render()
+ cmd.TUI.App.Redraw()
}
type Alias struct {
@@ -82,10 +76,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
return &CommandProcessor{
gomuksPointerContainer: gomuksPointerContainer{
MainView: parent,
- UI: parent.parent,
- Matrix: parent.matrix,
- Config: parent.config,
- Gomuks: parent.gmx,
+ TUI: parent.parent,
},
aliases: map[string]*Alias{
"part": {"leave"},
diff --git a/tui/commands.go b/tui/commands.go
new file mode 100644
index 0000000..6ddbd4f
--- /dev/null
+++ b/tui/commands.go
@@ -0,0 +1,1058 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ dbg "runtime/debug"
+ "runtime/pprof"
+ "runtime/trace"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/yuin/goldmark"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+ "maunium.net/go/mautrix/id"
+
+ "maunium.net/go/gomuks/debug"
+)
+
+func cmdMe(cmd *Command) {
+ text := strings.Join(cmd.Args, " ")
+ go cmd.Room.SendMessage(event.MsgEmote, text)
+}
+
+// GradientTable from https://github.com/lucasb-eyer/go-colorful/blob/master/doc/gradientgen/gradientgen.go
+type GradientTable []struct {
+ Col colorful.Color
+ Pos float64
+}
+
+func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
+ for i := 0; i < len(gt)-1; i++ {
+ c1 := gt[i]
+ c2 := gt[i+1]
+ if c1.Pos <= t && t <= c2.Pos {
+ t := (t - c1.Pos) / (c2.Pos - c1.Pos)
+ return c1.Col.BlendHcl(c2.Col, t).Clamped()
+ }
+ }
+ return gt[len(gt)-1].Col
+}
+
+var rainbow = GradientTable{
+ {colorful.LinearRgb(1, 0, 0), 0 / 11.0},
+ {colorful.LinearRgb(1, 0.5, 0), 1 / 11.0},
+ {colorful.LinearRgb(1, 1, 0), 2 / 11.0},
+ {colorful.LinearRgb(0.5, 1, 0), 3 / 11.0},
+ {colorful.LinearRgb(0, 1, 0), 4 / 11.0},
+ {colorful.LinearRgb(0, 1, 0.5), 5 / 11.0},
+ {colorful.LinearRgb(0, 1, 1), 6 / 11.0},
+ {colorful.LinearRgb(0, 0.5, 1), 7 / 11.0},
+ {colorful.LinearRgb(0, 0, 1), 8 / 11.0},
+ {colorful.LinearRgb(0.5, 0, 1), 9 / 11.0},
+ {colorful.LinearRgb(1, 0, 1), 10 / 11.0},
+ {colorful.LinearRgb(1, 0, 0.5), 11 / 11.0},
+}
+
+var rainbowMark = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(ExtensionRainbow))
+
+// TODO this command definitely belongs in a plugin once we have a plugin system.
+func makeRainbow(cmd *Command, msgtype event.MessageType) {
+ text := strings.Join(cmd.Args, " ")
+
+ var buf strings.Builder
+ _ = rainbowMark.Convert([]byte(text), &buf)
+
+ htmlBody := strings.TrimRight(buf.String(), "\n")
+ htmlBody = format.AntiParagraphRegex.ReplaceAllString(htmlBody, "$1")
+ text = format.HTMLToText(htmlBody)
+
+ count := strings.Count(htmlBody, defaultRB.ColorID)
+ i := -1
+ htmlBody = regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string {
+ i++
+ return rainbow.GetInterpolatedColorFor(float64(i) / float64(count)).Hex()
+ })
+
+ go cmd.Room.SendMessageHTML(msgtype, text, htmlBody)
+}
+
+func cmdRainbow(cmd *Command) {
+ makeRainbow(cmd, event.MsgText)
+}
+
+func cmdRainbowMe(cmd *Command) {
+ makeRainbow(cmd, event.MsgEmote)
+}
+
+func cmdRainbowNotice(cmd *Command) {
+ makeRainbow(cmd, event.MsgNotice)
+}
+
+func cmdNotice(cmd *Command) {
+ go cmd.Room.SendMessage(event.MsgNotice, strings.Join(cmd.Args, " "))
+}
+
+func cmdAccept(cmd *Command) {
+ room := cmd.Room.MxRoom()
+ if room.SessionMember.Membership != "invite" {
+ cmd.Reply("/accept can only be used in rooms you're invited to")
+ return
+ }
+ _, server, _ := room.SessionMember.Sender.Parse()
+ _, err := cmd.Matrix.JoinRoom(room.ID, server)
+ if err != nil {
+ cmd.Reply("Failed to accept invite: %v", err)
+ } else {
+ cmd.Reply("Successfully accepted invite")
+ }
+ cmd.MainView.UpdateTags(room)
+ go cmd.MainView.LoadHistory(room.ID)
+}
+
+func cmdReject(cmd *Command) {
+ room := cmd.Room.MxRoom()
+ if room.SessionMember.Membership != "invite" {
+ cmd.Reply("/reject can only be used in rooms you're invited to")
+ return
+ }
+ err := cmd.Matrix.LeaveRoom(room.ID)
+ if err != nil {
+ cmd.Reply("Failed to reject invite: %v", err)
+ } else {
+ cmd.Reply("Successfully rejected invite")
+ }
+ cmd.MainView.RemoveRoom(room)
+}
+
+func cmdID(cmd *Command) {
+ cmd.Reply("The internal ID of this room is %s", cmd.Room.MxRoom().ID)
+}
+
+type SelectReason string
+
+const (
+ SelectReply SelectReason = "reply to"
+ SelectReact = "react to"
+ SelectRedact = "redact"
+ SelectEdit = "edit"
+ SelectDownload = "download"
+ SelectOpen = "open"
+ SelectCopy = "copy"
+)
+
+func cmdReply(cmd *Command) {
+ cmd.Room.StartSelecting(SelectReply, strings.Join(cmd.Args, " "))
+}
+
+func cmdEdit(cmd *Command) {
+ cmd.Room.StartSelecting(SelectEdit, "")
+}
+
+func findEditorExecutable() (string, string, error) {
+ if editor := os.Getenv("VISUAL"); len(editor) > 0 {
+ if path, err := exec.LookPath(editor); err != nil {
+ return "", "", fmt.Errorf("$VISUAL ('%s') not found in $PATH", editor)
+ } else {
+ return editor, path, nil
+ }
+ } else if editor = os.Getenv("EDITOR"); len(editor) > 0 {
+ if path, err := exec.LookPath(editor); err != nil {
+ return "", "", fmt.Errorf("$EDITOR ('%s') not found in $PATH", editor)
+ } else {
+ return editor, path, nil
+ }
+ } else if path, _ := exec.LookPath("nano"); len(path) > 0 {
+ return "nano", path, nil
+ } else if path, _ = exec.LookPath("vi"); len(path) > 0 {
+ return "vi", path, nil
+ } else {
+ return "", "", fmt.Errorf("$VISUAL and $EDITOR not set, nano and vi not found in $PATH")
+ }
+}
+
+func cmdExternalEditor(cmd *Command) {
+ var file *os.File
+ defer func() {
+ if file != nil {
+ _ = file.Close()
+ _ = os.Remove(file.Name())
+ }
+ }()
+
+ fileExtension := "md"
+ if cmd.Config.Preferences.DisableMarkdown {
+ if cmd.Config.Preferences.DisableHTML {
+ fileExtension = "txt"
+ } else {
+ fileExtension = "html"
+ }
+ }
+
+ if editorName, executablePath, err := findEditorExecutable(); err != nil {
+ cmd.Reply("Couldn't find editor to use: %v", err)
+ return
+ } else if file, err = os.CreateTemp("", fmt.Sprintf("gomuks-draft-*.%s", fileExtension)); err != nil {
+ cmd.Reply("Failed to create temp file: %v", err)
+ return
+ } else if _, err = file.WriteString(cmd.RawArgs); err != nil {
+ cmd.Reply("Failed to write to temp file: %v", err)
+ } else if err = file.Close(); err != nil {
+ cmd.Reply("Failed to close temp file: %v", err)
+ } else if err = cmd.RunExternal(executablePath, file.Name()); err != nil {
+ var exitErr *exec.ExitError
+ if isExit := errors.As(err, &exitErr); isExit {
+ cmd.Reply("%s exited with non-zero status %d", editorName, exitErr.ExitCode())
+ } else {
+ cmd.Reply("Failed to run %s: %v", editorName, err)
+ }
+ } else if data, err := os.ReadFile(file.Name()); err != nil {
+ cmd.Reply("Failed to read temp file: %v", err)
+ } else if len(bytes.TrimSpace(data)) > 0 {
+ cmd.Room.InputSubmit(string(data))
+ } else {
+ cmd.Reply("Temp file was blank, sending cancelled")
+ if cmd.Room.editing != nil {
+ cmd.Room.SetEditing(nil)
+ }
+ }
+}
+func (cmd *Command) RunExternal(executablePath string, args ...string) error {
+ callback := make(chan error)
+ cmd.TUI.App.Suspend(func() {
+ cmd := exec.Command(executablePath, args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Stdin = os.Stdin
+ cmd.Env = os.Environ()
+ callback <- cmd.Run()
+ })
+ return <-callback
+}
+
+
+func cmdRedact(cmd *Command) {
+ cmd.Room.StartSelecting(SelectRedact, strings.Join(cmd.Args, " "))
+}
+
+func cmdDownload(cmd *Command) {
+ cmd.Room.StartSelecting(SelectDownload, strings.Join(cmd.Args, " "))
+}
+
+func cmdUpload(cmd *Command) {
+ var path string
+ var err error
+ if len(cmd.Args) == 0 {
+ if filepicker.IsSupported() {
+ path, err = filepicker.Open()
+ if err != nil {
+ cmd.Reply("Failed to open file picker: %v", err)
+ return
+ } else if len(path) == 0 {
+ cmd.Reply("File picking cancelled")
+ return
+ }
+ } else {
+ cmd.Reply("Usage: /upload ")
+ return
+ }
+ } else {
+ path, err = filepath.Abs(cmd.RawArgs)
+ if err != nil {
+ cmd.Reply("Failed to get absolute path: %v", err)
+ return
+ }
+ }
+
+ go cmd.Room.SendMessageMedia(path)
+}
+
+func cmdOpen(cmd *Command) {
+ cmd.Room.StartSelecting(SelectOpen, strings.Join(cmd.Args, " "))
+}
+
+func cmdCopy(cmd *Command) {
+ register := strings.Join(cmd.Args, " ")
+ if len(register) == 0 {
+ register = "clipboard"
+ }
+ if register == "clipboard" || register == "primary" {
+ cmd.Room.StartSelecting(SelectCopy, register)
+ } else {
+ cmd.Reply("Usage: /copy [register], where register is either \"clipboard\" or \"primary\". Defaults to \"clipboard\".")
+ }
+}
+
+func cmdReact(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /react ")
+ return
+ }
+
+ cmd.Room.StartSelecting(SelectReact, strings.Join(cmd.Args, " "))
+}
+
+func readRoomAlias(cmd *Command) (alias id.RoomAlias, err error) {
+ param := strings.Join(cmd.Args[1:], " ")
+ if strings.ContainsRune(param, ':') {
+ if param[0] != '#' {
+ return "", errors.New("full aliases must start with #")
+ }
+
+ alias = id.RoomAlias(param)
+ } else {
+ _, homeserver, _ := cmd.Matrix.Client().UserID.Parse()
+ alias = id.NewRoomAlias(param, homeserver)
+ }
+ return
+}
+
+func cmdAlias(cmd *Command) {
+ if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /alias ")
+ return
+ }
+
+ alias, err := readRoomAlias(cmd)
+ if err != nil {
+ cmd.Reply(err.Error())
+ return
+ }
+
+ subcmd := strings.ToLower(cmd.Args[0])
+ switch subcmd {
+ case "add", "create":
+ cmdAddAlias(cmd, alias)
+ case "remove", "delete", "del", "rm":
+ cmdRemoveAlias(cmd, alias)
+ case "resolve", "get":
+ cmdResolveAlias(cmd, alias)
+ default:
+ cmd.Reply("Usage: /alias ")
+ }
+}
+
+func niceError(err error) string {
+ httpErr, ok := err.(mautrix.HTTPError)
+ if ok && httpErr.RespError != nil {
+ return httpErr.RespError.Error()
+ }
+ return err.Error()
+}
+
+func cmdAddAlias(cmd *Command, alias id.RoomAlias) {
+ _, err := cmd.Matrix.Client().CreateAlias(alias, cmd.Room.MxRoom().ID)
+ if err != nil {
+ cmd.Reply("Failed to create alias: %v", niceError(err))
+ } else {
+ cmd.Reply("Created alias %s", alias)
+ }
+}
+
+func cmdRemoveAlias(cmd *Command, alias id.RoomAlias) {
+ _, err := cmd.Matrix.Client().DeleteAlias(alias)
+ if err != nil {
+ cmd.Reply("Failed to delete alias: %v", niceError(err))
+ } else {
+ cmd.Reply("Deleted alias %s", alias)
+ }
+}
+
+func cmdResolveAlias(cmd *Command, alias id.RoomAlias) {
+ resp, err := cmd.Matrix.Client().ResolveAlias(alias)
+ if err != nil {
+ cmd.Reply("Failed to resolve alias: %v", niceError(err))
+ } else {
+ roomIDText := string(resp.RoomID)
+ if resp.RoomID == cmd.Room.MxRoom().ID {
+ roomIDText += " (this room)"
+ }
+ cmd.Reply("Alias %s points to room %s\nThere are %d servers in the room.", alias, roomIDText, len(resp.Servers))
+ }
+}
+
+func cmdTags(cmd *Command) {
+ tags := cmd.Room.MxRoom().RawTags
+ if len(cmd.Args) > 0 && cmd.Args[0] == "--internal" {
+ tags = cmd.Room.MxRoom().Tags()
+ }
+ if len(tags) == 0 {
+ if cmd.Room.MxRoom().IsDirect {
+ cmd.Reply("This room has no tags, but it's marked as a direct chat.")
+ } else {
+ cmd.Reply("This room has no tags.")
+ }
+ return
+ }
+ var resp strings.Builder
+ resp.WriteString("Tags in this room:\n")
+ for _, tag := range tags {
+ if tag.Order != "" {
+ _, _ = fmt.Fprintf(&resp, "%s (order: %s)\n", tag.Tag, tag.Order)
+ } else {
+ _, _ = fmt.Fprintf(&resp, "%s (no order)\n", tag.Tag)
+ }
+ }
+ cmd.Reply(strings.TrimSpace(resp.String()))
+}
+
+func cmdTag(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /tag [order]")
+ return
+ }
+ order := math.NaN()
+ if len(cmd.Args) > 1 {
+ var err error
+ order, err = strconv.ParseFloat(cmd.Args[1], 64)
+ if err != nil {
+ cmd.Reply("%s is not a valid order: %v", cmd.Args[1], err)
+ return
+ }
+ }
+ var err error
+ if len(cmd.Args) > 2 && cmd.Args[2] == "--reset" {
+ tags := event.Tags{
+ cmd.Args[0]: {Order: json.Number(fmt.Sprintf("%f", order))},
+ }
+ for _, tag := range cmd.Room.MxRoom().RawTags {
+ tags[tag.Tag] = event.Tag{Order: tag.Order}
+ }
+ err = cmd.Matrix.Client().SetTags(cmd.Room.MxRoom().ID, tags)
+ } else {
+ err = cmd.Matrix.Client().AddTag(cmd.Room.MxRoom().ID, cmd.Args[0], order)
+ }
+ if err != nil {
+ cmd.Reply("Failed to add tag: %v", err)
+ }
+}
+
+func cmdUntag(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /untag ")
+ return
+ }
+ err := cmd.Matrix.Client().RemoveTag(cmd.Room.MxRoom().ID, cmd.Args[0])
+ if err != nil {
+ cmd.Reply("Failed to remove tag: %v", err)
+ }
+}
+
+func cmdRoomNick(cmd *Command) {
+ room := cmd.Room.MxRoom()
+ member := room.GetMember(room.SessionUserID)
+ member.Displayname = strings.Join(cmd.Args, " ")
+ _, err := cmd.Matrix.Client().SendStateEvent(room.ID, event.StateMember, string(room.SessionUserID), member)
+ if err != nil {
+ cmd.Reply("Failed to set room nick: %v", err)
+ }
+}
+
+func cmdFingerprint(cmd *Command) {
+ c := cmd.Matrix.Crypto()
+ if c == nil {
+ cmd.Reply("Encryption support is not enabled")
+ } else {
+ cmd.Reply("Device ID: %s\nFingerprint: %s", cmd.Matrix.Client().DeviceID, c.Fingerprint())
+ }
+}
+
+func cmdHeapProfile(cmd *Command) {
+ if len(cmd.Args) == 0 || cmd.Args[0] != "nogc" {
+ runtime.GC()
+ dbg.FreeOSMemory()
+ }
+ memProfile, err := os.Create("gomuks.heap.prof")
+ if err != nil {
+ debug.Print("Failed to open gomuks.heap.prof:", err)
+ return
+ }
+ defer func() {
+ err := memProfile.Close()
+ if err != nil {
+ debug.Print("Failed to close gomuks.heap.prof:", err)
+ }
+ }()
+ if err := pprof.WriteHeapProfile(memProfile); err != nil {
+ debug.Print("Heap profile error:", err)
+ }
+}
+
+func runTimedProfile(cmd *Command, start func(writer io.Writer) error, stop func(), task, file string) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /%s ", cmd.Command)
+ } else if dur, err := strconv.Atoi(cmd.Args[0]); err != nil || dur < 0 {
+ cmd.Reply("Usage: /%s ", cmd.Command)
+ } else if cpuProfile, err := os.Create(file); err != nil {
+ debug.Printf("Failed to open %s: %v", file, err)
+ } else if err = start(cpuProfile); err != nil {
+ _ = cpuProfile.Close()
+ debug.Print(task, "error:", err)
+ } else {
+ cmd.Reply("Started %s for %d seconds", task, dur)
+ go func() {
+ time.Sleep(time.Duration(dur) * time.Second)
+ stop()
+ cmd.Reply("%s finished.", task)
+
+ err := cpuProfile.Close()
+ if err != nil {
+ debug.Print("Failed to close gomuks.cpu.prof:", err)
+ }
+ }()
+ }
+}
+
+func cmdCPUProfile(cmd *Command) {
+ runTimedProfile(cmd, pprof.StartCPUProfile, pprof.StopCPUProfile, "CPU profiling", "gomuks.cpu.prof")
+}
+
+func cmdTrace(cmd *Command) {
+ runTimedProfile(cmd, trace.Start, trace.Stop, "Call tracing", "gomuks.trace")
+}
+
+func cmdQuit(cmd *Command) {
+ cmd.Gomuks.Stop(true)
+}
+
+func cmdClearCache(cmd *Command) {
+ cmd.Config.Clear()
+ cmd.Gomuks.Stop(false)
+}
+
+func cmdUnknownCommand(cmd *Command) {
+ cmd.Reply(`Unknown command "/%s". Try "/help" for help.`, cmd.Command)
+}
+
+func cmdHelp(cmd *Command) {
+ view := cmd.MainView
+ view.ShowModal(NewHelpModal(view))
+}
+
+func cmdLeave(cmd *Command) {
+ err := cmd.Matrix.LeaveRoom(cmd.Room.MxRoom().ID)
+ debug.Print("Leave room error:", err)
+ if err == nil {
+ cmd.MainView.RemoveRoom(cmd.Room.MxRoom())
+ }
+}
+
+func cmdInvite(cmd *Command) {
+ if len(cmd.Args) != 1 {
+ cmd.Reply("Usage: /invite ")
+ return
+ }
+ _, err := cmd.Matrix.Client().InviteUser(cmd.Room.MxRoom().ID, &mautrix.ReqInviteUser{UserID: id.UserID(cmd.Args[0])})
+ if err != nil {
+ debug.Print("Error in invite call:", err)
+ cmd.Reply("Failed to invite user: %v", err)
+ }
+}
+
+func cmdBan(cmd *Command) {
+ if len(cmd.Args) < 1 {
+ cmd.Reply("Usage: /ban [reason]")
+ return
+ }
+ reason := "you are the weakest link, goodbye!"
+ if len(cmd.Args) >= 2 {
+ reason = strings.Join(cmd.Args[1:], " ")
+ }
+ _, err := cmd.Matrix.Client().BanUser(cmd.Room.MxRoom().ID, &mautrix.ReqBanUser{Reason: reason, UserID: id.UserID(cmd.Args[0])})
+ if err != nil {
+ debug.Print("Error in ban call:", err)
+ cmd.Reply("Failed to ban user: %v", err)
+ }
+
+}
+
+func cmdUnban(cmd *Command) {
+ if len(cmd.Args) != 1 {
+ cmd.Reply("Usage: /unban ")
+ return
+ }
+ _, err := cmd.Matrix.Client().UnbanUser(cmd.Room.MxRoom().ID, &mautrix.ReqUnbanUser{UserID: id.UserID(cmd.Args[0])})
+ if err != nil {
+ debug.Print("Error in unban call:", err)
+ cmd.Reply("Failed to unban user: %v", err)
+ }
+}
+
+func cmdKick(cmd *Command) {
+ if len(cmd.Args) < 1 {
+ cmd.Reply("Usage: /kick [reason]")
+ return
+ }
+ reason := "you are the weakest link, goodbye!"
+ if len(cmd.Args) >= 2 {
+ reason = strings.Join(cmd.Args[1:], " ")
+ }
+ _, err := cmd.Matrix.Client().KickUser(cmd.Room.MxRoom().ID, &mautrix.ReqKickUser{Reason: reason, UserID: id.UserID(cmd.Args[0])})
+ if err != nil {
+ debug.Print("Error in kick call:", err)
+ debug.Print("Failed to kick user:", err)
+ }
+}
+
+func formatPowerLevels(pl *event.PowerLevelsEventContent) string {
+ var buf strings.Builder
+ buf.WriteString("Membership actions:\n")
+ _, _ = fmt.Fprintf(&buf, " Invite: %d\n", pl.Invite())
+ _, _ = fmt.Fprintf(&buf, " Kick: %d\n", pl.Kick())
+ _, _ = fmt.Fprintf(&buf, " Ban: %d\n", pl.Ban())
+ buf.WriteString("Events:\n")
+ _, _ = fmt.Fprintf(&buf, " Redact: %d\n", pl.Redact())
+ _, _ = fmt.Fprintf(&buf, " State default: %d\n", pl.StateDefault())
+ _, _ = fmt.Fprintf(&buf, " Event default: %d\n", pl.EventsDefault)
+ for evtType, level := range pl.Events {
+ _, _ = fmt.Fprintf(&buf, " %s: %d\n", evtType, level)
+ }
+ buf.WriteString("Users:\n")
+ _, _ = fmt.Fprintf(&buf, " Default: %d\n", pl.UsersDefault)
+ for userID, level := range pl.Users {
+ _, _ = fmt.Fprintf(&buf, " %s: %d\n", userID, level)
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+func copyPtr(ptr *int) *int {
+ if ptr == nil {
+ return nil
+ }
+ val := *ptr
+ return &val
+}
+
+func copyMap[Key comparable](m map[Key]int) map[Key]int {
+ if m == nil {
+ return nil
+ }
+ copied := make(map[Key]int, len(m))
+ for k, v := range m {
+ copied[k] = v
+ }
+ return copied
+}
+
+func copyPowerLevels(pl *event.PowerLevelsEventContent) *event.PowerLevelsEventContent {
+ return &event.PowerLevelsEventContent{
+ Users: copyMap(pl.Users),
+ Events: copyMap(pl.Events),
+ InvitePtr: copyPtr(pl.InvitePtr),
+ KickPtr: copyPtr(pl.KickPtr),
+ BanPtr: copyPtr(pl.BanPtr),
+ RedactPtr: copyPtr(pl.RedactPtr),
+ StateDefaultPtr: copyPtr(pl.StateDefaultPtr),
+ EventsDefault: pl.EventsDefault,
+ UsersDefault: pl.UsersDefault,
+ }
+}
+
+var things = `
+[thing] can be one of the following
+
+Literals:
+* invite, kick, ban, redact - special moderation action levels
+* state_default, events_default - default level for state and non-state events
+* users_default - default level for users
+
+Patterns:
+* user ID - specific user level
+* event type - specific event type level
+
+The default levels are 0 for users, 50 for moderators and 100 for admins.`
+
+func cmdPowerLevel(cmd *Command) {
+ evt := cmd.Room.MxRoom().GetStateEvent(event.StatePowerLevels, "")
+ pl := copyPowerLevels(evt.Content.AsPowerLevels())
+ if len(cmd.Args) == 0 {
+ // TODO open in modal?
+ cmd.Reply(formatPowerLevels(pl))
+ return
+ } else if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /%s [thing] [level]\n%s", cmd.Command, things)
+ return
+ }
+
+ value, err := strconv.Atoi(cmd.Args[1])
+ if err != nil {
+ cmd.Reply("Invalid power level %q: %v", cmd.Args[1], err)
+ return
+ }
+
+ ownLevel := pl.GetUserLevel(cmd.Matrix.Client().UserID)
+ plChangeLevel := pl.GetEventLevel(event.StatePowerLevels)
+ if ownLevel < plChangeLevel {
+ cmd.Reply("Can't modify power levels (own level is %d, modifying requires %d)", ownLevel, plChangeLevel)
+ return
+ } else if value > ownLevel {
+ cmd.Reply("Can't set level to be higher than own level (%d > %d)", value, ownLevel)
+ return
+ }
+
+ var oldValue int
+ var thing string
+ switch cmd.Args[0] {
+ case "invite":
+ oldValue = pl.Invite()
+ pl.InvitePtr = &value
+ thing = "invite level"
+ case "kick":
+ oldValue = pl.Kick()
+ pl.KickPtr = &value
+ thing = "kick level"
+ case "ban":
+ oldValue = pl.Ban()
+ pl.BanPtr = &value
+ thing = "ban level"
+ case "redact":
+ oldValue = pl.Redact()
+ pl.RedactPtr = &value
+ thing = "level for redacting other users' events"
+ case "state_default":
+ oldValue = pl.StateDefault()
+ pl.StateDefaultPtr = &value
+ thing = "default level for state events"
+ case "events_default":
+ oldValue = pl.EventsDefault
+ pl.EventsDefault = value
+ thing = "default level for normal events"
+ case "users_default":
+ oldValue = pl.UsersDefault
+ pl.UsersDefault = value
+ thing = "default level for users"
+ default:
+ userID := id.UserID(cmd.Args[0])
+ if _, _, err = userID.Parse(); err == nil {
+ if pl.Users == nil {
+ pl.Users = make(map[id.UserID]int)
+ }
+ oldValue = pl.Users[userID]
+ if oldValue == ownLevel && userID != cmd.Matrix.Client().UserID {
+ cmd.Reply("Can't change level of another user which is equal to own level (%d)", ownLevel)
+ return
+ }
+ pl.Users[userID] = value
+ thing = fmt.Sprintf("level of user %s", userID)
+ } else {
+ if pl.Events == nil {
+ pl.Events = make(map[string]int)
+ }
+ oldValue = pl.Events[cmd.Args[0]]
+ pl.Events[cmd.Args[0]] = value
+ thing = fmt.Sprintf("level for event %s", cmd.Args[0])
+ }
+ }
+
+ if oldValue == value {
+ cmd.Reply("%s is already %d", strings.ToUpper(thing[0:1])+thing[1:], value)
+ } else if oldValue > ownLevel {
+ cmd.Reply("Can't change level which is higher than own level (%d > %d)", oldValue, ownLevel)
+ } else if resp, err := cmd.Matrix.Client().SendStateEvent(cmd.Room.MxRoom().ID, event.StatePowerLevels, "", pl); err != nil {
+ if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil {
+ err = httpErr.RespError
+ }
+ cmd.Reply("Failed to set %s to %d: %v", thing, value, err)
+ } else {
+ cmd.Reply("Successfully set %s to %d\n(event ID: %s)", thing, value, resp.EventID)
+ }
+}
+
+func cmdCreateRoom(cmd *Command) {
+ req := &mautrix.ReqCreateRoom{}
+ if len(cmd.Args) > 0 {
+ req.Name = strings.Join(cmd.Args, " ")
+ }
+ room, err := cmd.Matrix.CreateRoom(req)
+ if err != nil {
+ cmd.Reply("Failed to create room: %v", err)
+ return
+ }
+ cmd.MainView.SwitchRoom("", room)
+}
+
+func cmdPrivateMessage(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /pm [more user ids...]")
+ }
+ invites := make([]id.UserID, len(cmd.Args))
+ for i, userID := range cmd.Args {
+ invites[i] = id.UserID(userID)
+ _, _, err := invites[i].Parse()
+ if err != nil {
+ cmd.Reply("%s isn't a valid user ID", userID)
+ return
+ }
+ }
+ req := &mautrix.ReqCreateRoom{
+ Preset: "trusted_private_chat",
+ Invite: invites,
+ IsDirect: true,
+ }
+ room, err := cmd.Matrix.CreateRoom(req)
+ if err != nil {
+ cmd.Reply("Failed to create room: %v", err)
+ return
+ }
+ cmd.MainView.SwitchRoom("", room)
+}
+
+func cmdJoin(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /join ")
+ return
+ }
+ identifer := id.RoomID(cmd.Args[0])
+ server := ""
+ if len(cmd.Args) > 1 {
+ server = cmd.Args[1]
+ }
+ room, err := cmd.Matrix.JoinRoom(identifer, server)
+ debug.Print("Join room error:", err)
+ if err == nil {
+ cmd.MainView.AddRoom(room)
+ }
+}
+
+func cmdMSendEvent(cmd *Command) {
+ if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /msend ")
+ return
+ }
+ cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...)
+ cmdSendEvent(cmd)
+}
+
+func cmdSendEvent(cmd *Command) {
+ if len(cmd.Args) < 3 {
+ cmd.Reply("Usage: /send ")
+ return
+ }
+ roomID := id.RoomID(cmd.Args[0])
+ eventType := event.NewEventType(cmd.Args[1])
+ rawContent := strings.Join(cmd.Args[2:], " ")
+
+ var content interface{}
+ err := json.Unmarshal([]byte(rawContent), &content)
+ debug.Print(err)
+ if err != nil {
+ cmd.Reply("Failed to parse content: %v", err)
+ return
+ }
+ debug.Print("Sending event to", roomID, eventType, content)
+
+ resp, err := cmd.Matrix.Client().SendMessageEvent(roomID, eventType, content)
+ debug.Print(resp, err)
+ if err != nil {
+ cmd.Reply("Error from server: %v", err)
+ } else {
+ cmd.Reply("Event sent, ID: %s", resp.EventID)
+ }
+}
+
+func cmdMSetState(cmd *Command) {
+ if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /msetstate ")
+ return
+ }
+ cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...)
+ cmdSetState(cmd)
+}
+
+func cmdSetState(cmd *Command) {
+ if len(cmd.Args) < 4 {
+ cmd.Reply("Usage: /setstate ")
+ return
+ }
+
+ roomID := id.RoomID(cmd.Args[0])
+ eventType := event.NewEventType(cmd.Args[1])
+ stateKey := cmd.Args[2]
+ if stateKey == "-" {
+ stateKey = ""
+ }
+ rawContent := strings.Join(cmd.Args[3:], " ")
+
+ var content interface{}
+ err := json.Unmarshal([]byte(rawContent), &content)
+ if err != nil {
+ cmd.Reply("Failed to parse content: %v", err)
+ return
+ }
+ debug.Print("Sending state event to", roomID, eventType, stateKey, content)
+ resp, err := cmd.Matrix.Client().SendStateEvent(roomID, eventType, stateKey, content)
+ if err != nil {
+ cmd.Reply("Error from server: %v", err)
+ } else {
+ cmd.Reply("State event sent, ID: %s", resp.EventID)
+ }
+}
+
+type ToggleMessage interface {
+ Name() string
+ Format(state bool) string
+}
+
+type HideMessage string
+
+func (hm HideMessage) Format(state bool) string {
+ if state {
+ return string(hm) + " is now hidden"
+ } else {
+ return string(hm) + " is now visible"
+ }
+}
+
+func (hm HideMessage) Name() string {
+ return string(hm)
+}
+
+type SimpleToggleMessage string
+
+func (stm SimpleToggleMessage) Format(state bool) string {
+ if state {
+ return "Disabled " + string(stm)
+ } else {
+ return "Enabled " + string(stm)
+ }
+}
+
+func (stm SimpleToggleMessage) Name() string {
+ return string(unicode.ToUpper(rune(stm[0]))) + string(stm[1:])
+}
+
+type InvertedToggleMessage string
+
+func (itm InvertedToggleMessage) Format(state bool) string {
+ if state {
+ return "Enabled " + string(itm)
+ } else {
+ return "Disabled " + string(itm)
+ }
+}
+
+func (itm InvertedToggleMessage) Name() string {
+ return string(unicode.ToUpper(rune(itm[0]))) + string(itm[1:])
+}
+
+var toggleMsg = map[string]ToggleMessage{
+ "rooms": HideMessage("Room list sidebar"),
+ "users": HideMessage("User list sidebar"),
+ "timestamps": HideMessage("message timestamps"),
+ "baremessages": InvertedToggleMessage("bare message view"),
+ "images": SimpleToggleMessage("image rendering"),
+ "typingnotif": SimpleToggleMessage("typing notifications"),
+ "emojis": SimpleToggleMessage("emoji shortcode conversion"),
+ "html": SimpleToggleMessage("HTML input"),
+ "markdown": SimpleToggleMessage("markdown input"),
+ "downloads": SimpleToggleMessage("automatic downloads"),
+ "notifications": SimpleToggleMessage("desktop notifications"),
+ "unverified": SimpleToggleMessage("sending messages to unverified devices"),
+ "showurls": SimpleToggleMessage("show URLs in text format"),
+ "inlineurls": InvertedToggleMessage("use fancy terminal features to render URLs inside text"),
+}
+
+func makeUsage() string {
+ var buf strings.Builder
+ buf.WriteString("Usage: /toggle \n\n")
+ buf.WriteString("List of Things:\n")
+ for key, value := range toggleMsg {
+ _, _ = fmt.Fprintf(&buf, "* %s - %s\n", key, value.Name())
+ }
+ return buf.String()[:buf.Len()-1]
+}
+
+func cmdToggle(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply(makeUsage())
+ return
+ }
+ for _, thing := range cmd.Args {
+ var val *bool
+ switch thing {
+ case "rooms":
+ val = &cmd.Config.Preferences.HideRoomList
+ case "users":
+ val = &cmd.Config.Preferences.HideUserList
+ case "timestamps":
+ val = &cmd.Config.Preferences.HideTimestamp
+ case "baremessages":
+ val = &cmd.Config.Preferences.BareMessageView
+ case "images":
+ val = &cmd.Config.Preferences.DisableImages
+ case "typingnotif":
+ val = &cmd.Config.Preferences.DisableTypingNotifs
+ case "emojis":
+ val = &cmd.Config.Preferences.DisableEmojis
+ case "html":
+ val = &cmd.Config.Preferences.DisableHTML
+ case "markdown":
+ val = &cmd.Config.Preferences.DisableMarkdown
+ case "downloads":
+ val = &cmd.Config.Preferences.DisableDownloads
+ case "notifications":
+ val = &cmd.Config.Preferences.DisableNotifications
+ case "unverified":
+ val = &cmd.Config.SendToVerifiedOnly
+ case "showurls":
+ val = &cmd.Config.Preferences.DisableShowURLs
+ case "inlineurls":
+ switch cmd.Config.Preferences.InlineURLMode {
+ case "enable":
+ cmd.Config.Preferences.InlineURLMode = "disable"
+ cmd.Reply("Force-disabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.")
+ default:
+ cmd.Config.Preferences.InlineURLMode = "enable"
+ cmd.Reply("Force-enabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.")
+ }
+ continue
+ default:
+ cmd.Reply("Unknown toggle %s. Use /toggle without arguments for a list of togglable things.", thing)
+ return
+ }
+ *val = !(*val)
+ debug.Print(thing, *val)
+ cmd.Reply(toggleMsg[thing].Format(*val))
+ if thing == "rooms" {
+ // Update topic string to include or not include room name
+ cmd.Room.Update()
+ }
+ }
+ cmd.TUI.App.Redraw()
+ go cmd.Matrix.SendPreferencesToMatrix()
+}
+
+func cmdLogout(cmd *Command) {
+ cmd.Matrix.Logout()
+}
diff --git a/tui/crypto-commands.go b/tui/crypto-commands.go
new file mode 100644
index 0000000..05b1d07
--- /dev/null
+++ b/tui/crypto-commands.go
@@ -0,0 +1,699 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build cgo
+
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicode"
+
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/crypto"
+ "maunium.net/go/mautrix/crypto/ssss"
+ "maunium.net/go/mautrix/id"
+)
+
+func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) {
+ userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0])
+ if len(userCompletions) == 1 {
+ newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id)
+ } else {
+ completions = make([]string, len(userCompletions))
+ for i, completion := range userCompletions {
+ completions[i] = completion.id
+ }
+ }
+ return
+}
+
+func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) {
+ //????
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0]))
+ if len(devices) == 0 || err != nil {
+ return
+ }
+ var completedDeviceID id.DeviceID
+ if len(cmd.Args) > 1 {
+ existingID := strings.ToUpper(cmd.Args[1])
+ for _, device := range devices {
+ deviceIDStr := string(device.DeviceID)
+ if deviceIDStr == existingID {
+ // We don't want to do any autocompletion if there's already a full device ID there.
+ return []string{}, ""
+ } else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) {
+ completedDeviceID = device.DeviceID
+ completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name))
+ }
+ }
+ } else {
+ completions = make([]string, len(devices))
+ i := 0
+ for _, device := range devices {
+ completedDeviceID = device.DeviceID
+ completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)
+ i++
+ }
+ }
+ if len(completions) == 1 {
+ newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID)
+ }
+ return
+}
+
+func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) {
+ if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
+ return autocompleteDeviceUserID(cmd)
+ }
+ return []string{}, ""
+}
+
+func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
+ if len(cmd.Args) == 0 {
+ return []string{}, ""
+ } else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) {
+ return autocompleteDeviceUserID(cmd)
+ }
+ return autocompleteDeviceDeviceID(cmd)
+}
+
+func getDevice(cmd *Command) *crypto.DeviceIdentity {
+ if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /%s [fingerprint]", cmd.Command)
+ return nil
+ }
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1]))
+ if err != nil {
+ cmd.Reply("Failed to get device: %v", err)
+ return nil
+ }
+ return device
+}
+
+func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) {
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ err := mach.CryptoStore.PutDevice(device.UserID, device)
+ if err != nil {
+ cmd.Reply("Failed to save device: %v", err)
+ } else {
+ cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name)
+ }
+ mach.OnDevicesChanged(device.UserID)
+}
+
+func cmdDevices(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply("Usage: /devices ")
+ return
+ }
+ userID := id.UserID(cmd.Args[0])
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ devices, err := mach.CryptoStore.GetDevices(userID)
+ if err != nil {
+ cmd.Reply("Failed to get device list: %v", err)
+ }
+ if len(devices) == 0 {
+ cmd.Reply("Fetching device list from server...")
+ devices = mach.LoadDevices(userID)
+ }
+ if len(devices) == 0 {
+ cmd.Reply("No devices found for %s", userID)
+ return
+ }
+ var buf strings.Builder
+ for _, device := range devices {
+ trust := device.Trust.String()
+ if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
+ trust = "verified (transitive)"
+ }
+ _, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint())
+ }
+ resp := buf.String()
+ cmd.Reply("%s", resp[:len(resp)-1])
+}
+
+func cmdDevice(cmd *Command) {
+ device := getDevice(cmd)
+ if device == nil {
+ return
+ }
+ deviceType := "Device"
+ if device.Deleted {
+ deviceType = "Deleted device"
+ }
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ trustState := device.Trust.String()
+ if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
+ trustState = "verified (transitive)"
+ }
+ cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s",
+ deviceType, device.DeviceID, device.UserID,
+ device.Fingerprint(), device.IdentityKey,
+ device.Name, trustState)
+}
+
+func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) {
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ err := mach.SignOwnDevice(device)
+ if err != nil {
+ cmd.Reply("Failed to upload cross-signing signature: %v", err)
+ } else {
+ cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name)
+ }
+}
+
+func cmdVerifyDevice(cmd *Command) {
+ device := getDevice(cmd)
+ if device == nil {
+ return
+ }
+ if device.Trust == crypto.TrustStateVerified {
+ cmd.Reply("That device is already verified")
+ return
+ }
+ if len(cmd.Args) == 2 {
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ mach.DefaultSASTimeout = 120 * time.Second
+ modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout)
+ cmd.MainView.ShowModal(modal)
+ _, err := mach.NewSimpleSASVerificationWith(device, modal)
+ if err != nil {
+ cmd.Reply("Failed to start interactive verification: %v", err)
+ return
+ }
+ } else {
+ fingerprint := strings.Join(cmd.Args[2:], "")
+ if string(device.SigningKey) != fingerprint {
+ cmd.Reply("Mismatching fingerprint")
+ return
+ }
+ action := "verified"
+ if device.Trust == crypto.TrustStateBlacklisted {
+ action = "unblacklisted and verified"
+ }
+ if device.UserID == cmd.Matrix.Client().UserID {
+ crossSignDevice(cmd, device)
+ device.Trust = crypto.TrustStateVerified
+ putDevice(cmd, device, action)
+ } else {
+ putDevice(cmd, device, action)
+ cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing")
+ }
+ }
+}
+
+func cmdVerify(cmd *Command) {
+ if len(cmd.Args) < 1 {
+ cmd.Reply("Usage: /%s [--force]", cmd.OrigCommand)
+ return
+ }
+ force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force"
+ userID := id.UserID(cmd.Args[0])
+ room := cmd.Room.Room
+ if !room.Encrypted {
+ cmd.Reply("In-room verification is only supported in encrypted rooms")
+ return
+ }
+ if (!room.IsDirect || room.OtherUser != userID) && !force {
+ cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+
+ "or use `--force` to start the verification anyway.", userID)
+ return
+ }
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ if mach.CrossSigningKeys == nil && !force {
+ cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " +
+ "or use `--force` to start the verification anyway")
+ return
+ }
+ modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout)
+ _, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second)
+ if err != nil {
+ cmd.Reply("Failed to start in-room verification: %v", err)
+ return
+ }
+ cmd.MainView.ShowModal(modal)
+}
+
+func cmdUnverify(cmd *Command) {
+ device := getDevice(cmd)
+ if device == nil {
+ return
+ }
+ if device.Trust == crypto.TrustStateUnset {
+ cmd.Reply("That device is already not verified")
+ return
+ }
+ action := "unverified"
+ if device.Trust == crypto.TrustStateBlacklisted {
+ action = "unblacklisted"
+ }
+ device.Trust = crypto.TrustStateUnset
+ putDevice(cmd, device, action)
+}
+
+func cmdBlacklist(cmd *Command) {
+ device := getDevice(cmd)
+ if device == nil {
+ return
+ }
+ if device.Trust == crypto.TrustStateBlacklisted {
+ cmd.Reply("That device is already blacklisted")
+ return
+ }
+ action := "blacklisted"
+ if device.Trust == crypto.TrustStateVerified {
+ action = "unverified and blacklisted"
+ }
+ device.Trust = crypto.TrustStateBlacklisted
+ putDevice(cmd, device, action)
+}
+
+func cmdResetSession(cmd *Command) {
+ err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID)
+ if err != nil {
+ cmd.Reply("Failed to remove outbound group session: %v", err)
+ } else {
+ cmd.Reply("Removed outbound group session for this room")
+ }
+}
+
+func cmdImportKeys(cmd *Command) {
+ path, err := filepath.Abs(cmd.RawArgs)
+ if err != nil {
+ cmd.Reply("Failed to get absolute path: %v", err)
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ cmd.Reply("Failed to read %s: %v", path, err)
+ return
+ }
+ passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false)
+ if !ok {
+ cmd.Reply("Passphrase entry cancelled")
+ return
+ }
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ imported, total, err := mach.ImportKeys(passphrase, data)
+ if err != nil {
+ cmd.Reply("Failed to import sessions: %v", err)
+ } else {
+ cmd.Reply("Successfully imported %d/%d sessions", imported, total)
+ }
+}
+
+func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) {
+ path, err := filepath.Abs(cmd.RawArgs)
+ if err != nil {
+ cmd.Reply("Failed to get absolute path: %v", err)
+ return
+ }
+ passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true)
+ if !ok {
+ cmd.Reply("Passphrase entry cancelled")
+ return
+ }
+ export, err := crypto.ExportKeys(passphrase, sessions)
+ if err != nil {
+ cmd.Reply("Failed to export sessions: %v", err)
+ }
+ err = ioutil.WriteFile(path, export, 0400)
+ if err != nil {
+ cmd.Reply("Failed to write sessions to %s: %v", path, err)
+ } else {
+ cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path)
+ }
+}
+
+func cmdExportKeys(cmd *Command) {
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ sessions, err := mach.CryptoStore.GetAllGroupSessions()
+ if err != nil {
+ cmd.Reply("Failed to get sessions to export: %v", err)
+ return
+ }
+ exportKeys(cmd, sessions)
+}
+
+func cmdExportRoomKeys(cmd *Command) {
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+ sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID)
+ if err != nil {
+ cmd.Reply("Failed to get sessions to export: %v", err)
+ return
+ }
+ exportKeys(cmd, sessions)
+}
+
+const ssssHelp = `Usage: /%s [...]
+
+Subcommands:
+* status [key ID] - Check the status of your SSSS.
+* generate [--set-default] - Generate a SSSS key and optionally set it as the default.
+* set-default - Set a SSSS key as the default.`
+
+func cmdSSSS(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply(ssssHelp, cmd.OrigCommand)
+ return
+ }
+
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+
+ switch strings.ToLower(cmd.Args[0]) {
+ case "status":
+ keyID := ""
+ if len(cmd.Args) > 1 {
+ keyID = cmd.Args[1]
+ }
+ cmdS4Status(cmd, mach, keyID)
+ case "generate":
+ setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default"
+ cmdS4Generate(cmd, mach, setDefault)
+ case "set-default":
+ if len(cmd.Args) < 2 {
+ cmd.Reply("Usage: /%s set-default ", cmd.OrigCommand)
+ return
+ }
+ cmdS4SetDefault(cmd, mach, cmd.Args[1])
+ default:
+ cmd.Reply(ssssHelp, cmd.OrigCommand)
+ }
+}
+
+func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) {
+ var keyData *ssss.KeyMetadata
+ var err error
+ if len(keyID) == 0 {
+ keyID, keyData, err = mach.SSSS.GetDefaultKeyData(context.TODO)
+ } else {
+ keyData, err = mach.SSSS.GetKeyData(context.TODO, keyID)
+ }
+ if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) {
+ cmd.Reply("SSSS is not set up: no default key set")
+ return
+ } else if err != nil {
+ cmd.Reply("Failed to get key data: %v", err)
+ return
+ }
+ hasPassphrase := "no"
+ if keyData.Passphrase != nil {
+ hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations)
+ }
+ algorithm := keyData.Algorithm
+ if algorithm != ssss.AlgorithmAESHMACSHA2 {
+ algorithm += " (not supported!)"
+ }
+ cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm)
+}
+
+func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) {
+ passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true)
+ if !ok {
+ return
+ }
+
+ key, err := ssss.NewKey(passphrase)
+ if err != nil {
+ cmd.Reply("Failed to generate new key: %v", err)
+ return
+ }
+
+ err = mach.SSSS.SetKeyData(context.TODO(), key.ID, key.Metadata)
+ if err != nil {
+ cmd.Reply("Failed to upload key metadata: %v", err)
+ return
+ }
+
+ // TODO if we start persisting command replies, the recovery key needs to be moved into a popup
+ cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey())
+
+ if setDefault {
+ err = mach.SSSS.SetDefaultKeyID(context.TODO(), key.ID)
+ if err != nil {
+ cmd.Reply("Failed to set key as default: %v", err)
+ }
+ } else {
+ cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID)
+ }
+}
+
+func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) {
+ _, err := mach.SSSS.GetKeyData(context.TODO(), keyID)
+ if err != nil {
+ if errors.Is(err, mautrix.MNotFound) {
+ cmd.Reply("Couldn't find key data on server")
+ } else {
+ cmd.Reply("Failed to fetch key data: %v", err)
+ }
+ return
+ }
+
+ err = mach.SSSS.SetDefaultKeyID(context.TODO(), keyID)
+ if err != nil {
+ cmd.Reply("Failed to set key as default: %v", err)
+ } else {
+ cmd.Reply("Successfully set key %s as default", keyID)
+ }
+}
+
+const crossSigningHelp = `Usage: /%s [...]
+
+Subcommands:
+* status
+ Check the status of your own cross-signing keys.
+* generate [--force]
+ Generate and upload new cross-signing keys.
+ This will prompt you to enter your account password.
+ If you already have existing keys, --force is required.
+* self-sign
+ Sign the current device with cached cross-signing keys.
+* fetch [--save-to-disk]
+ Fetch your cross-signing keys from SSSS and decrypt them.
+ If --save-to-disk is specified, the keys are saved to disk.
+* upload
+ Upload your cross-signing keys to SSSS.`
+
+func cmdCrossSigning(cmd *Command) {
+ if len(cmd.Args) == 0 {
+ cmd.Reply(crossSigningHelp, cmd.OrigCommand)
+ return
+ }
+
+ client := cmd.Matrix.Client()
+ mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
+
+ switch strings.ToLower(cmd.Args[0]) {
+ case "status":
+ cmdCrossSigningStatus(cmd, mach)
+ case "generate":
+ force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force"
+ cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force)
+ case "fetch":
+ saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk"
+ cmdCrossSigningFetch(cmd, mach, saveToDisk)
+ case "upload":
+ cmdCrossSigningUpload(cmd, mach)
+ case "self-sign":
+ cmdCrossSigningSelfSign(cmd, mach)
+ default:
+ cmd.Reply(crossSigningHelp, cmd.OrigCommand)
+ }
+}
+
+func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) {
+ keys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
+ if keys == nil {
+ if mach.CrossSigningKeys != nil {
+ cmd.Reply("Cross-signing keys are cached, but not published")
+ } else {
+ cmd.Reply("Didn't find published cross-signing keys")
+ }
+ return
+ }
+ if mach.CrossSigningKeys != nil {
+ cmd.Reply("Cross-signing keys are published and private keys are cached")
+ } else {
+ cmd.Reply("Cross-signing keys are published, but private keys are not cached")
+ }
+ cmd.Reply("Master key: %s", keys.MasterKey)
+ cmd.Reply("User signing key: %s", keys.UserSigningKey)
+ cmd.Reply("Self-signing key: %s", keys.SelfSigningKey)
+}
+
+func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) {
+ key := getSSSS(cmd, mach)
+ if key == nil {
+ return
+ }
+
+ err := mach.FetchCrossSigningKeysFromSSSS(context.TODO(), key)
+ if err != nil {
+ cmd.Reply("Error fetching cross-signing keys: %v", err)
+ return
+ }
+ if saveToDisk {
+ cmd.Reply("Saving keys to disk is not yet implemented")
+ }
+ cmd.Reply("Successfully unlocked cross-signing keys")
+}
+
+// korjaa
+func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) {
+ if !force {
+ existingKeys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
+ if existingKeys != nil {
+ cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.")
+ return
+ }
+ }
+
+ keys, err := mach.GenerateCrossSigningKeys()
+ if err != nil {
+ cmd.Reply("Failed to generate cross-signing keys: %v", err)
+ return
+ }
+
+ err = mach.PublishCrossSigningKeys(context.TODO(), keys, func(uia *mautrix.RespUserInteractive) interface{} {
+ if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) {
+ for _, flow := range uia.Flows {
+ if len(flow.Stages) != 1 {
+ return nil
+ }
+ cmd.Reply("Opening browser for authentication")
+ err := container.UIAFallback(flow.Stages[0], uia.Session)
+ if err != nil {
+ cmd.Reply("Authentication failed: %v", err)
+ return nil
+ }
+ return &mautrix.ReqUIAuthFallback{
+ Session: uia.Session,
+ User: mach.Client.UserID.String(),
+ }
+ }
+ cmd.Reply("No supported authentication mechanisms found")
+ return nil
+ }
+ password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false)
+ if !ok {
+ return nil
+ }
+ return &mautrix.ReqUIAuthLogin{
+ BaseAuthData: mautrix.BaseAuthData{
+ Type: mautrix.AuthTypePassword,
+ Session: uia.Session,
+ },
+ User: mach.Client.UserID.String(),
+ Password: password,
+ }
+ })
+ if err != nil {
+ cmd.Reply("Failed to publish cross-signing keys: %v", err)
+ return
+ }
+ cmd.Reply("Successfully generated and published cross-signing keys")
+
+ err = mach.SignOwnMasterKey(context.TODO())
+ if err != nil {
+ cmd.Reply("Failed to sign master key with device key: %v", err)
+ }
+}
+
+func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key {
+ _, keyData, err := mach.SSSS.GetDefaultKeyData(context.TODO())
+ if err != nil {
+ if errors.Is(err, mautrix.MNotFound) {
+ cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first")
+ } else {
+ cmd.Reply("Failed to fetch default SSSS key data: %v", err)
+ }
+ return nil
+ }
+
+ var key *ssss.Key
+ if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 {
+ passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false)
+ if !ok {
+ return nil
+ }
+ key, err = keyData.VerifyPassphrase(passphrase)
+ if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
+ cmd.Reply("Incorrect passphrase")
+ return nil
+ }
+ } else {
+ recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false)
+ if !ok {
+ return nil
+ }
+ key, err = keyData.VerifyRecoveryKey(recoveryKey)
+ if errors.Is(err, ssss.ErrInvalidRecoveryKey) {
+ cmd.Reply("Malformed recovery key")
+ return nil
+ } else if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
+ cmd.Reply("Incorrect recovery key")
+ return nil
+ }
+ }
+ // All the errors should already be handled above, this is just for backup
+ if err != nil {
+ cmd.Reply("Failed to get SSSS key: %v", err)
+ return nil
+ }
+ return key
+}
+
+func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) {
+ if mach.CrossSigningKeys == nil {
+ cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand)
+ return
+ }
+
+ key := getSSSS(cmd, mach)
+ if key == nil {
+ return
+ }
+
+ err := mach.UploadCrossSigningKeysToSSSS(context.TODO(), key, mach.CrossSigningKeys)
+ if err != nil {
+ cmd.Reply("Failed to upload keys to SSSS: %v", err)
+ } else {
+ cmd.Reply("Successfully uploaded cross-signing keys to SSSS")
+ }
+}
+
+func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) {
+ if mach.CrossSigningKeys == nil {
+ cmd.Reply("Cross-signing keys not cached")
+ return
+ }
+
+ err := mach.SignOwnDevice(context.TODO(), mach.OwnIdentity())
+ if err != nil {
+ cmd.Reply("Failed to self-sign: %v", err)
+ } else {
+ cmd.Reply("Successfully self-signed. This device is now trusted by other devices")
+ }
+}
diff --git a/tui/doc.go b/tui/doc.go
new file mode 100644
index 0000000..147be0c
--- /dev/null
+++ b/tui/doc.go
@@ -0,0 +1,2 @@
+// Package tui contains the main gomuks TUI.
+package tui
diff --git a/tui/fuzzy-search-modal.go b/tui/fuzzy-search-modal.go
new file mode 100644
index 0000000..b5886cd
--- /dev/null
+++ b/tui/fuzzy-search-modal.go
@@ -0,0 +1,161 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+ "maunium.net/go/mautrix/id"
+
+ "maunium.net/go/gomuks/debug"
+)
+
+type FuzzySearchModal struct {
+ mauview.Component
+
+ container *mauview.Box
+
+ search *mauview.InputArea
+ results *mauview.TextView
+
+ matches fuzzy.Ranks
+ selected int
+
+ roomList []*rooms.Room
+ roomTitles []string
+
+ parent *MainView
+}
+
+func NewFuzzySearchModal(mainView *MainView, width int, height int) *FuzzySearchModal {
+ fs := &FuzzySearchModal{
+ parent: mainView,
+ }
+
+ fs.InitList(mainView.rooms)
+
+ fs.results = mauview.NewTextView().SetRegions(true)
+ fs.search = mauview.NewInputArea().
+ SetChangedFunc(fs.changeHandler).
+ SetTextColor(tcell.ColorWhite).
+ SetBackgroundColor(tcell.ColorDarkCyan)
+ fs.search.Focus()
+
+ flex := mauview.NewFlex().
+ SetDirection(mauview.FlexRow).
+ AddFixedComponent(fs.search, 1).
+ AddProportionalComponent(fs.results, 1)
+
+ fs.container = mauview.NewBox(flex).
+ SetBorder(true).
+ SetTitle("Quick Room Switcher").
+ SetBlurCaptureFunc(func() bool {
+ fs.parent.HideModal()
+ return true
+ })
+
+ fs.Component = mauview.Center(fs.container, width, height).SetAlwaysFocusChild(true)
+
+ return fs
+}
+
+func (fs *FuzzySearchModal) Focus() {
+ fs.container.Focus()
+}
+
+func (fs *FuzzySearchModal) Blur() {
+ fs.container.Blur()
+}
+
+func (fs *FuzzySearchModal) InitList(rooms map[id.RoomID]*RoomView) {
+ for _, room := range rooms {
+ if room.Room.IsReplaced() {
+ //if _, ok := rooms[room.Room.ReplacedBy()]; ok
+ continue
+ }
+ fs.roomList = append(fs.roomList, room.Room)
+ fs.roomTitles = append(fs.roomTitles, room.Room.GetTitle())
+ }
+}
+
+func (fs *FuzzySearchModal) changeHandler(str string) {
+ // Get matches and display in result box
+ fs.matches = fuzzy.RankFindFold(str, fs.roomTitles)
+ if len(str) > 0 && len(fs.matches) > 0 {
+ sort.Sort(fs.matches)
+ fs.results.Clear()
+ for _, match := range fs.matches {
+ fmt.Fprintf(fs.results, `["%d"]%s[""]%s`, match.OriginalIndex, match.Target, "\n")
+ }
+ //fs.parent.parent.Render()
+ fs.results.Highlight(strconv.Itoa(fs.matches[0].OriginalIndex))
+ fs.selected = 0
+ fs.results.ScrollToBeginning()
+ } else {
+ fs.results.Clear()
+ fs.results.Highlight()
+ }
+}
+
+func (fs *FuzzySearchModal) OnKeyEvent(event mauview.KeyEvent) bool {
+ highlights := fs.results.GetHighlights()
+ kb := config.Keybind{
+ Key: event.Key(),
+ Ch: event.Rune(),
+ Mod: event.Modifiers(),
+ }
+ switch fs.parent.config.Keybindings.Modal[kb] {
+ case "cancel":
+ // Close room finder
+ fs.parent.HideModal()
+ return true
+ case "select_next":
+ // Cycle highlighted area to next match
+ if len(highlights) > 0 {
+ fs.selected = (fs.selected + 1) % len(fs.matches)
+ fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex))
+ fs.results.ScrollToHighlight()
+ }
+ return true
+ case "select_prev":
+ if len(highlights) > 0 {
+ fs.selected = (fs.selected - 1) % len(fs.matches)
+ if fs.selected < 0 {
+ fs.selected += len(fs.matches)
+ }
+ fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex))
+ fs.results.ScrollToHighlight()
+ }
+ return true
+ case "confirm":
+ // Switch room to currently selected room
+ if len(highlights) > 0 {
+ debug.Print("Fuzzy Selected Room:", fs.roomList[fs.matches[fs.selected].OriginalIndex].GetTitle())
+ fs.parent.SwitchRoom(fs.roomList[fs.matches[fs.selected].OriginalIndex].Tags()[0].Tag, fs.roomList[fs.matches[fs.selected].OriginalIndex])
+ }
+ fs.parent.HideModal()
+ fs.results.Clear()
+ fs.search.SetText("")
+ return true
+ }
+ return fs.search.OnKeyEvent(event)
+}
diff --git a/tui/help-modal.go b/tui/help-modal.go
new file mode 100644
index 0000000..6415194
--- /dev/null
+++ b/tui/help-modal.go
@@ -0,0 +1,115 @@
+package tui
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+const helpText = `# General
+/help - Show this help dialog.
+/quit - Quit gomuks.
+/clearcache - Clear cache and quit gomuks.
+/logout - Log out of Matrix.
+/toggle - Temporary command to toggle various UI features.
+ Run /toggle without arguments to see the list of toggles.
+
+# Media
+/download [path] - Downloads file from selected message.
+/open [path] - Download file from selected message and open it with xdg-open.
+/upload - Upload the file at the given path to the current room.
+
+# Sending special messages
+/me - Send an emote message.
+/notice - Send a notice (generally used for bot messages).
+/rainbow - Send rainbow text.
+/rainbowme - Send rainbow text in an emote.
+/reply [text] - Reply to the selected message.
+/react - React to the selected message.
+/redact [reason] - Redact the selected message.
+/edit - Edit the selected message.
+
+# Encryption
+/fingerprint - View the fingerprint of your device.
+
+/devices - View the device list of a user.
+/device - Show info about a specific device.
+/unverify - Un-verify a device.
+/blacklist - Blacklist a device.
+/verify - Verify a user with in-room verification. Probably broken.
+/verify-device [fingerprint]
+ - Verify a device. If the fingerprint is not provided,
+ interactive emoji verification will be started.
+/reset-session - Reset the outbound Megolm session in the current room.
+
+/import - Import encryption keys
+/export - Export encryption keys
+/export-room - Export encryption keys for the current room.
+
+/cross-signing [...]
+ - Cross-signing commands. Somewhat experimental.
+ Run without arguments for help. (alias: /cs)
+/ssss [...]
+ - Secure Secret Storage (and Sharing) commands. Very experimental.
+ Run without arguments for help.
+
+# Rooms
+/pm <...> - Create a private chat with the given user(s).
+/create [room name] - Create a room.
+
+/join [server] - Join a room.
+/accept - Accept the invite.
+/reject - Reject the invite.
+
+/invite - Invite the given user to the room.
+/roomnick - Change your per-room displayname.
+/tag - Add the room to .
+/untag - Remove the room from .
+/tags - List the tags the room is in.
+/alias - Add or remove local addresses.
+
+/leave - Leave the current room.
+/kick [reason] - Kick a user.
+/ban [reason] - Ban a user.
+/unban - Unban a user.`
+
+type HelpModal struct {
+ mauview.FocusableComponent
+ parent *MainView
+}
+
+func NewHelpModal(parent *MainView) *HelpModal {
+ hm := &HelpModal{parent: parent}
+
+ text := mauview.NewTextView().
+ SetText(helpText).
+ SetScrollable(true).
+ SetWrap(false).
+ SetTextColor(tcell.ColorDefault)
+
+ box := mauview.NewBox(text).
+ SetBorder(true).
+ SetTitle("Help").
+ SetBlurCaptureFunc(func() bool {
+ hm.parent.HideModal()
+ return true
+ })
+ box.Focus()
+
+ hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5)
+
+ return hm
+}
+
+func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool {
+ kb := config.Keybind{
+ Key: event.Key(),
+ Ch: event.Rune(),
+ Mod: event.Modifiers(),
+ }
+ // TODO unhardcode q
+ if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' {
+ hm.parent.HideModal()
+ return true
+ }
+ return hm.FocusableComponent.OnKeyEvent(event)
+}
diff --git a/tui/member-list.go b/tui/member-list.go
index 15d0e37..657694e 100644
--- a/tui/member-list.go
+++ b/tui/member-list.go
@@ -23,14 +23,11 @@ import (
"github.com/mattn/go-runewidth"
+ "github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
- "go.mau.fi/tcell"
-
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
- "maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/widget"
)
type MemberList struct {
diff --git a/tui/message-view.go b/tui/message-view.go
index 189774e..2a80bea 100644
--- a/tui/message-view.go
+++ b/tui/message-view.go
@@ -23,20 +23,14 @@ import (
"sync/atomic"
"github.com/mattn/go-runewidth"
- sync "github.com/sasha-s/go-deadlock"
"go.mau.fi/mauview"
- "go.mau.fi/tcell"
+ "github.com/gdamore/tcell/v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
- "maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
- ifc "maunium.net/go/gomuks/interface"
- "maunium.net/go/gomuks/lib/open"
- "maunium.net/go/gomuks/ui/messages"
- "maunium.net/go/gomuks/ui/widget"
)
type MessageView struct {
@@ -652,7 +646,6 @@ func (view *MessageView) Draw(screen mauview.Screen) {
if msg == prevMsg {
debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index)
line++
- continue
}
if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp {
diff --git a/tui/messages/base.go b/tui/messages/base.go
new file mode 100644
index 0000000..211a983
--- /dev/null
+++ b/tui/messages/base.go
@@ -0,0 +1,397 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+ /*
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/matrix/muksevt"
+
+ "maunium.net/go/gomuks/ui/widget"
+ */
+)
+
+type MessageRenderer interface {
+ Draw(screen mauview.Screen, msg *UIMessage)
+ NotificationContent() string
+ PlainText() string
+ CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage)
+ Height() int
+ Clone() MessageRenderer
+ String() string
+}
+
+type ReactionItem struct {
+ Key string
+ Count int
+}
+
+func (ri ReactionItem) String() string {
+ return fmt.Sprintf("%d×%s", ri.Count, ri.Key)
+}
+
+type ReactionSlice []ReactionItem
+
+func (rs ReactionSlice) Len() int {
+ return len(rs)
+}
+
+func (rs ReactionSlice) Less(i, j int) bool {
+ return rs[i].Key < rs[j].Key
+}
+
+func (rs ReactionSlice) Swap(i, j int) {
+ rs[i], rs[j] = rs[j], rs[i]
+}
+
+type UIMessage struct {
+ EventID id.EventID
+ TxnID string
+ Relation event.RelatesTo
+ Type event.MessageType
+ SenderID id.UserID
+ SenderName string
+ DefaultSenderColor tcell.Color
+ Timestamp time.Time
+ State muksevt.OutgoingState
+ IsHighlight bool
+ IsService bool
+ IsSelected bool
+ Edited bool
+ Event *muksevt.Event
+ ReplyTo *UIMessage
+ Reactions ReactionSlice
+ Renderer MessageRenderer
+}
+
+func (msg *UIMessage) GetEvent() *muksevt.Event {
+ if msg == nil {
+ return nil
+ }
+ return msg.Event
+}
+
+const DateFormat = "January _2, 2006"
+const TimeFormat = "15:04:05"
+
+func newUIMessage(evt *muksevt.Event, displayname string, renderer MessageRenderer) *UIMessage {
+ msgContent := evt.Content.AsMessage()
+ msgtype := msgContent.MsgType
+ if len(msgtype) == 0 {
+ msgtype = event.MessageType(evt.Type.String())
+ }
+
+ reactions := make(ReactionSlice, 0, len(evt.Unsigned.Relations.Annotations.Map))
+ for key, count := range evt.Unsigned.Relations.Annotations.Map {
+ reactions = append(reactions, ReactionItem{
+ Key: key,
+ Count: count,
+ })
+ }
+ sort.Sort(reactions)
+
+ return &UIMessage{
+ SenderID: evt.Sender,
+ SenderName: displayname,
+ Timestamp: unixToTime(evt.Timestamp),
+ DefaultSenderColor: widget.GetHashColor(evt.Sender),
+ Type: msgtype,
+ EventID: evt.ID,
+ TxnID: evt.Unsigned.TransactionID,
+ Relation: *msgContent.GetRelatesTo(),
+ State: evt.Gomuks.OutgoingState,
+ IsHighlight: false,
+ IsService: false,
+ Edited: len(evt.Gomuks.Edits) > 0,
+ Reactions: reactions,
+ Event: evt,
+ Renderer: renderer,
+ }
+}
+
+func (msg *UIMessage) AddReaction(key string) {
+ found := false
+ for i, rs := range msg.Reactions {
+ if rs.Key == key {
+ rs.Count++
+ msg.Reactions[i] = rs
+ found = true
+ break
+ }
+ }
+ if !found {
+ msg.Reactions = append(msg.Reactions, ReactionItem{
+ Key: key,
+ Count: 1,
+ })
+ }
+ sort.Sort(msg.Reactions)
+}
+
+func unixToTime(unix int64) time.Time {
+ timestamp := time.Now()
+ if unix != 0 {
+ timestamp = time.Unix(unix/1000, unix%1000*1000)
+ }
+ return timestamp
+}
+
+// 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 *UIMessage) Sender() string {
+ switch msg.State {
+ case muksevt.StateLocalEcho:
+ return "Sending..."
+ case muksevt.StateSendFail:
+ return "Error"
+ }
+ switch msg.Type {
+ case "m.emote":
+ // Emotes don't show a separate sender, it's included in the buffer.
+ return ""
+ default:
+ return msg.SenderName
+ }
+}
+
+func (msg *UIMessage) NotificationSenderName() string {
+ return msg.SenderName
+}
+
+func (msg *UIMessage) NotificationContent() string {
+ return msg.Renderer.NotificationContent()
+}
+
+func (msg *UIMessage) getStateSpecificColor() tcell.Color {
+ switch msg.State {
+ case muksevt.StateLocalEcho:
+ return tcell.ColorGray
+ case muksevt.StateSendFail:
+ return tcell.ColorRed
+ case muksevt.StateDefault:
+ 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 *UIMessage) SenderColor() tcell.Color {
+ stateColor := msg.getStateSpecificColor()
+ switch {
+ case stateColor != tcell.ColorDefault:
+ return stateColor
+ case msg.Type == "m.room.member":
+ return widget.GetHashColor(msg.SenderName)
+ case msg.IsService:
+ return tcell.ColorGray
+ default:
+ return msg.DefaultSenderColor
+ }
+}
+
+// TextColor returns the color the actual content of the message should be shown in.
+func (msg *UIMessage) TextColor() tcell.Color {
+ stateColor := msg.getStateSpecificColor()
+ switch {
+ case stateColor != tcell.ColorDefault:
+ return stateColor
+ case msg.IsService, msg.Type == "m.notice":
+ return tcell.ColorGray
+ case msg.IsHighlight:
+ return tcell.ColorYellow
+ case msg.Type == "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 *UIMessage) TimestampColor() tcell.Color {
+ if msg.IsService {
+ return tcell.ColorGray
+ }
+ return msg.getStateSpecificColor()
+}
+
+func (msg *UIMessage) ReplyHeight() int {
+ if msg.ReplyTo != nil {
+ return 1 + msg.ReplyTo.Height()
+ }
+ return 0
+}
+
+func (msg *UIMessage) ReactionHeight() int {
+ if len(msg.Reactions) > 0 {
+ return 1
+ }
+ return 0
+}
+
+// Height returns the number of rows in the computed buffer (see Buffer()).
+func (msg *UIMessage) Height() int {
+ return msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight()
+}
+
+func (msg *UIMessage) Time() time.Time {
+ return msg.Timestamp
+}
+
+// FormatTime returns the formatted time when the message was sent.
+func (msg *UIMessage) FormatTime() string {
+ return msg.Timestamp.Format(TimeFormat)
+}
+
+// FormatDate returns the formatted date when the message was sent.
+func (msg *UIMessage) FormatDate() string {
+ return msg.Timestamp.Format(DateFormat)
+}
+
+func (msg *UIMessage) SameDate(message *UIMessage) bool {
+ year1, month1, day1 := msg.Timestamp.Date()
+ year2, month2, day2 := message.Timestamp.Date()
+ return day1 == day2 && month1 == month2 && year1 == year2
+}
+
+func (msg *UIMessage) ID() id.EventID {
+ if len(msg.EventID) == 0 {
+ return id.EventID(msg.TxnID)
+ }
+ return msg.EventID
+}
+
+func (msg *UIMessage) SetID(id id.EventID) {
+ msg.EventID = id
+}
+
+func (msg *UIMessage) SetIsHighlight(isHighlight bool) {
+ msg.IsHighlight = isHighlight
+}
+
+func (msg *UIMessage) DrawReactions(screen mauview.Screen) {
+ if len(msg.Reactions) == 0 {
+ return
+ }
+ width, height := screen.Size()
+ screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1)
+
+ x := 0
+ for _, reaction := range msg.Reactions {
+ _, drawn := mauview.PrintWithStyle(screen, reaction.String(), x, 0, width-x, mauview.AlignLeft, tcell.StyleDefault.Foreground(mauview.Styles.PrimaryTextColor).Background(tcell.ColorDarkGreen))
+ x += drawn + 1
+ if x >= width {
+ break
+ }
+ }
+}
+
+func (msg *UIMessage) Draw(screen mauview.Screen) {
+ proxyScreen := msg.DrawReply(screen)
+ msg.Renderer.Draw(proxyScreen, msg)
+ msg.DrawReactions(proxyScreen)
+ if msg.IsSelected {
+ w, h := screen.Size()
+ for x := 0; x < w; x++ {
+ for y := 0; y < h; y++ {
+ mainc, combc, style, _ := screen.GetContent(x, y)
+ _, bg, _ := style.Decompose()
+ if bg == tcell.ColorDefault {
+ screen.SetContent(x, y, mainc, combc, style.Background(tcell.ColorDarkGreen))
+ }
+ }
+ }
+ }
+}
+
+func (msg *UIMessage) Clone() *UIMessage {
+ clone := *msg
+ clone.ReplyTo = nil
+ clone.Reactions = nil
+ clone.Renderer = clone.Renderer.Clone()
+ return &clone
+}
+
+func (msg *UIMessage) CalculateReplyBuffer(preferences config.UserPreferences, width int) {
+ if msg.ReplyTo == nil {
+ return
+ }
+ msg.ReplyTo.CalculateBuffer(preferences, width-1)
+}
+
+func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width int) {
+ msg.Renderer.CalculateBuffer(preferences, width, msg)
+ msg.CalculateReplyBuffer(preferences, width)
+}
+
+func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen {
+ if msg.ReplyTo == nil {
+ return screen
+ }
+ width, height := screen.Size()
+ replyHeight := msg.ReplyTo.Height()
+ widget.WriteLineSimpleColor(screen, "In reply to", 1, 0, tcell.ColorGreen)
+ widget.WriteLineSimpleColor(screen, msg.ReplyTo.SenderName, 13, 0, msg.ReplyTo.SenderColor())
+ for y := 0; y < 1+replyHeight; y++ {
+ screen.SetCell(0, y, tcell.StyleDefault, '▊')
+ }
+ replyScreen := mauview.NewProxyScreen(screen, 1, 1, width-1, replyHeight)
+ msg.ReplyTo.Draw(replyScreen)
+ return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1)
+}
+
+func (msg *UIMessage) String() string {
+ return fmt.Sprintf(`&messages.UIMessage{
+ ID="%s", TxnID="%s",
+ Type="%s", Timestamp=%s,
+ Sender={ID="%s", Name="%s", Color=#%X},
+ IsService=%t, IsHighlight=%t,
+ Renderer=%s,
+}`,
+ msg.EventID, msg.TxnID,
+ msg.Type, msg.Timestamp.String(),
+ msg.SenderID, msg.SenderName, msg.DefaultSenderColor.Hex(),
+ msg.IsService, msg.IsHighlight, msg.Renderer.String())
+}
+
+func (msg *UIMessage) PlainText() string {
+ return msg.Renderer.PlainText()
+}
diff --git a/tui/messages/doc.go b/tui/messages/doc.go
new file mode 100644
index 0000000..289c308
--- /dev/null
+++ b/tui/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/tui/messages/expandedtextmessage.go b/tui/messages/expandedtextmessage.go
new file mode 100644
index 0000000..8c2a3c6
--- /dev/null
+++ b/tui/messages/expandedtextmessage.go
@@ -0,0 +1,102 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+ /*
+ "maunium.net/go/gomuks/matrix/muksevt"
+
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/ui/messages/tstring"
+ */)
+
+type ExpandedTextMessage struct {
+ Text tstring.TString
+ buffer []tstring.TString
+}
+
+// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state.
+func NewExpandedTextMessage(evt *muksevt.Event, displayname string, text tstring.TString) *UIMessage {
+ return newUIMessage(evt, displayname, &ExpandedTextMessage{
+ Text: text,
+ })
+}
+
+func NewServiceMessage(text string) *UIMessage {
+ return &UIMessage{
+ SenderID: "*",
+ SenderName: "*",
+ Timestamp: time.Now(),
+ IsService: true,
+ Renderer: &ExpandedTextMessage{
+ Text: tstring.NewTString(text),
+ },
+ }
+}
+
+func NewDateChangeMessage(text string) *UIMessage {
+ midnight := time.Now()
+ midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(),
+ 0, 0, 0, 0,
+ midnight.Location())
+ return &UIMessage{
+ SenderID: "*",
+ SenderName: "*",
+ Timestamp: midnight,
+ IsService: true,
+ Renderer: &ExpandedTextMessage{
+ Text: tstring.NewColorTString(text, tcell.ColorGreen),
+ },
+ }
+}
+
+func (msg *ExpandedTextMessage) Clone() MessageRenderer {
+ return &ExpandedTextMessage{
+ Text: msg.Text.Clone(),
+ }
+}
+
+func (msg *ExpandedTextMessage) NotificationContent() string {
+ return msg.Text.String()
+}
+
+func (msg *ExpandedTextMessage) PlainText() string {
+ return msg.Text.String()
+}
+
+func (msg *ExpandedTextMessage) String() string {
+ return fmt.Sprintf(`&messages.ExpandedTextMessage{Text="%s"}`, msg.Text.String())
+}
+
+func (msg *ExpandedTextMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
+ msg.buffer = calculateBufferWithText(prefs, msg.Text, width, uiMsg)
+}
+
+func (msg *ExpandedTextMessage) Height() int {
+ return len(msg.buffer)
+}
+
+func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) {
+ for y, line := range msg.buffer {
+ line.Draw(screen, 0, y)
+ }
+}
diff --git a/tui/messages/filemessage.go b/tui/messages/filemessage.go
new file mode 100644
index 0000000..7324071
--- /dev/null
+++ b/tui/messages/filemessage.go
@@ -0,0 +1,190 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+
+ "maunium.net/go/mautrix/crypto/attachment"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+ /*
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/debug"
+ ifc "maunium.net/go/gomuks/interface"
+ "maunium.net/go/gomuks/lib/ansimage"
+ "maunium.net/go/gomuks/matrix/muksevt"
+ "maunium.net/go/gomuks/ui/messages/tstring"
+ */)
+
+type FileMessage struct {
+ Type event.MessageType
+ Body string
+
+ URL id.ContentURI
+ File *attachment.EncryptedFile
+ Thumbnail id.ContentURI
+ ThumbnailFile *attachment.EncryptedFile
+
+ eventID id.EventID
+
+ imageData []byte
+ buffer []tstring.TString
+
+ matrix ifc.MatrixContainer
+}
+
+// NewFileMessage creates a new FileMessage object with the provided values and the default state.
+func NewFileMessage(matrix ifc.MatrixContainer, evt *muksevt.Event, displayname string) *UIMessage {
+ content := evt.Content.AsMessage()
+ var file, thumbnailFile *attachment.EncryptedFile
+ if content.File != nil {
+ file = &content.File.EncryptedFile
+ content.URL = content.File.URL
+ }
+ if content.GetInfo().ThumbnailFile != nil {
+ thumbnailFile = &content.Info.ThumbnailFile.EncryptedFile
+ content.Info.ThumbnailURL = content.Info.ThumbnailFile.URL
+ }
+ return newUIMessage(evt, displayname, &FileMessage{
+ Type: content.MsgType,
+ Body: content.Body,
+ URL: content.URL.ParseOrIgnore(),
+ File: file,
+ Thumbnail: content.GetInfo().ThumbnailURL.ParseOrIgnore(),
+ ThumbnailFile: thumbnailFile,
+ eventID: evt.ID,
+ matrix: matrix,
+ })
+}
+
+func (msg *FileMessage) Clone() MessageRenderer {
+ data := make([]byte, len(msg.imageData))
+ copy(data, msg.imageData)
+ return &FileMessage{
+ Body: msg.Body,
+ URL: msg.URL,
+ Thumbnail: msg.Thumbnail,
+ imageData: data,
+ matrix: msg.matrix,
+ }
+}
+
+func (msg *FileMessage) NotificationContent() string {
+ switch msg.Type {
+ case event.MsgImage:
+ return "Sent an image"
+ case event.MsgAudio:
+ return "Sent an audio file"
+ case event.MsgVideo:
+ return "Sent a video"
+ case event.MsgFile:
+ fallthrough
+ default:
+ return "Sent a file"
+ }
+}
+
+func (msg *FileMessage) PlainText() string {
+ return fmt.Sprintf("%s: %s", msg.Body, msg.matrix.GetDownloadURL(msg.URL, msg.File))
+}
+
+func (msg *FileMessage) String() string {
+ return fmt.Sprintf(`&messages.FileMessage{Body="%s", URL="%s", Thumbnail="%s"}`, msg.Body, msg.URL, msg.Thumbnail)
+}
+
+func (msg *FileMessage) DownloadPreview() {
+ var url id.ContentURI
+ var file *attachment.EncryptedFile
+ if !msg.Thumbnail.IsEmpty() {
+ url = msg.Thumbnail
+ file = msg.ThumbnailFile
+ } else if msg.Type == event.MsgImage && !msg.URL.IsEmpty() {
+ msg.Thumbnail = msg.URL
+ url = msg.URL
+ file = msg.File
+ } else {
+ return
+ }
+ debug.Print("Loading file:", url)
+ data, err := msg.matrix.Download(url, file)
+ if err != nil {
+ debug.Printf("Failed to download file %s: %v", url, err)
+ return
+ }
+ debug.Print("File", url, "loaded.")
+ msg.imageData = data
+}
+
+func (msg *FileMessage) ThumbnailPath() string {
+ return msg.matrix.GetCachePath(msg.Thumbnail)
+}
+
+func (msg *FileMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
+ if width < 2 {
+ return
+ }
+
+ if prefs.BareMessageView || prefs.DisableImages || len(msg.imageData) == 0 {
+ url := msg.matrix.GetDownloadURL(msg.URL, msg.File)
+ var urlTString tstring.TString
+ if prefs.EnableInlineURLs() {
+ urlTString = tstring.NewStyleTString(url, tcell.StyleDefault.Url(url).UrlId(msg.eventID.String()))
+ } else {
+ urlTString = tstring.NewTString(url)
+ }
+ text := tstring.NewTString(msg.Body).
+ Append(": ").
+ AppendTString(urlTString)
+ msg.buffer = calculateBufferWithText(prefs, text, width, uiMsg)
+ return
+ }
+
+ img, _, err := image.DecodeConfig(bytes.NewReader(msg.imageData))
+ if err != nil {
+ debug.Print("File could not be decoded:", err)
+ }
+ imgWidth := img.Width
+ if img.Width > width {
+ imgWidth = width / 3
+ }
+
+ ansFile, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.imageData), 0, imgWidth, 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 = ansFile.Render()
+}
+
+func (msg *FileMessage) Height() int {
+ return len(msg.buffer)
+}
+
+func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) {
+ for y, line := range msg.buffer {
+ line.Draw(screen, 0, y)
+ }
+}
diff --git a/tui/messages/html/base.go b/tui/messages/html/base.go
new file mode 100644
index 0000000..203f79a
--- /dev/null
+++ b/tui/messages/html/base.go
@@ -0,0 +1,101 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+package html
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+type BaseEntity struct {
+ // The HTML tag of this entity.
+ Tag string
+ // Style for this entity.
+ Style tcell.Style
+ // Whether or not this is a block-type entity.
+ Block bool
+ // Height to use for entity if both text and children are empty.
+ DefaultHeight int
+
+ prevWidth int
+ startX int
+ height int
+}
+
+// AdjustStyle changes the style of this text entity.
+func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ be.Style = fn(be.Style)
+ return be
+}
+
+func (be *BaseEntity) IsEmpty() bool {
+ return false
+}
+
+// IsBlock returns whether or not this is a block-type entity.
+func (be *BaseEntity) IsBlock() bool {
+ return be.Block
+}
+
+// GetTag returns the HTML tag of this entity.
+func (be *BaseEntity) GetTag() string {
+ return be.Tag
+}
+
+// Height returns the render height of this entity.
+func (be *BaseEntity) Height() int {
+ return be.height
+}
+
+func (be *BaseEntity) getStartX() int {
+ return be.startX
+}
+
+// Clone creates a copy of this base entity.
+func (be *BaseEntity) Clone() Entity {
+ return &BaseEntity{
+ Tag: be.Tag,
+ Style: be.Style,
+ Block: be.Block,
+ DefaultHeight: be.DefaultHeight,
+ }
+}
+
+func (be *BaseEntity) PlainText() string {
+ return ""
+}
+
+// String returns a textual representation of this BaseEntity struct.
+func (be *BaseEntity) String() string {
+ return fmt.Sprintf(`&html.BaseEntity{Tag="%s", Style=%#v, Block=%t, startX=%d, height=%d}`,
+ be.Tag, be.Style, be.Block, be.startX, be.height)
+}
+
+// CalculateBuffer prepares this entity for rendering with the given parameters.
+func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
+ be.height = be.DefaultHeight
+ be.startX = startX
+ if be.Block {
+ be.startX = 0
+ }
+ return be.startX
+}
+
+func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ panic("Called Draw() of BaseEntity")
+}
diff --git a/tui/messages/html/blockquote.go b/tui/messages/html/blockquote.go
new file mode 100644
index 0000000..25123da
--- /dev/null
+++ b/tui/messages/html/blockquote.go
@@ -0,0 +1,88 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "strings"
+
+ "go.mau.fi/mauview"
+)
+
+type BlockquoteEntity struct {
+ *ContainerEntity
+}
+
+const BlockQuoteChar = '>'
+
+func NewBlockquoteEntity(children []Entity) *BlockquoteEntity {
+ return &BlockquoteEntity{&ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "blockquote",
+ Block: true,
+ },
+ Children: children,
+ Indent: 2,
+ }}
+}
+
+func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
+ return be
+}
+
+func (be *BlockquoteEntity) Clone() Entity {
+ return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)}
+}
+
+func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ be.ContainerEntity.Draw(screen, ctx)
+ for y := 0; y < be.height; y++ {
+ screen.SetContent(0, y, BlockQuoteChar, nil, be.Style)
+ }
+}
+
+func (be *BlockquoteEntity) PlainText() string {
+ if len(be.Children) == 0 {
+ return ""
+ }
+ var buf strings.Builder
+ newlined := false
+ for i, child := range be.Children {
+ if i != 0 && child.IsBlock() && !newlined {
+ buf.WriteRune('\n')
+ }
+ newlined = false
+ for i, row := range strings.Split(child.PlainText(), "\n") {
+ if i != 0 {
+ buf.WriteRune('\n')
+ }
+ buf.WriteRune('>')
+ buf.WriteRune(' ')
+ buf.WriteString(row)
+ }
+ if child.IsBlock() {
+ buf.WriteRune('\n')
+ newlined = true
+ }
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+func (be *BlockquoteEntity) String() string {
+ return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity)
+}
diff --git a/tui/messages/html/break.go b/tui/messages/html/break.go
new file mode 100644
index 0000000..f702a76
--- /dev/null
+++ b/tui/messages/html/break.go
@@ -0,0 +1,54 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "go.mau.fi/mauview"
+)
+
+type BreakEntity struct {
+ *BaseEntity
+}
+
+func NewBreakEntity() *BreakEntity {
+ return &BreakEntity{&BaseEntity{
+ Tag: "br",
+ Block: true,
+ }}
+}
+
+// AdjustStyle changes the style of this text entity.
+func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
+ return be
+}
+
+func (be *BreakEntity) Clone() Entity {
+ return NewBreakEntity()
+}
+
+func (be *BreakEntity) PlainText() string {
+ return "\n"
+}
+
+func (be *BreakEntity) String() string {
+ return "&html.BreakEntity{},\n"
+}
+
+func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ // No-op, the logic happens in containers
+}
diff --git a/tui/messages/html/codeblock.go b/tui/messages/html/codeblock.go
new file mode 100644
index 0000000..01071a8
--- /dev/null
+++ b/tui/messages/html/codeblock.go
@@ -0,0 +1,59 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+type CodeBlockEntity struct {
+ *ContainerEntity
+ Background tcell.Style
+}
+
+func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity {
+ return &CodeBlockEntity{
+ ContainerEntity: &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "pre",
+ Block: true,
+ },
+ Children: children,
+ },
+ Background: background,
+ }
+}
+
+func (ce *CodeBlockEntity) Clone() Entity {
+ return &CodeBlockEntity{
+ ContainerEntity: ce.ContainerEntity.Clone().(*ContainerEntity),
+ Background: ce.Background,
+ }
+}
+
+func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ screen.Fill(' ', ce.Background)
+ ce.ContainerEntity.Draw(screen, ctx)
+}
+
+func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ if reason != AdjustStyleReasonNormal {
+ ce.ContainerEntity.AdjustStyle(fn, reason)
+ }
+ return ce
+}
diff --git a/tui/messages/html/colormap.go b/tui/messages/html/colormap.go
new file mode 100644
index 0000000..305309c
--- /dev/null
+++ b/tui/messages/html/colormap.go
@@ -0,0 +1,156 @@
+// From https://github.com/golang/image/blob/master/colornames/colornames.go
+package html
+
+import (
+ "image/color"
+)
+
+var colorMap = map[string]color.RGBA{
+ "aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255)
+ "antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215)
+ "aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255)
+ "aquamarine": {0x7f, 0xff, 0xd4, 0xff}, // rgb(127, 255, 212)
+ "azure": {0xf0, 0xff, 0xff, 0xff}, // rgb(240, 255, 255)
+ "beige": {0xf5, 0xf5, 0xdc, 0xff}, // rgb(245, 245, 220)
+ "bisque": {0xff, 0xe4, 0xc4, 0xff}, // rgb(255, 228, 196)
+ "black": {0x00, 0x00, 0x00, 0xff}, // rgb(0, 0, 0)
+ "blanchedalmond": {0xff, 0xeb, 0xcd, 0xff}, // rgb(255, 235, 205)
+ "blue": {0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255)
+ "blueviolet": {0x8a, 0x2b, 0xe2, 0xff}, // rgb(138, 43, 226)
+ "brown": {0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42)
+ "burlywood": {0xde, 0xb8, 0x87, 0xff}, // rgb(222, 184, 135)
+ "cadetblue": {0x5f, 0x9e, 0xa0, 0xff}, // rgb(95, 158, 160)
+ "chartreuse": {0x7f, 0xff, 0x00, 0xff}, // rgb(127, 255, 0)
+ "chocolate": {0xd2, 0x69, 0x1e, 0xff}, // rgb(210, 105, 30)
+ "coral": {0xff, 0x7f, 0x50, 0xff}, // rgb(255, 127, 80)
+ "cornflowerblue": {0x64, 0x95, 0xed, 0xff}, // rgb(100, 149, 237)
+ "cornsilk": {0xff, 0xf8, 0xdc, 0xff}, // rgb(255, 248, 220)
+ "crimson": {0xdc, 0x14, 0x3c, 0xff}, // rgb(220, 20, 60)
+ "cyan": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255)
+ "darkblue": {0x00, 0x00, 0x8b, 0xff}, // rgb(0, 0, 139)
+ "darkcyan": {0x00, 0x8b, 0x8b, 0xff}, // rgb(0, 139, 139)
+ "darkgoldenrod": {0xb8, 0x86, 0x0b, 0xff}, // rgb(184, 134, 11)
+ "darkgray": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169)
+ "darkgreen": {0x00, 0x64, 0x00, 0xff}, // rgb(0, 100, 0)
+ "darkgrey": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169)
+ "darkkhaki": {0xbd, 0xb7, 0x6b, 0xff}, // rgb(189, 183, 107)
+ "darkmagenta": {0x8b, 0x00, 0x8b, 0xff}, // rgb(139, 0, 139)
+ "darkolivegreen": {0x55, 0x6b, 0x2f, 0xff}, // rgb(85, 107, 47)
+ "darkorange": {0xff, 0x8c, 0x00, 0xff}, // rgb(255, 140, 0)
+ "darkorchid": {0x99, 0x32, 0xcc, 0xff}, // rgb(153, 50, 204)
+ "darkred": {0x8b, 0x00, 0x00, 0xff}, // rgb(139, 0, 0)
+ "darksalmon": {0xe9, 0x96, 0x7a, 0xff}, // rgb(233, 150, 122)
+ "darkseagreen": {0x8f, 0xbc, 0x8f, 0xff}, // rgb(143, 188, 143)
+ "darkslateblue": {0x48, 0x3d, 0x8b, 0xff}, // rgb(72, 61, 139)
+ "darkslategray": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79)
+ "darkslategrey": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79)
+ "darkturquoise": {0x00, 0xce, 0xd1, 0xff}, // rgb(0, 206, 209)
+ "darkviolet": {0x94, 0x00, 0xd3, 0xff}, // rgb(148, 0, 211)
+ "deeppink": {0xff, 0x14, 0x93, 0xff}, // rgb(255, 20, 147)
+ "deepskyblue": {0x00, 0xbf, 0xff, 0xff}, // rgb(0, 191, 255)
+ "dimgray": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105)
+ "dimgrey": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105)
+ "dodgerblue": {0x1e, 0x90, 0xff, 0xff}, // rgb(30, 144, 255)
+ "firebrick": {0xb2, 0x22, 0x22, 0xff}, // rgb(178, 34, 34)
+ "floralwhite": {0xff, 0xfa, 0xf0, 0xff}, // rgb(255, 250, 240)
+ "forestgreen": {0x22, 0x8b, 0x22, 0xff}, // rgb(34, 139, 34)
+ "fuchsia": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255)
+ "gainsboro": {0xdc, 0xdc, 0xdc, 0xff}, // rgb(220, 220, 220)
+ "ghostwhite": {0xf8, 0xf8, 0xff, 0xff}, // rgb(248, 248, 255)
+ "gold": {0xff, 0xd7, 0x00, 0xff}, // rgb(255, 215, 0)
+ "goldenrod": {0xda, 0xa5, 0x20, 0xff}, // rgb(218, 165, 32)
+ "gray": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128)
+ "green": {0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0)
+ "greenyellow": {0xad, 0xff, 0x2f, 0xff}, // rgb(173, 255, 47)
+ "grey": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128)
+ "honeydew": {0xf0, 0xff, 0xf0, 0xff}, // rgb(240, 255, 240)
+ "hotpink": {0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180)
+ "indianred": {0xcd, 0x5c, 0x5c, 0xff}, // rgb(205, 92, 92)
+ "indigo": {0x4b, 0x00, 0x82, 0xff}, // rgb(75, 0, 130)
+ "ivory": {0xff, 0xff, 0xf0, 0xff}, // rgb(255, 255, 240)
+ "khaki": {0xf0, 0xe6, 0x8c, 0xff}, // rgb(240, 230, 140)
+ "lavender": {0xe6, 0xe6, 0xfa, 0xff}, // rgb(230, 230, 250)
+ "lavenderblush": {0xff, 0xf0, 0xf5, 0xff}, // rgb(255, 240, 245)
+ "lawngreen": {0x7c, 0xfc, 0x00, 0xff}, // rgb(124, 252, 0)
+ "lemonchiffon": {0xff, 0xfa, 0xcd, 0xff}, // rgb(255, 250, 205)
+ "lightblue": {0xad, 0xd8, 0xe6, 0xff}, // rgb(173, 216, 230)
+ "lightcoral": {0xf0, 0x80, 0x80, 0xff}, // rgb(240, 128, 128)
+ "lightcyan": {0xe0, 0xff, 0xff, 0xff}, // rgb(224, 255, 255)
+ "lightgoldenrodyellow": {0xfa, 0xfa, 0xd2, 0xff}, // rgb(250, 250, 210)
+ "lightgray": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211)
+ "lightgreen": {0x90, 0xee, 0x90, 0xff}, // rgb(144, 238, 144)
+ "lightgrey": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211)
+ "lightpink": {0xff, 0xb6, 0xc1, 0xff}, // rgb(255, 182, 193)
+ "lightsalmon": {0xff, 0xa0, 0x7a, 0xff}, // rgb(255, 160, 122)
+ "lightseagreen": {0x20, 0xb2, 0xaa, 0xff}, // rgb(32, 178, 170)
+ "lightskyblue": {0x87, 0xce, 0xfa, 0xff}, // rgb(135, 206, 250)
+ "lightslategray": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153)
+ "lightslategrey": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153)
+ "lightsteelblue": {0xb0, 0xc4, 0xde, 0xff}, // rgb(176, 196, 222)
+ "lightyellow": {0xff, 0xff, 0xe0, 0xff}, // rgb(255, 255, 224)
+ "lime": {0x00, 0xff, 0x00, 0xff}, // rgb(0, 255, 0)
+ "limegreen": {0x32, 0xcd, 0x32, 0xff}, // rgb(50, 205, 50)
+ "linen": {0xfa, 0xf0, 0xe6, 0xff}, // rgb(250, 240, 230)
+ "magenta": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255)
+ "maroon": {0x80, 0x00, 0x00, 0xff}, // rgb(128, 0, 0)
+ "mediumaquamarine": {0x66, 0xcd, 0xaa, 0xff}, // rgb(102, 205, 170)
+ "mediumblue": {0x00, 0x00, 0xcd, 0xff}, // rgb(0, 0, 205)
+ "mediumorchid": {0xba, 0x55, 0xd3, 0xff}, // rgb(186, 85, 211)
+ "mediumpurple": {0x93, 0x70, 0xdb, 0xff}, // rgb(147, 112, 219)
+ "mediumseagreen": {0x3c, 0xb3, 0x71, 0xff}, // rgb(60, 179, 113)
+ "mediumslateblue": {0x7b, 0x68, 0xee, 0xff}, // rgb(123, 104, 238)
+ "mediumspringgreen": {0x00, 0xfa, 0x9a, 0xff}, // rgb(0, 250, 154)
+ "mediumturquoise": {0x48, 0xd1, 0xcc, 0xff}, // rgb(72, 209, 204)
+ "mediumvioletred": {0xc7, 0x15, 0x85, 0xff}, // rgb(199, 21, 133)
+ "midnightblue": {0x19, 0x19, 0x70, 0xff}, // rgb(25, 25, 112)
+ "mintcream": {0xf5, 0xff, 0xfa, 0xff}, // rgb(245, 255, 250)
+ "mistyrose": {0xff, 0xe4, 0xe1, 0xff}, // rgb(255, 228, 225)
+ "moccasin": {0xff, 0xe4, 0xb5, 0xff}, // rgb(255, 228, 181)
+ "navajowhite": {0xff, 0xde, 0xad, 0xff}, // rgb(255, 222, 173)
+ "navy": {0x00, 0x00, 0x80, 0xff}, // rgb(0, 0, 128)
+ "oldlace": {0xfd, 0xf5, 0xe6, 0xff}, // rgb(253, 245, 230)
+ "olive": {0x80, 0x80, 0x00, 0xff}, // rgb(128, 128, 0)
+ "olivedrab": {0x6b, 0x8e, 0x23, 0xff}, // rgb(107, 142, 35)
+ "orange": {0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0)
+ "orangered": {0xff, 0x45, 0x00, 0xff}, // rgb(255, 69, 0)
+ "orchid": {0xda, 0x70, 0xd6, 0xff}, // rgb(218, 112, 214)
+ "palegoldenrod": {0xee, 0xe8, 0xaa, 0xff}, // rgb(238, 232, 170)
+ "palegreen": {0x98, 0xfb, 0x98, 0xff}, // rgb(152, 251, 152)
+ "paleturquoise": {0xaf, 0xee, 0xee, 0xff}, // rgb(175, 238, 238)
+ "palevioletred": {0xdb, 0x70, 0x93, 0xff}, // rgb(219, 112, 147)
+ "papayawhip": {0xff, 0xef, 0xd5, 0xff}, // rgb(255, 239, 213)
+ "peachpuff": {0xff, 0xda, 0xb9, 0xff}, // rgb(255, 218, 185)
+ "peru": {0xcd, 0x85, 0x3f, 0xff}, // rgb(205, 133, 63)
+ "pink": {0xff, 0xc0, 0xcb, 0xff}, // rgb(255, 192, 203)
+ "plum": {0xdd, 0xa0, 0xdd, 0xff}, // rgb(221, 160, 221)
+ "powderblue": {0xb0, 0xe0, 0xe6, 0xff}, // rgb(176, 224, 230)
+ "purple": {0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128)
+ "red": {0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0)
+ "rosybrown": {0xbc, 0x8f, 0x8f, 0xff}, // rgb(188, 143, 143)
+ "royalblue": {0x41, 0x69, 0xe1, 0xff}, // rgb(65, 105, 225)
+ "saddlebrown": {0x8b, 0x45, 0x13, 0xff}, // rgb(139, 69, 19)
+ "salmon": {0xfa, 0x80, 0x72, 0xff}, // rgb(250, 128, 114)
+ "sandybrown": {0xf4, 0xa4, 0x60, 0xff}, // rgb(244, 164, 96)
+ "seagreen": {0x2e, 0x8b, 0x57, 0xff}, // rgb(46, 139, 87)
+ "seashell": {0xff, 0xf5, 0xee, 0xff}, // rgb(255, 245, 238)
+ "sienna": {0xa0, 0x52, 0x2d, 0xff}, // rgb(160, 82, 45)
+ "silver": {0xc0, 0xc0, 0xc0, 0xff}, // rgb(192, 192, 192)
+ "skyblue": {0x87, 0xce, 0xeb, 0xff}, // rgb(135, 206, 235)
+ "slateblue": {0x6a, 0x5a, 0xcd, 0xff}, // rgb(106, 90, 205)
+ "slategray": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144)
+ "slategrey": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144)
+ "snow": {0xff, 0xfa, 0xfa, 0xff}, // rgb(255, 250, 250)
+ "springgreen": {0x00, 0xff, 0x7f, 0xff}, // rgb(0, 255, 127)
+ "steelblue": {0x46, 0x82, 0xb4, 0xff}, // rgb(70, 130, 180)
+ "tan": {0xd2, 0xb4, 0x8c, 0xff}, // rgb(210, 180, 140)
+ "teal": {0x00, 0x80, 0x80, 0xff}, // rgb(0, 128, 128)
+ "thistle": {0xd8, 0xbf, 0xd8, 0xff}, // rgb(216, 191, 216)
+ "tomato": {0xff, 0x63, 0x47, 0xff}, // rgb(255, 99, 71)
+ "turquoise": {0x40, 0xe0, 0xd0, 0xff}, // rgb(64, 224, 208)
+ "violet": {0xee, 0x82, 0xee, 0xff}, // rgb(238, 130, 238)
+ "wheat": {0xf5, 0xde, 0xb3, 0xff}, // rgb(245, 222, 179)
+ "white": {0xff, 0xff, 0xff, 0xff}, // rgb(255, 255, 255)
+ "whitesmoke": {0xf5, 0xf5, 0xf5, 0xff}, // rgb(245, 245, 245)
+ "yellow": {0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0)
+ "yellowgreen": {0x9a, 0xcd, 0x32, 0xff}, // rgb(154, 205, 50)
+}
diff --git a/tui/messages/html/container.go b/tui/messages/html/container.go
new file mode 100644
index 0000000..17e7aa9
--- /dev/null
+++ b/tui/messages/html/container.go
@@ -0,0 +1,148 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "strings"
+
+ "go.mau.fi/mauview"
+)
+
+type ContainerEntity struct {
+ *BaseEntity
+
+ // The children of this container entity.
+ Children []Entity
+ // Number of cells to indent children.
+ Indent int
+}
+
+func (ce *ContainerEntity) IsEmpty() bool {
+ return len(ce.Children) == 0
+}
+
+// PlainText returns the plaintext content in this entity and all its children.
+func (ce *ContainerEntity) PlainText() string {
+ if len(ce.Children) == 0 {
+ return ""
+ }
+ var buf strings.Builder
+ newlined := false
+ for _, child := range ce.Children {
+ text := child.PlainText()
+ if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined {
+ buf.WriteRune('\n')
+ }
+ newlined = false
+ buf.WriteString(text)
+ if child.IsBlock() {
+ if !strings.HasSuffix(text, "\n") {
+ buf.WriteRune('\n')
+ }
+ newlined = true
+ }
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+// AdjustStyle recursively changes the style of this entity and all its children.
+func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ for _, child := range ce.Children {
+ child.AdjustStyle(fn, reason)
+ }
+ ce.Style = fn(ce.Style)
+ return ce
+}
+
+// Clone creates a deep copy of this base entity.
+func (ce *ContainerEntity) Clone() Entity {
+ children := make([]Entity, len(ce.Children))
+ for i, child := range ce.Children {
+ children[i] = child.Clone()
+ }
+ return &ContainerEntity{
+ BaseEntity: ce.BaseEntity.Clone().(*BaseEntity),
+ Children: children,
+ Indent: ce.Indent,
+ }
+}
+
+// String returns a textual representation of this BaseEntity struct.
+func (ce *ContainerEntity) String() string {
+ if len(ce.Children) == 0 {
+ return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent)
+ }
+ var buf strings.Builder
+ _, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent)
+ for _, child := range ce.Children {
+ buf.WriteString("\n ")
+ buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n "))
+ }
+ buf.WriteString("\n]},")
+ return buf.String()
+}
+
+// Draw draws this entity onto the given mauview Screen.
+func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ if len(ce.Children) == 0 {
+ return
+ }
+ width, _ := screen.Size()
+ prevBreak := false
+ proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style}
+ for i, entity := range ce.Children {
+ if i != 0 && entity.getStartX() == 0 {
+ proxyScreen.OffsetY++
+ }
+ proxyScreen.Height = entity.Height()
+ entity.Draw(proxyScreen, ctx)
+ proxyScreen.SetStyle(ce.Style)
+ proxyScreen.OffsetY += entity.Height() - 1
+ _, isBreak := entity.(*BreakEntity)
+ if prevBreak && isBreak {
+ proxyScreen.OffsetY++
+ }
+ prevBreak = isBreak
+ }
+}
+
+// CalculateBuffer prepares this entity and all its children for rendering with the given parameters
+func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
+ ce.BaseEntity.CalculateBuffer(width, startX, ctx)
+ if len(ce.Children) > 0 {
+ ce.height = 0
+ childStartX := ce.startX
+ prevBreak := false
+ for _, entity := range ce.Children {
+ if entity.IsBlock() || childStartX == 0 || ce.height == 0 {
+ ce.height++
+ }
+ childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx)
+ ce.height += entity.Height() - 1
+ _, isBreak := entity.(*BreakEntity)
+ if prevBreak && isBreak {
+ ce.height++
+ }
+ prevBreak = isBreak
+ }
+ if !ce.Block {
+ return childStartX
+ }
+ }
+ return ce.startX
+}
diff --git a/tui/messages/html/entity.go b/tui/messages/html/entity.go
new file mode 100644
index 0000000..3c4260a
--- /dev/null
+++ b/tui/messages/html/entity.go
@@ -0,0 +1,63 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+// AdjustStyleFunc is a lambda function type to edit an existing tcell Style.
+type AdjustStyleFunc func(tcell.Style) tcell.Style
+
+type AdjustStyleReason int
+
+const (
+ AdjustStyleReasonNormal AdjustStyleReason = iota
+ AdjustStyleReasonHideSpoiler
+)
+
+type DrawContext struct {
+ IsSelected bool
+ BareMessages bool
+}
+
+type Entity interface {
+ // AdjustStyle recursively changes the style of the entity and all its children.
+ AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity
+ // Draw draws the entity onto the given mauview Screen.
+ Draw(screen mauview.Screen, ctx DrawContext)
+ // IsBlock returns whether or not it's a block-type entity.
+ IsBlock() bool
+ // GetTag returns the HTML tag of the entity.
+ GetTag() string
+ // PlainText returns the plaintext content in the entity and all its children.
+ PlainText() string
+ // String returns a string representation of the entity struct.
+ String() string
+ // Clone creates a deep copy of the entity.
+ Clone() Entity
+
+ // Height returns the render height of the entity.
+ Height() int
+ // CalculateBuffer prepares the entity and all its children for rendering with the given parameters
+ CalculateBuffer(width, startX int, ctx DrawContext) int
+
+ getStartX() int
+
+ IsEmpty() bool
+}
diff --git a/tui/messages/html/horizontalline.go b/tui/messages/html/horizontalline.go
new file mode 100644
index 0000000..ff12709
--- /dev/null
+++ b/tui/messages/html/horizontalline.go
@@ -0,0 +1,61 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "strings"
+
+ "go.mau.fi/mauview"
+)
+
+type HorizontalLineEntity struct {
+ *BaseEntity
+}
+
+const HorizontalLineChar = '━'
+
+func NewHorizontalLineEntity() *HorizontalLineEntity {
+ return &HorizontalLineEntity{&BaseEntity{
+ Tag: "hr",
+ Block: true,
+ DefaultHeight: 1,
+ }}
+}
+
+func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
+ return he
+}
+
+func (he *HorizontalLineEntity) Clone() Entity {
+ return NewHorizontalLineEntity()
+}
+
+func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ width, _ := screen.Size()
+ for x := 0; x < width; x++ {
+ screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style)
+ }
+}
+
+func (he *HorizontalLineEntity) PlainText() string {
+ return strings.Repeat(string(HorizontalLineChar), 5)
+}
+
+func (he *HorizontalLineEntity) String() string {
+ return "&html.HorizontalLineEntity{},\n"
+}
diff --git a/tui/messages/html/list.go b/tui/messages/html/list.go
new file mode 100644
index 0000000..04f1414
--- /dev/null
+++ b/tui/messages/html/list.go
@@ -0,0 +1,123 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "strings"
+
+ "go.mau.fi/mauview"
+
+ "maunium.net/go/gomuks/tui/widget"
+ "maunium.net/go/mautrix/format"
+)
+
+type ListEntity struct {
+ *ContainerEntity
+ Ordered bool
+ Start int
+}
+
+func NewListEntity(ordered bool, start int, children []Entity) *ListEntity {
+ entity := &ListEntity{
+ ContainerEntity: &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "ul",
+ Block: true,
+ },
+ Indent: 2,
+ Children: children,
+ },
+ Ordered: ordered,
+ Start: start,
+ }
+ if ordered {
+ entity.Tag = "ol"
+ entity.Indent += format.Digits(start + len(children) - 1)
+ }
+ return entity
+}
+
+func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
+ le.ContainerEntity.AdjustStyle(fn, reason)
+ return le
+}
+
+func (le *ListEntity) Clone() Entity {
+ return &ListEntity{
+ ContainerEntity: le.ContainerEntity.Clone().(*ContainerEntity),
+ Ordered: le.Ordered,
+ Start: le.Start,
+ }
+}
+
+func (le *ListEntity) paddingFor(number int) string {
+ padding := le.Indent - 2 - format.Digits(number)
+ if padding <= 0 {
+ return ""
+ }
+ return strings.Repeat(" ", padding)
+}
+
+func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ width, _ := screen.Size()
+
+ proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style}
+ for i, entity := range le.Children {
+ proxyScreen.Height = entity.Height()
+ if le.Ordered {
+ number := le.Start + i
+ line := fmt.Sprintf("%d. %s", number, le.paddingFor(number))
+ widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style)
+ } else {
+ screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style)
+ }
+ entity.Draw(proxyScreen, ctx)
+ proxyScreen.SetStyle(le.Style)
+ proxyScreen.OffsetY += entity.Height()
+ }
+}
+
+func (le *ListEntity) PlainText() string {
+ if len(le.Children) == 0 {
+ return ""
+ }
+ var buf strings.Builder
+ for i, child := range le.Children {
+ indent := strings.Repeat(" ", le.Indent)
+ if le.Ordered {
+ number := le.Start + i
+ _, _ = fmt.Fprintf(&buf, "%d. %s", number, le.paddingFor(number))
+ } else {
+ buf.WriteString("● ")
+ }
+ for j, row := range strings.Split(child.PlainText(), "\n") {
+ if j != 0 {
+ buf.WriteRune('\n')
+ buf.WriteString(indent)
+ }
+ buf.WriteString(row)
+ }
+ buf.WriteRune('\n')
+ }
+ return strings.TrimSpace(buf.String())
+}
+
+func (le *ListEntity) String() string {
+ return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity)
+}
diff --git a/tui/messages/html/parser.go b/tui/messages/html/parser.go
new file mode 100644
index 0000000..5362f9c
--- /dev/null
+++ b/tui/messages/html/parser.go
@@ -0,0 +1,555 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ /*
+ "github.com/alecthomas/chroma"
+ "github.com/alecthomas/chroma/lexers"
+ "github.com/alecthomas/chroma/styles"
+ */
+ "github.com/lucasb-eyer/go-colorful"
+ "golang.org/x/net/html"
+ "mvdan.cc/xurls/v2"
+
+ "github.com/gdamore/tcell/v2"
+
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+/*
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/matrix/muksevt"
+ "maunium.net/go/gomuks/matrix/rooms"
+ "maunium.net/go/gomuks/ui/widget"
+*/
+)
+
+type htmlParser struct {
+ prefs *config.UserPreferences
+ room *rooms.Room
+ evt *muksevt.Event
+
+ preserveWhitespace bool
+ linkIDCounter int
+}
+
+func AdjustStyleBold(style tcell.Style) tcell.Style {
+ return style.Bold(true)
+}
+
+func AdjustStyleItalic(style tcell.Style) tcell.Style {
+ return style.Italic(true)
+}
+
+func AdjustStyleUnderline(style tcell.Style) tcell.Style {
+ return style.Underline(true)
+}
+
+func AdjustStyleStrikethrough(style tcell.Style) tcell.Style {
+ return style.StrikeThrough(true)
+}
+
+func AdjustStyleTextColor(color tcell.Color) AdjustStyleFunc {
+ return func(style tcell.Style) tcell.Style {
+ return style.Foreground(color)
+ }
+}
+
+func AdjustStyleBackgroundColor(color tcell.Color) AdjustStyleFunc {
+ return func(style tcell.Style) tcell.Style {
+ return style.Background(color)
+ }
+}
+
+func AdjustStyleLink(url, id string) AdjustStyleFunc {
+ return func(style tcell.Style) tcell.Style {
+ return style.Url(url).UrlId(id)
+ }
+}
+
+func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) {
+ for _, attr := range node.Attr {
+ if attr.Key == attribute {
+ return attr.Val, true
+ }
+ }
+ return "", false
+}
+
+func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string {
+ val, _ := parser.maybeGetAttribute(node, attribute)
+ return val
+}
+
+func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool {
+ _, ok := parser.maybeGetAttribute(node, attribute)
+ return ok
+}
+
+func (parser *htmlParser) listToEntity(node *html.Node) Entity {
+ children := parser.nodeToEntities(node.FirstChild)
+ ordered := node.Data == "ol"
+ start := 1
+ if ordered {
+ if startRaw := parser.getAttribute(node, "start"); len(startRaw) > 0 {
+ var err error
+ start, err = strconv.Atoi(startRaw)
+ if err != nil {
+ start = 1
+ }
+ }
+ }
+ listItems := children[:0]
+ for _, child := range children {
+ if child.GetTag() == "li" {
+ listItems = append(listItems, child)
+ }
+ }
+ return NewListEntity(ordered, start, listItems)
+}
+
+func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity {
+ entity := &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: node.Data,
+ },
+ Children: parser.nodeToEntities(node.FirstChild),
+ }
+ switch node.Data {
+ case "b", "strong":
+ entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
+ case "i", "em":
+ entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal)
+ case "s", "del", "strike":
+ entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal)
+ case "u", "ins":
+ entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal)
+ case "code":
+ bgColor := tcell.ColorDarkSlateGray
+ fgColor := tcell.ColorWhite
+ entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal)
+ entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal)
+ case "font", "span":
+ fgColor, ok := parser.parseColor(node, "data-mx-color", "color")
+ if ok {
+ entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal)
+ }
+ bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color")
+ if ok {
+ entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal)
+ }
+ spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler")
+ if isSpoiler {
+ return NewSpoilerEntity(entity, spoilerReason)
+ }
+ }
+ return entity
+}
+
+func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) {
+ hex := parser.getAttribute(node, mainName)
+ if len(hex) == 0 {
+ hex = parser.getAttribute(node, altName)
+ if len(hex) == 0 {
+ return
+ }
+ }
+
+ cful, err := colorful.Hex(hex)
+ if err != nil {
+ color2, found := colorMap[strings.ToLower(hex)]
+ if !found {
+ return
+ }
+ cful, _ = colorful.MakeColor(color2)
+ }
+
+ r, g, b := cful.RGB255()
+ return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true
+}
+
+func (parser *htmlParser) headerToEntity(node *html.Node) Entity {
+ return (&ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: node.Data,
+ },
+ Children: append(
+ []Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")},
+ parser.nodeToEntities(node.FirstChild)...,
+ ),
+ }).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
+}
+
+func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity {
+ return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild))
+}
+
+func (parser *htmlParser) linkToEntity(node *html.Node) Entity {
+ sameURL := false
+ href := parser.getAttribute(node, "href")
+
+ entity := &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "a",
+ },
+ Children: parser.nodeToEntities(node.FirstChild),
+ }
+
+ if len(href) == 0 {
+ return entity
+ }
+
+ if len(entity.Children) == 1 {
+ entity, ok := entity.Children[0].(*TextEntity)
+ if ok && entity.Text == href {
+ sameURL = true
+ }
+ }
+
+ matrixURI, _ := id.ParseMatrixURIOrMatrixToURL(href)
+ if matrixURI != nil && (matrixURI.Sigil1 == '@' || matrixURI.Sigil1 == '#') && matrixURI.Sigil2 == 0 {
+ text := NewTextEntity(matrixURI.PrimaryIdentifier())
+ if matrixURI.Sigil1 == '@' {
+ if member := parser.room.GetMember(matrixURI.UserID()); member != nil {
+ text.Text = member.Displayname
+ text.Style = text.Style.Foreground(widget.GetHashColor(matrixURI.UserID()))
+ }
+ entity.Children = []Entity{text}
+ } else if matrixURI.Sigil1 == '#' {
+ entity.Children = []Entity{text}
+ }
+ } else if parser.prefs.EnableInlineURLs() {
+ linkID := fmt.Sprintf("%s-%d", parser.evt.ID, parser.linkIDCounter)
+ parser.linkIDCounter++
+ entity.AdjustStyle(AdjustStyleLink(href, linkID), AdjustStyleReasonNormal)
+ } else if !sameURL && !parser.prefs.DisableShowURLs && !parser.hasAttribute(node, "data-mautrix-exclude-plaintext") {
+ entity.Children = append(entity.Children, NewTextEntity(fmt.Sprintf(" (%s)", href)))
+ }
+ return entity
+}
+
+func (parser *htmlParser) imageToEntity(node *html.Node) Entity {
+ alt := parser.getAttribute(node, "alt")
+ if len(alt) == 0 {
+ alt = parser.getAttribute(node, "title")
+ if len(alt) == 0 {
+ alt = "[inline image]"
+ }
+ }
+ entity := &TextEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "img",
+ },
+ Text: alt,
+ }
+ // TODO add click action and underline on hover for inline images
+ return entity
+}
+
+func colourToColor(colour chroma.Colour) tcell.Color {
+ if !colour.IsSet() {
+ return tcell.ColorDefault
+ }
+ return tcell.NewRGBColor(int32(colour.Red()), int32(colour.Green()), int32(colour.Blue()))
+}
+
+func styleEntryToStyle(se chroma.StyleEntry) tcell.Style {
+ return tcell.StyleDefault.
+ Bold(se.Bold == chroma.Yes).
+ Italic(se.Italic == chroma.Yes).
+ Underline(se.Underline == chroma.Yes).
+ Foreground(colourToColor(se.Colour)).
+ Background(colourToColor(se.Background))
+}
+
+func tokenToTextEntity(style *chroma.Style, token *chroma.Token) *TextEntity {
+ return &TextEntity{
+ BaseEntity: &BaseEntity{
+ Tag: token.Type.String(),
+ Style: styleEntryToStyle(style.Get(token.Type)),
+ DefaultHeight: 1,
+ },
+ Text: token.Value,
+ }
+}
+
+func (parser *htmlParser) syntaxHighlight(text, language string) Entity {
+ lexer := lexers.Get(strings.ToLower(language))
+ if lexer == nil {
+ lexer = lexers.Get("plaintext")
+ }
+ iter, err := lexer.Tokenise(nil, text)
+ if err != nil {
+ return nil
+ }
+ // TODO allow changing theme
+ style := styles.SolarizedDark
+
+ tokens := iter.Tokens()
+
+ var children []Entity
+ for _, token := range tokens {
+ lines := strings.SplitAfter(token.Value, "\n")
+ for _, line := range lines {
+ line_len := len(line)
+ if line_len == 0 {
+ continue
+ }
+ t := token.Clone()
+
+ if line[line_len-1:] == "\n" {
+ t.Value = line[:line_len-1]
+ children = append(children, tokenToTextEntity(style, &t), NewBreakEntity())
+ } else {
+ t.Value = line
+ children = append(children, tokenToTextEntity(style, &t))
+ }
+ }
+ }
+
+ return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background)))
+}
+
+func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity {
+ lang := "plaintext"
+ // TODO allow disabling syntax highlighting
+ if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" {
+ node = node.FirstChild
+ attr := parser.getAttribute(node, "class")
+ for _, class := range strings.Split(attr, " ") {
+ if strings.HasPrefix(class, "language-") {
+ lang = class[len("language-"):]
+ break
+ }
+ }
+ }
+ parser.preserveWhitespace = true
+ text := (&ContainerEntity{
+ Children: parser.nodeToEntities(node.FirstChild),
+ }).PlainText()
+ parser.preserveWhitespace = false
+ return parser.syntaxHighlight(text, lang)
+}
+
+func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity {
+ switch node.Data {
+ case "blockquote":
+ return parser.blockquoteToEntity(node)
+ case "ol", "ul":
+ return parser.listToEntity(node)
+ case "h1", "h2", "h3", "h4", "h5", "h6":
+ return parser.headerToEntity(node)
+ case "br":
+ return NewBreakEntity()
+ case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "font", "span", "code":
+ return parser.basicFormatToEntity(node)
+ case "a":
+ return parser.linkToEntity(node)
+ case "img":
+ return parser.imageToEntity(node)
+ case "pre":
+ return parser.codeblockToEntity(node)
+ case "hr":
+ return NewHorizontalLineEntity()
+ case "mx-reply":
+ return nil
+ default:
+ return &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: node.Data,
+ Block: parser.isBlockTag(node.Data),
+ },
+ Children: parser.nodeToEntities(node.FirstChild),
+ }
+ }
+}
+
+var spaces = regexp.MustCompile("\\s+")
+
+// textToHTMLEntity converts a plain text string into an HTML Entity while preserving newlines.
+func textToHTMLEntity(text string) Entity {
+ if strings.Index(text, "\n") == -1 {
+ return NewTextEntity(text)
+ }
+ return &ContainerEntity{
+ BaseEntity: &BaseEntity{Tag: "span"},
+ Children: textToHTMLEntities(text),
+ }
+}
+
+func textToHTMLEntities(text string) []Entity {
+ lines := strings.SplitAfter(text, "\n")
+ entities := make([]Entity, 0, len(lines))
+ for _, line := range lines {
+ line_len := len(line)
+ if line_len == 0 {
+ continue
+ }
+ if line == "\n" {
+ entities = append(entities, NewBreakEntity())
+ } else if line[line_len-1:] == "\n" {
+ entities = append(entities, NewTextEntity(line[:line_len-1]), NewBreakEntity())
+ } else {
+ entities = append(entities, NewTextEntity(line))
+ }
+ }
+ return entities
+}
+
+func TextToEntity(text string, eventID id.EventID, linkify bool) Entity {
+ if len(text) == 0 {
+ return nil
+ }
+ if !linkify {
+ return textToHTMLEntity(text)
+ }
+ indices := xurls.Strict().FindAllStringIndex(text, -1)
+ if len(indices) == 0 {
+ return textToHTMLEntity(text)
+ }
+ ent := &ContainerEntity{
+ BaseEntity: &BaseEntity{Tag: "span"},
+ }
+ var lastEnd int
+ for i, item := range indices {
+ start, end := item[0], item[1]
+ if start > lastEnd {
+ ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:start])...)
+ }
+ link := text[start:end]
+ linkID := fmt.Sprintf("%s-%d", eventID, i)
+ ent.Children = append(ent.Children, NewTextEntity(link).AdjustStyle(AdjustStyleLink(link, linkID), AdjustStyleReasonNormal))
+ lastEnd = end
+ }
+ if lastEnd < len(text) {
+ ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:])...)
+ }
+ return ent
+}
+
+func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity {
+ switch node.Type {
+ case html.TextNode:
+ if !parser.preserveWhitespace {
+ node.Data = strings.ReplaceAll(node.Data, "\n", "")
+ node.Data = spaces.ReplaceAllLiteralString(node.Data, " ")
+ }
+ return TextToEntity(node.Data, parser.evt.ID, parser.prefs.EnableInlineURLs())
+ case html.ElementNode:
+ parsed := parser.tagNodeToEntity(node)
+ if parsed != nil && !parsed.IsBlock() && parsed.IsEmpty() {
+ return nil
+ }
+ return parsed
+ case html.DocumentNode:
+ if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil {
+ return parser.singleNodeToEntity(node.FirstChild)
+ }
+ return &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "html",
+ Block: true,
+ },
+ Children: parser.nodeToEntities(node.FirstChild),
+ }
+ default:
+ return nil
+ }
+}
+
+func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) {
+ for ; node != nil; node = node.NextSibling {
+ if entity := parser.singleNodeToEntity(node); entity != nil {
+ entities = append(entities, entity)
+ }
+ }
+ return
+}
+
+var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "pre", "blockquote", "div", "hr", "table"}
+
+func (parser *htmlParser) isBlockTag(tag string) bool {
+ for _, blockTag := range BlockTags {
+ if tag == blockTag {
+ return true
+ }
+ }
+ return false
+}
+
+func (parser *htmlParser) Parse(htmlData string) Entity {
+ node, _ := html.Parse(strings.NewReader(htmlData))
+ bodyNode := node.FirstChild.FirstChild
+ for bodyNode != nil && (bodyNode.Type != html.ElementNode || bodyNode.Data != "body") {
+ bodyNode = bodyNode.NextSibling
+ }
+ if bodyNode != nil {
+ return parser.singleNodeToEntity(bodyNode)
+ }
+
+ return parser.singleNodeToEntity(node)
+}
+
+const TabLength = 4
+
+// Parse parses a HTML-formatted Matrix event into a UIMessage.
+func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.MessageEventContent, evt *muksevt.Event, senderDisplayname string) Entity {
+ htmlData := content.FormattedBody
+
+ if content.Format != event.FormatHTML {
+ htmlData = strings.Replace(html.EscapeString(content.Body), "\n", "
", -1)
+ }
+ htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1)
+
+ parser := htmlParser{room: room, prefs: prefs, evt: evt}
+ root := parser.Parse(htmlData)
+ if root == nil {
+ return nil
+ }
+ beRoot, ok := root.(*ContainerEntity)
+ if ok {
+ beRoot.Block = false
+ if len(beRoot.Children) > 0 {
+ beChild, ok := beRoot.Children[0].(*ContainerEntity)
+ if ok && beChild.Tag == "p" {
+ // Hacky fix for m.emote
+ beChild.Block = false
+ }
+ }
+ }
+
+ if content.MsgType == event.MsgEmote {
+ root = &ContainerEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "emote",
+ },
+ Children: []Entity{
+ NewTextEntity("* "),
+ NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal),
+ NewTextEntity(" "),
+ root,
+ },
+ }
+ }
+
+ return root
+}
diff --git a/tui/messages/html/spoiler.go b/tui/messages/html/spoiler.go
new file mode 100644
index 0000000..c325798
--- /dev/null
+++ b/tui/messages/html/spoiler.go
@@ -0,0 +1,120 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2022 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "strings"
+
+ "go.mau.fi/mauview"
+ "github.com/gdamore/tcell/v2"
+)
+
+type SpoilerEntity struct {
+ reason string
+ hidden *ContainerEntity
+ visible *ContainerEntity
+}
+
+const SpoilerColor = tcell.ColorYellow
+
+func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity {
+ hidden := visible.Clone().(*ContainerEntity)
+ hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
+ return style.Foreground(SpoilerColor).Background(SpoilerColor)
+ }, AdjustStyleReasonHideSpoiler)
+ if len(reason) > 0 {
+ reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason))
+ hidden.Children = append([]Entity{reasonEnt}, hidden.Children...)
+ visible.Children = append([]Entity{reasonEnt}, visible.Children...)
+ }
+ return &SpoilerEntity{
+ reason: reason,
+ hidden: hidden,
+ visible: visible,
+ }
+}
+
+func (se *SpoilerEntity) Clone() Entity {
+ return &SpoilerEntity{
+ reason: se.reason,
+ hidden: se.hidden.Clone().(*ContainerEntity),
+ visible: se.visible.Clone().(*ContainerEntity),
+ }
+}
+
+func (se *SpoilerEntity) IsBlock() bool {
+ return false
+}
+
+func (se *SpoilerEntity) GetTag() string {
+ return "span"
+}
+
+func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ if ctx.IsSelected {
+ se.visible.Draw(screen, ctx)
+ } else {
+ se.hidden.Draw(screen, ctx)
+ }
+}
+
+func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ if reason != AdjustStyleReasonHideSpoiler {
+ se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
+ return fn(style).Foreground(SpoilerColor).Background(SpoilerColor)
+ }, reason)
+ se.visible.AdjustStyle(fn, reason)
+ }
+ return se
+}
+
+func (se *SpoilerEntity) PlainText() string {
+ if len(se.reason) > 0 {
+ return fmt.Sprintf("spoiler: %s", se.reason)
+ } else {
+ return "spoiler"
+ }
+}
+
+func (se *SpoilerEntity) String() string {
+ var buf strings.Builder
+ _, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason)
+ buf.WriteString("\n visible=")
+ buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n "))
+ buf.WriteString("\n hidden=")
+ buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n "))
+ buf.WriteString("\n]},")
+ return buf.String()
+}
+
+func (se *SpoilerEntity) Height() int {
+ return se.visible.Height()
+}
+
+func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
+ se.hidden.CalculateBuffer(width, startX, ctx)
+ return se.visible.CalculateBuffer(width, startX, ctx)
+}
+
+func (se *SpoilerEntity) getStartX() int {
+ return se.visible.getStartX()
+}
+
+func (se *SpoilerEntity) IsEmpty() bool {
+ return se.visible.IsEmpty()
+}
diff --git a/tui/messages/html/text.go b/tui/messages/html/text.go
new file mode 100644
index 0000000..95a1ff5
--- /dev/null
+++ b/tui/messages/html/text.go
@@ -0,0 +1,156 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package html
+
+import (
+ "fmt"
+ "regexp"
+
+ "github.com/mattn/go-runewidth"
+
+ "go.mau.fi/mauview"
+
+ "maunium.net/go/gomuks/tui/widget"
+)
+
+type TextEntity struct {
+ *BaseEntity
+ // Text in this entity.
+ Text string
+
+ buffer []string
+}
+
+// NewTextEntity creates a new text-only Entity.
+func NewTextEntity(text string) *TextEntity {
+ return &TextEntity{
+ BaseEntity: &BaseEntity{
+ Tag: "text",
+ },
+ Text: text,
+ }
+}
+
+func (te *TextEntity) IsEmpty() bool {
+ return len(te.Text) == 0
+}
+
+func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
+ te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
+ return te
+}
+
+func (te *TextEntity) Clone() Entity {
+ return &TextEntity{
+ BaseEntity: te.BaseEntity.Clone().(*BaseEntity),
+ Text: te.Text,
+ }
+}
+
+func (te *TextEntity) PlainText() string {
+ return te.Text
+}
+
+func (te *TextEntity) String() string {
+ return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity)
+}
+
+func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) {
+ width, _ := screen.Size()
+ x := te.startX
+ for y, line := range te.buffer {
+ widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, te.Style)
+ x = 0
+ }
+}
+
+func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
+ te.BaseEntity.CalculateBuffer(width, startX, ctx)
+ if len(te.Text) == 0 {
+ return te.startX
+ }
+ te.height = 0
+ te.prevWidth = width
+ if te.buffer == nil {
+ te.buffer = []string{}
+ }
+ bufPtr := 0
+ text := te.Text
+ textStartX := te.startX
+ for {
+ // TODO add option no wrap and character wrap options
+ extract := runewidth.Truncate(text, width-textStartX, "")
+ extract, wordWrapped := trim(extract, text, ctx.BareMessages)
+ if !wordWrapped && textStartX > 0 {
+ if bufPtr < len(te.buffer) {
+ te.buffer[bufPtr] = ""
+ } else {
+ te.buffer = append(te.buffer, "")
+ }
+ bufPtr++
+ textStartX = 0
+ continue
+ }
+ if bufPtr < len(te.buffer) {
+ te.buffer[bufPtr] = extract
+ } else {
+ te.buffer = append(te.buffer, extract)
+ }
+ bufPtr++
+ text = text[len(extract):]
+ if len(text) == 0 {
+ te.buffer = te.buffer[:bufPtr]
+ te.height += len(te.buffer)
+ // This entity is over, return the startX for the next entity
+ if te.Block {
+ // ...except if it's a block entity
+ return 0
+ }
+ return textStartX + runewidth.StringWidth(extract)
+ }
+ textStartX = 0
+ }
+}
+
+var (
+ boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`)
+ bareBoundaryPattern = regexp.MustCompile(`(\s+)`)
+ spacePattern = regexp.MustCompile(`\s+`)
+)
+
+func trim(extract, full string, bare bool) (string, bool) {
+ if len(extract) == len(full) {
+ return extract, true
+ }
+ if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 {
+ extract = full[:len(extract)+spaces[1]]
+ }
+ regex := boundaryPattern
+ if bare {
+ regex = bareBoundaryPattern
+ }
+ matches := regex.FindAllStringIndex(extract, -1)
+ if len(matches) > 0 {
+ if match := matches[len(matches)-1]; len(match) >= 2 {
+ if until := match[1]; until < len(extract) {
+ extract = extract[:until]
+ return extract, true
+ }
+ }
+ }
+ return extract, len(extract) > 0 && extract[len(extract)-1] == ' '
+}
diff --git a/tui/messages/htmlmessage.go b/tui/messages/htmlmessage.go
new file mode 100644
index 0000000..bca1672
--- /dev/null
+++ b/tui/messages/htmlmessage.go
@@ -0,0 +1,100 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "go.mau.fi/mauview"
+ "github.com/gdamore/tcell/v2"
+/*
+ "maunium.net/go/gomuks/matrix/muksevt"
+
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/tui/messages/html"
+*/
+)
+
+type HTMLMessage struct {
+ Root html.Entity
+ TextColor tcell.Color
+}
+
+func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage {
+ return newUIMessage(evt, displayname, &HTMLMessage{
+ Root: root,
+ })
+}
+
+func (hw *HTMLMessage) Clone() MessageRenderer {
+ return &HTMLMessage{
+ Root: hw.Root.Clone(),
+ }
+}
+
+func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) {
+ if hw.TextColor != tcell.ColorDefault {
+ hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style {
+ fg, _, _ := style.Decompose()
+ if fg == tcell.ColorDefault {
+ return style.Foreground(hw.TextColor)
+ }
+ return style
+ }, html.AdjustStyleReasonNormal)
+ }
+ screen.Clear()
+ hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected})
+}
+
+func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool {
+ return false
+}
+
+func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool {
+ return false
+}
+
+func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool {
+ return false
+}
+
+func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int, msg *UIMessage) {
+ if width < 2 {
+ return
+ }
+ // TODO account for bare messages in initial startX
+ startX := 0
+ hw.TextColor = msg.TextColor()
+ hw.Root.CalculateBuffer(width, startX, html.DrawContext{
+ IsSelected: msg.IsSelected,
+ BareMessages: preferences.BareMessageView,
+ })
+}
+
+func (hw *HTMLMessage) Height() int {
+ return hw.Root.Height()
+}
+
+func (hw *HTMLMessage) PlainText() string {
+ return hw.Root.PlainText()
+}
+
+func (hw *HTMLMessage) NotificationContent() string {
+ return hw.Root.PlainText()
+}
+
+func (hw *HTMLMessage) String() string {
+ return hw.Root.String()
+}
diff --git a/tui/messages/parser.go b/tui/messages/parser.go
new file mode 100644
index 0000000..3450d7a
--- /dev/null
+++ b/tui/messages/parser.go
@@ -0,0 +1,324 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+/* "maunium.net/go/gomuks/debug"
+ "maunium.net/go/gomuks/matrix/muksevt"
+ "maunium.net/go/gomuks/matrix/rooms"
+ "maunium.net/go/gomuks/tui/messages/html"
+ "maunium.net/go/gomuks/tui/messages/tstring"
+ "maunium.net/go/gomuks/tui/widget"
+*/
+)
+
+func getCachedEvent(mainView ifc.MainView, roomID id.RoomID, eventID id.EventID) *UIMessage {
+ if roomView := mainView.GetRoom(roomID); roomView != nil {
+ if replyToIfcMsg := roomView.GetEvent(eventID); replyToIfcMsg != nil {
+ if replyToMsg, ok := replyToIfcMsg.(*UIMessage); ok && replyToMsg != nil {
+ return replyToMsg
+ }
+ }
+ }
+ return nil
+}
+
+func ParseEvent(matrix ifc.MatrixContainer, mainView ifc.MainView, room *rooms.Room, evt *muksevt.Event) *UIMessage {
+ msg := directParseEvent(matrix, room, evt)
+ if msg == nil {
+ return nil
+ }
+ if content, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && len(content.GetReplyTo()) > 0 {
+ if replyToMsg := getCachedEvent(mainView, room.ID, content.GetReplyTo()); replyToMsg != nil {
+ msg.ReplyTo = replyToMsg.Clone()
+ } else if replyToEvt, _ := matrix.GetEvent(room, content.GetReplyTo()); replyToEvt != nil {
+ if replyToMsg = directParseEvent(matrix, room, replyToEvt); replyToMsg != nil {
+ msg.ReplyTo = replyToMsg
+ msg.ReplyTo.Reactions = nil
+ } else {
+ // TODO add unrenderable reply header
+ }
+ } else {
+ // TODO add unknown reply header
+ }
+ }
+ return msg
+}
+
+func directParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event) *UIMessage {
+ displayname := string(evt.Sender)
+ member := room.GetMember(evt.Sender)
+ if member != nil {
+ displayname = member.Displayname
+ }
+ if evt.Unsigned.RedactedBecause != nil || evt.Type == event.EventRedaction {
+ return NewRedactedMessage(evt, displayname)
+ }
+ switch content := evt.Content.Parsed.(type) {
+ case *event.MessageEventContent:
+ if evt.Type == event.EventSticker {
+ content.MsgType = event.MsgImage
+ }
+ return ParseMessage(matrix, room, evt, displayname)
+ case *muksevt.BadEncryptedContent:
+ return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString(content.Reason, tcell.StyleDefault.Italic(true)))
+ case *muksevt.EncryptionUnsupportedContent:
+ return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString("gomuks not built with encryption support", tcell.StyleDefault.Italic(true)))
+ case *event.TopicEventContent, *event.RoomNameEventContent, *event.CanonicalAliasEventContent:
+ return ParseStateEvent(evt, displayname)
+ case *event.MemberEventContent:
+ return ParseMembershipEvent(room, evt)
+ default:
+ debug.Printf("Unknown event content type %T in directParseEvent", content)
+ return nil
+ }
+}
+
+func findAltAliasDifference(newList, oldList []id.RoomAlias) (addedStr, removedStr tstring.TString) {
+ var addedList, removedList []tstring.TString
+OldLoop:
+ for _, oldAlias := range oldList {
+ for _, newAlias := range newList {
+ if oldAlias == newAlias {
+ continue OldLoop
+ }
+ }
+ removedList = append(removedList, tstring.NewStyleTString(string(oldAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(oldAlias)).Underline(true)))
+ }
+NewLoop:
+ for _, newAlias := range newList {
+ for _, oldAlias := range oldList {
+ if newAlias == oldAlias {
+ continue NewLoop
+ }
+ }
+ addedList = append(addedList, tstring.NewStyleTString(string(newAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(newAlias)).Underline(true)))
+ }
+ if len(addedList) == 1 {
+ addedStr = tstring.NewColorTString("added alternative address ", tcell.ColorGreen).AppendTString(addedList[0])
+ } else if len(addedList) != 0 {
+ addedStr = tstring.
+ Join(addedList[:len(addedList)-1], ", ").
+ PrependColor("added alternative addresses ", tcell.ColorGreen).
+ AppendColor(" and ", tcell.ColorGreen).
+ AppendTString(addedList[len(addedList)-1])
+ }
+ if len(removedList) == 1 {
+ removedStr = tstring.NewColorTString("removed alternative address ", tcell.ColorGreen).AppendTString(removedList[0])
+ } else if len(removedList) != 0 {
+ removedStr = tstring.
+ Join(removedList[:len(removedList)-1], ", ").
+ PrependColor("removed alternative addresses ", tcell.ColorGreen).
+ AppendColor(" and ", tcell.ColorGreen).
+ AppendTString(removedList[len(removedList)-1])
+ }
+ return
+}
+
+func ParseStateEvent(evt *muksevt.Event, displayname string) *UIMessage {
+ text := tstring.NewColorTString(displayname, widget.GetHashColor(evt.Sender)).Append(" ")
+ switch content := evt.Content.Parsed.(type) {
+ case *event.TopicEventContent:
+ if len(content.Topic) == 0 {
+ text = text.AppendColor("removed the topic.", tcell.ColorGreen)
+ } else {
+ text = text.AppendColor("changed the topic to ", tcell.ColorGreen).
+ AppendStyle(content.Topic, tcell.StyleDefault.Underline(true)).
+ AppendColor(".", tcell.ColorGreen)
+ }
+ case *event.RoomNameEventContent:
+ if len(content.Name) == 0 {
+ text = text.AppendColor("removed the room name.", tcell.ColorGreen)
+ } else {
+ text = text.AppendColor("changed the room name to ", tcell.ColorGreen).
+ AppendStyle(content.Name, tcell.StyleDefault.Underline(true)).
+ AppendColor(".", tcell.ColorGreen)
+ }
+ case *event.CanonicalAliasEventContent:
+ prevContent := &event.CanonicalAliasEventContent{}
+ if evt.Unsigned.PrevContent != nil {
+ _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
+ prevContent = evt.Unsigned.PrevContent.AsCanonicalAlias()
+ }
+ debug.Printf("%+v -> %+v", prevContent, content)
+ if len(content.Alias) == 0 && len(prevContent.Alias) != 0 {
+ text = text.AppendColor("removed the main address of the room", tcell.ColorGreen)
+ } else if content.Alias != prevContent.Alias {
+ text = text.
+ AppendColor("changed the main address of the room to ", tcell.ColorGreen).
+ AppendStyle(string(content.Alias), tcell.StyleDefault.Underline(true))
+ } else {
+ added, removed := findAltAliasDifference(content.AltAliases, prevContent.AltAliases)
+ if len(added) > 0 {
+ if len(removed) > 0 {
+ text = text.
+ AppendTString(added).
+ AppendColor(" and ", tcell.ColorGreen).
+ AppendTString(removed)
+ } else {
+ text = text.AppendTString(added)
+ }
+ } else if len(removed) > 0 {
+ text = text.AppendTString(removed)
+ } else {
+ text = text.AppendColor("changed nothing", tcell.ColorGreen)
+ }
+ text = text.AppendColor(" for this room", tcell.ColorGreen)
+ }
+ }
+ return NewExpandedTextMessage(evt, displayname, text)
+}
+
+func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event, displayname string) *UIMessage {
+ content := evt.Content.AsMessage()
+ if len(content.GetReplyTo()) > 0 {
+ content.RemoveReplyFallback()
+ }
+ if len(evt.Gomuks.Edits) > 0 {
+ newContent := evt.Gomuks.Edits[len(evt.Gomuks.Edits)-1].Content.AsMessage().NewContent
+ if newContent != nil {
+ content = newContent
+ }
+ }
+ switch content.MsgType {
+ case event.MsgText, event.MsgNotice, event.MsgEmote:
+ var htmlEntity html.Entity
+ if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
+ htmlEntity = html.Parse(matrix.Preferences(), room, content, evt, displayname)
+ if htmlEntity == nil {
+ htmlEntity = html.NewTextEntity("Malformed message")
+ htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal)
+ }
+ } else if len(content.Body) > 0 {
+ content.Body = strings.Replace(content.Body, "\t", " ", -1)
+ htmlEntity = html.TextToEntity(content.Body, evt.ID, matrix.Preferences().EnableInlineURLs())
+ } else {
+ htmlEntity = html.NewTextEntity("Blank message")
+ htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal)
+ }
+ return NewHTMLMessage(evt, displayname, htmlEntity)
+ case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
+ msg := NewFileMessage(matrix, evt, displayname)
+ if !matrix.Preferences().DisableDownloads {
+ renderer := msg.Renderer.(*FileMessage)
+ renderer.DownloadPreview()
+ }
+ return msg
+ }
+ return nil
+}
+
+func getMembershipChangeMessage(evt *muksevt.Event, content *event.MemberEventContent, prevMembership event.Membership, senderDisplayname, displayname, prevDisplayname string) (sender string, text tstring.TString) {
+ switch content.Membership {
+ case "invite":
+ sender = "---"
+ text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", senderDisplayname, displayname), tcell.ColorGreen)
+ text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
+ text.Colorize(len(senderDisplayname)+len(" invited "), len(displayname), widget.GetHashColor(evt.StateKey))
+ case "join":
+ sender = "-->"
+ if prevMembership == event.MembershipInvite {
+ text = tstring.NewColorTString(fmt.Sprintf("%s accepted the invite.", displayname), tcell.ColorGreen)
+ } else {
+ text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen)
+ }
+ text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey))
+ case "leave":
+ sender = "<--"
+ if evt.Sender != id.UserID(*evt.StateKey) {
+ if prevMembership == event.MembershipBan {
+ text = tstring.NewColorTString(fmt.Sprintf("%s unbanned %s", senderDisplayname, displayname), tcell.ColorGreen)
+ text.Colorize(len(senderDisplayname)+len(" unbanned "), len(displayname), widget.GetHashColor(evt.StateKey))
+ } else {
+ text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed)
+ text.Colorize(len(senderDisplayname)+len(" kicked "), len(displayname), widget.GetHashColor(evt.StateKey))
+ }
+ text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
+ } else {
+ if displayname == *evt.StateKey {
+ displayname = prevDisplayname
+ }
+ if prevMembership == event.MembershipInvite {
+ text = tstring.NewColorTString(fmt.Sprintf("%s rejected the invite.", displayname), tcell.ColorRed)
+ } else {
+ text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed)
+ }
+ text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey))
+ }
+ case "ban":
+ text = tstring.NewColorTString(fmt.Sprintf("%s banned %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed)
+ text.Colorize(len(senderDisplayname)+len(" banned "), len(displayname), widget.GetHashColor(evt.StateKey))
+ text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender))
+ }
+ return
+}
+
+func getMembershipEventContent(room *rooms.Room, evt *muksevt.Event) (sender string, text tstring.TString) {
+ member := room.GetMember(evt.Sender)
+ senderDisplayname := string(evt.Sender)
+ if member != nil {
+ senderDisplayname = member.Displayname
+ }
+
+ content := evt.Content.AsMember()
+ displayname := content.Displayname
+ if len(displayname) == 0 {
+ displayname = *evt.StateKey
+ }
+
+ prevMembership := event.MembershipLeave
+ prevDisplayname := *evt.StateKey
+ if evt.Unsigned.PrevContent != nil {
+ _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type)
+ prevContent := evt.Unsigned.PrevContent.AsMember()
+ prevMembership = prevContent.Membership
+ prevDisplayname = prevContent.Displayname
+ if len(prevDisplayname) == 0 {
+ prevDisplayname = *evt.StateKey
+ }
+ }
+
+ if content.Membership != prevMembership {
+ sender, text = getMembershipChangeMessage(evt, content, prevMembership, senderDisplayname, displayname, prevDisplayname)
+ } else if displayname != prevDisplayname {
+ sender = "---"
+ color := widget.GetHashColor(evt.StateKey)
+ text = tstring.NewBlankTString().
+ AppendColor(prevDisplayname, color).
+ AppendColor(" changed their display name to ", tcell.ColorGreen).
+ AppendColor(displayname, color).
+ AppendColor(".", tcell.ColorGreen)
+ }
+ return
+}
+
+func ParseMembershipEvent(room *rooms.Room, evt *muksevt.Event) *UIMessage {
+ displayname, text := getMembershipEventContent(room, evt)
+ if len(text) == 0 {
+ return nil
+ }
+
+ return NewExpandedTextMessage(evt, displayname, text)
+}
diff --git a/tui/messages/redactedmessage.go b/tui/messages/redactedmessage.go
new file mode 100644
index 0000000..497ede9
--- /dev/null
+++ b/tui/messages/redactedmessage.go
@@ -0,0 +1,66 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+ /* "maunium.net/go/gomuks/matrix/muksevt"
+
+ "maunium.net/go/gomuks/config"
+ */)
+
+type RedactedMessage struct{}
+
+func NewRedactedMessage(evt *muksevt.Event, displayname string) *UIMessage {
+ return newUIMessage(evt, displayname, &RedactedMessage{})
+}
+
+func (msg *RedactedMessage) Clone() MessageRenderer {
+ return &RedactedMessage{}
+}
+
+func (msg *RedactedMessage) NotificationContent() string {
+ return ""
+}
+
+func (msg *RedactedMessage) PlainText() string {
+ return "[redacted]"
+}
+
+func (msg *RedactedMessage) String() string {
+ return "&messages.RedactedMessage{}"
+}
+
+func (msg *RedactedMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) {
+}
+
+func (msg *RedactedMessage) Height() int {
+ return 1
+}
+
+const RedactionChar = '█'
+const RedactionMaxWidth = 40
+
+var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0))
+
+func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) {
+ w, _ := screen.Size()
+ for x := 0; x < w && x < RedactionMaxWidth; x++ {
+ screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle)
+ }
+}
diff --git a/tui/messages/textbase.go b/tui/messages/textbase.go
new file mode 100644
index 0000000..8f8da60
--- /dev/null
+++ b/tui/messages/textbase.go
@@ -0,0 +1,96 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package messages
+
+import (
+ "fmt"
+ "regexp"
+ /*
+ "maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/tui/messages/tstring"
+ */)
+
+// Regular expressions used to split lines when calculating the buffer.
+//
+// From tview/textview.go
+var (
+ boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`)
+ bareBoundaryPattern = regexp.MustCompile(`(\s+)`)
+ spacePattern = regexp.MustCompile(`\s+`)
+)
+
+func matchBoundaryPattern(bare bool, extract tstring.TString) tstring.TString {
+ regex := boundaryPattern
+ if bare {
+ regex = bareBoundaryPattern
+ }
+ matches := regex.FindAllStringIndex(extract.String(), -1)
+ if len(matches) > 0 {
+ if match := matches[len(matches)-1]; len(match) >= 2 {
+ if until := match[1]; until < len(extract) {
+ extract = extract[:until]
+ }
+ }
+ }
+ return extract
+}
+
+// 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 calculateBufferWithText(prefs config.UserPreferences, text tstring.TString, width int, msg *UIMessage) []tstring.TString {
+ if width < 2 {
+ return nil
+ }
+
+ var buffer []tstring.TString
+
+ if prefs.BareMessageView {
+ newText := tstring.NewTString(msg.FormatTime())
+ if len(msg.Sender()) > 0 {
+ newText = newText.AppendTString(tstring.NewColorTString(fmt.Sprintf(" <%s> ", msg.Sender()), msg.SenderColor()))
+ } else {
+ newText = newText.Append(" ")
+ }
+ newText = newText.AppendTString(text)
+ text = newText
+ }
+
+ forcedLinebreaks := text.Split('\n')
+ newlines := 0
+ for _, str := range forcedLinebreaks {
+ if len(str) == 0 && newlines < 1 {
+ buffer = append(buffer, tstring.TString{})
+ newlines++
+ } else {
+ newlines = 0
+ }
+ // Adapted 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]]
+ }
+ extract = matchBoundaryPattern(prefs.BareMessageView, extract)
+ }
+ buffer = append(buffer, extract)
+ str = str[len(extract):]
+ }
+ }
+ return buffer
+}
diff --git a/tui/messages/tstring/cell.go b/tui/messages/tstring/cell.go
new file mode 100644
index 0000000..affa01c
--- /dev/null
+++ b/tui/messages/tstring/cell.go
@@ -0,0 +1,53 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tstring
+
+import (
+ "github.com/mattn/go-runewidth"
+
+ "go.mau.fi/mauview"
+ "github.com/gdamore/tcell/v2"
+)
+
+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 mauview.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/tui/messages/tstring/doc.go b/tui/messages/tstring/doc.go
new file mode 100644
index 0000000..d03a1da
--- /dev/null
+++ b/tui/messages/tstring/doc.go
@@ -0,0 +1,4 @@
+// Package tstring contains a string type that stores style data for each
+// character, allowing it to be rendered to a tcell screen essentially
+// unmodified.
+package tstring
diff --git a/tui/messages/tstring/string.go b/tui/messages/tstring/string.go
new file mode 100644
index 0000000..ddba2da
--- /dev/null
+++ b/tui/messages/tstring/string.go
@@ -0,0 +1,270 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tstring
+
+import (
+ "strings"
+ "unicode"
+
+ "github.com/mattn/go-runewidth"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+type TString []Cell
+
+func NewBlankTString() TString {
+ return make(TString, 0)
+}
+
+func NewTString(str string) TString {
+ newStr := make(TString, len(str))
+ for i, char := range str {
+ newStr[i] = NewCell(char)
+ }
+ return newStr
+}
+
+func NewColorTString(str string, color tcell.Color) TString {
+ newStr := make(TString, len(str))
+ for i, char := range str {
+ newStr[i] = NewColorCell(char, color)
+ }
+ return newStr
+}
+
+func NewStyleTString(str string, style tcell.Style) TString {
+ newStr := make(TString, len(str))
+ for i, char := range str {
+ newStr[i] = NewStyleCell(char, style)
+ }
+ return newStr
+}
+
+func Join(strings []TString, separator string) TString {
+ if len(strings) == 0 {
+ return NewBlankTString()
+ }
+
+ out := strings[0]
+ strings = strings[1:]
+
+ if len(separator) == 0 {
+ return out.AppendTString(strings...)
+ }
+
+ for _, str := range strings {
+ out = append(out, str.Prepend(separator)...)
+ }
+ return out
+}
+
+func (str TString) Clone() TString {
+ newStr := make(TString, len(str))
+ copy(newStr, str)
+ return newStr
+}
+
+func (str TString) AppendTString(dataList ...TString) TString {
+ newStr := str
+ for _, data := range dataList {
+ newStr = append(newStr, data...)
+ }
+ return newStr
+}
+
+func (str TString) PrependTString(data TString) TString {
+ return append(data, str...)
+}
+
+func (str TString) Append(data string) TString {
+ return str.AppendCustom(data, func(r rune) Cell {
+ return NewCell(r)
+ })
+}
+
+func (str TString) TrimSpace() TString {
+ return str.Trim(unicode.IsSpace)
+}
+
+func (str TString) Trim(fn func(rune) bool) TString {
+ return str.TrimLeft(fn).TrimRight(fn)
+}
+
+func (str TString) TrimLeft(fn func(rune) bool) TString {
+ for index, cell := range str {
+ if !fn(cell.Char) {
+ return append(NewBlankTString(), str[index:]...)
+ }
+ }
+ return NewBlankTString()
+}
+
+func (str TString) TrimRight(fn func(rune) bool) TString {
+ for i := len(str) - 1; i >= 0; i-- {
+ if !fn(str[i].Char) {
+ return append(NewBlankTString(), str[:i+1]...)
+ }
+ }
+ return NewBlankTString()
+}
+
+func (str TString) AppendColor(data string, color tcell.Color) TString {
+ return str.AppendCustom(data, func(r rune) Cell {
+ return NewColorCell(r, color)
+ })
+}
+
+func (str TString) AppendStyle(data string, style tcell.Style) TString {
+ return str.AppendCustom(data, func(r rune) Cell {
+ return NewStyleCell(r, style)
+ })
+}
+
+func (str TString) AppendCustom(data string, cellCreator func(rune) Cell) TString {
+ newStr := make(TString, len(str)+len(data))
+ copy(newStr, str)
+ for i, char := range data {
+ newStr[i+len(str)] = cellCreator(char)
+ }
+ return newStr
+}
+
+func (str TString) Prepend(data string) TString {
+ return str.PrependCustom(data, func(r rune) Cell {
+ return NewCell(r)
+ })
+}
+
+func (str TString) PrependColor(data string, color tcell.Color) TString {
+ return str.PrependCustom(data, func(r rune) Cell {
+ return NewColorCell(r, color)
+ })
+}
+
+func (str TString) PrependStyle(data string, style tcell.Style) TString {
+ return str.PrependCustom(data, func(r rune) Cell {
+ return NewStyleCell(r, style)
+ })
+}
+
+func (str TString) PrependCustom(data string, cellCreator func(rune) Cell) TString {
+ newStr := make(TString, len(str)+len(data))
+ copy(newStr[len(data):], str)
+ for i, char := range data {
+ newStr[i] = cellCreator(char)
+ }
+ return newStr
+}
+
+func (str TString) Colorize(from, length int, color tcell.Color) {
+ str.AdjustStyle(from, length, func(style tcell.Style) tcell.Style {
+ return style.Foreground(color)
+ })
+}
+
+func (str TString) AdjustStyle(from, length int, fn func(tcell.Style) tcell.Style) {
+ for i := from; i < from+length; i++ {
+ str[i].Style = fn(str[i].Style)
+ }
+}
+
+func (str TString) AdjustStyleFull(fn func(tcell.Style) tcell.Style) {
+ str.AdjustStyle(0, len(str), fn)
+}
+
+func (str TString) Draw(screen mauview.Screen, x, y int) {
+ for _, cell := range str {
+ x += cell.Draw(screen, x, 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/tui/no-crypto-commands.go b/tui/no-crypto-commands.go
new file mode 100644
index 0000000..f559f28
--- /dev/null
+++ b/tui/no-crypto-commands.go
@@ -0,0 +1,46 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build !cgo
+
+package tui
+
+func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
+ return []string{}, ""
+}
+
+func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) {
+ return []string{}, ""
+}
+
+func cmdNoCrypto(cmd *Command) {
+ cmd.Reply("This gomuks was built without encryption support")
+}
+
+var (
+ cmdDevices = cmdNoCrypto
+ cmdDevice = cmdNoCrypto
+ cmdVerifyDevice = cmdNoCrypto
+ cmdVerify = cmdNoCrypto
+ cmdUnverify = cmdNoCrypto
+ cmdBlacklist = cmdNoCrypto
+ cmdResetSession = cmdNoCrypto
+ cmdImportKeys = cmdNoCrypto
+ cmdExportKeys = cmdNoCrypto
+ cmdExportRoomKeys = cmdNoCrypto
+ cmdSSSS = cmdNoCrypto
+ cmdCrossSigning = cmdNoCrypto
+)
diff --git a/tui/password-modal.go b/tui/password-modal.go
new file mode 100644
index 0000000..a86a2d1
--- /dev/null
+++ b/tui/password-modal.go
@@ -0,0 +1,143 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+type PasswordModal struct {
+ mauview.Component
+
+ outputChan chan string
+ cancelChan chan struct{}
+
+ form *mauview.Form
+
+ text *mauview.TextField
+ confirmText *mauview.TextField
+
+ input *mauview.InputField
+ confirmInput *mauview.InputField
+
+ cancel *mauview.Button
+ submit *mauview.Button
+
+ parent *MainView
+}
+
+func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) {
+ pwm := NewPasswordModal(view, title, thing, placeholder, isNew)
+ view.ShowModal(pwm)
+ view.parent.App.Redraw()
+ return pwm.Wait()
+}
+
+func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal {
+ if placeholder == "" {
+ placeholder = "correct horse battery staple"
+ }
+ if thing == "" {
+ thing = strings.ToLower(title)
+ }
+ pwm := &PasswordModal{
+ parent: parent,
+ form: mauview.NewForm(),
+ outputChan: make(chan string, 1),
+ cancelChan: make(chan struct{}, 1),
+ }
+
+ pwm.form.
+ SetColumns([]int{1, 20, 1, 20, 1}).
+ SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1})
+
+ width := 45
+ height := 8
+
+ pwm.text = mauview.NewTextField()
+ if isNew {
+ pwm.text.SetText(fmt.Sprintf("Create a %s", thing))
+ } else {
+ pwm.text.SetText(fmt.Sprintf("Enter the %s", thing))
+ }
+ pwm.input = mauview.NewInputField().
+ SetMaskCharacter('*').
+ SetPlaceholder(placeholder)
+ pwm.form.AddComponent(pwm.text, 1, 1, 3, 1)
+ pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1)
+
+ if isNew {
+ height += 3
+ pwm.confirmInput = mauview.NewInputField().
+ SetMaskCharacter('*').
+ SetPlaceholder(placeholder).
+ SetChangedFunc(pwm.HandleChange)
+ pwm.input.SetChangedFunc(pwm.HandleChange)
+ pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing))
+
+ pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1)
+ pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1)
+ pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1)
+ }
+
+ pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel)
+ pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit)
+
+ pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1)
+ pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1)
+
+ box := mauview.NewBox(pwm.form).SetTitle(title)
+ center := mauview.Center(box, width, height).SetAlwaysFocusChild(true)
+ center.Focus()
+ pwm.form.FocusNextItem()
+ pwm.Component = center
+
+ return pwm
+}
+
+func (pwm *PasswordModal) HandleChange(_ string) {
+ if pwm.input.GetText() == pwm.confirmInput.GetText() {
+ pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor)
+ } else {
+ pwm.submit.SetBackgroundColor(tcell.ColorDefault)
+ }
+}
+
+func (pwm *PasswordModal) ClickCancel() {
+ pwm.parent.HideModal()
+ pwm.cancelChan <- struct{}{}
+}
+
+func (pwm *PasswordModal) ClickSubmit() {
+ if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() {
+ pwm.parent.HideModal()
+ pwm.outputChan <- pwm.input.GetText()
+ }
+}
+
+func (pwm *PasswordModal) Wait() (string, bool) {
+ select {
+ case result := <-pwm.outputChan:
+ return result, true
+ case <-pwm.cancelChan:
+ return "", false
+ }
+}
diff --git a/tui/rainbow.go b/tui/rainbow.go
new file mode 100644
index 0000000..82c4b88
--- /dev/null
+++ b/tui/rainbow.go
@@ -0,0 +1,135 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2022 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "fmt"
+ "crypto/rand"
+ "unicode"
+
+ "github.com/rivo/uniseg"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/util"
+)
+
+func Rand(n int) (str string) {
+ b := make([]byte, n)
+ rand.Read(b)
+ str = fmt.Sprintf("%x", b)
+ return
+}
+
+type extRainbow struct{}
+type rainbowRenderer struct {
+ HardWraps bool
+ ColorID string
+}
+
+var ExtensionRainbow = &extRainbow{}
+var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: Rand(16)}
+
+func (er *extRainbow) Extend(m goldmark.Markdown) {
+ m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0)))
+}
+
+func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindText, rb.renderText)
+ reg.Register(ast.KindString, rb.renderString)
+}
+
+type rainbowBufWriter struct {
+ util.BufWriter
+ ColorID string
+}
+
+func (rbw rainbowBufWriter) WriteString(s string) (int, error) {
+ i := 0
+ graphemes := uniseg.NewGraphemes(s)
+ for graphemes.Next() {
+ runes := graphemes.Runes()
+ if len(runes) == 1 && unicode.IsSpace(runes[0]) {
+ i2, err := rbw.BufWriter.WriteRune(runes[0])
+ i += i2
+ if err != nil {
+ return i, err
+ }
+ continue
+ }
+ i2, err := fmt.Fprintf(rbw.BufWriter, "%s", rbw.ColorID, graphemes.Str())
+ i += i2
+ if err != nil {
+ return i, err
+ }
+ }
+ return i, nil
+}
+
+func (rbw rainbowBufWriter) Write(data []byte) (int, error) {
+ return rbw.WriteString(string(data))
+}
+
+func (rbw rainbowBufWriter) WriteByte(c byte) error {
+ _, err := rbw.WriteRune(rune(c))
+ return err
+}
+
+func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) {
+ if unicode.IsSpace(r) {
+ return rbw.BufWriter.WriteRune(r)
+ } else {
+ return fmt.Fprintf(rbw.BufWriter, "%c", rbw.ColorID, r)
+ }
+}
+
+func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ n := node.(*ast.Text)
+ segment := n.Segment
+ if n.IsRaw() {
+ html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
+ } else {
+ html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
+ if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) {
+ _, _ = w.WriteString("
\n")
+ } else if n.SoftLineBreak() {
+ _ = w.WriteByte('\n')
+ }
+ }
+ return ast.WalkContinue, nil
+}
+
+func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ n := node.(*ast.String)
+ if n.IsCode() {
+ _, _ = w.Write(n.Value)
+ } else {
+ if n.IsRaw() {
+ html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value)
+ } else {
+ html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value)
+ }
+ }
+ return ast.WalkContinue, nil
+}
diff --git a/tui/room-list.go b/tui/room-list.go
index f7136f9..3d54404 100644
--- a/tui/room-list.go
+++ b/tui/room-list.go
@@ -22,15 +22,12 @@ import (
"sort"
"strings"
- sync "github.com/sasha-s/go-deadlock"
-
+ "github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
- "go.mau.fi/tcell"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
- "maunium.net/go/gomuks/matrix/rooms"
)
var tagOrder = map[string]int{
diff --git a/tui/room-view.go b/tui/room-view.go
index 1cf05e0..6d33a05 100644
--- a/tui/room-view.go
+++ b/tui/room-view.go
@@ -23,29 +23,21 @@ import (
"time"
"unicode"
- "github.com/kyokomi/emoji/v2"
"github.com/mattn/go-runewidth"
"github.com/zyedidia/clipboard"
+ "github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
- "go.mau.fi/tcell"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
- "maunium.net/go/mautrix/util/variationselector"
- "maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
- ifc "maunium.net/go/gomuks/interface"
- "maunium.net/go/gomuks/lib/open"
- "maunium.net/go/gomuks/lib/util"
- "maunium.net/go/gomuks/matrix/muksevt"
- "maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/messages"
- "maunium.net/go/gomuks/ui/widget"
+ "maunium.net/go/gomuks/tui/messages"
+ "maunium.net/go/gomuks/tui/widget"
)
type RoomView struct {
@@ -106,7 +98,6 @@ func NewRoomView(parent *MainView, room *rooms.Room) *RoomView {
ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth},
parent: parent,
- config: parent.config,
}
view.content = NewMessageView(view)
view.Room.SetPreUnload(func() bool {
@@ -338,6 +329,7 @@ func (view *RoomView) ClearAllContext() {
func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool {
msgView := view.MessageView()
+ //helpp
kb := config.Keybind{
Key: event.Key(),
Ch: event.Rune(),
@@ -711,11 +703,11 @@ func (view *RoomView) CopyToClipboard(text string, register string) {
err := clipboard.WriteAll(text, register)
if err != nil {
view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
}
} else {
view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
}
}
@@ -723,11 +715,11 @@ func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile
path, err := view.parent.matrix.DownloadToDisk(url, file, filename)
if err != nil {
view.AddServiceMessage(fmt.Sprintf("Failed to download media: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
return
}
view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
if openFile {
debug.Print("Opening file", path)
open.Open(path)
@@ -745,7 +737,7 @@ func (view *RoomView) Redact(eventID id.EventID, reason string) {
}
}
view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
}
}
@@ -775,7 +767,7 @@ func (view *RoomView) SendReaction(eventID id.EventID, reaction string) {
}
}
view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
}
}
@@ -816,7 +808,7 @@ func (view *RoomView) SendMessageMedia(path string) {
evt, err := view.parent.matrix.PrepareMediaMessage(view.Room, path, rel)
if err != nil {
view.AddServiceMessage(fmt.Sprintf("Failed to upload media: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
return
}
view.addLocalEcho(evt)
@@ -838,13 +830,13 @@ func (view *RoomView) addLocalEcho(evt *muksevt.Event) {
}
}
view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err))
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
} else {
debug.Print("Event ID received:", eventID)
msg.EventID = eventID
msg.State = muksevt.StateDefault
view.MessageView().setMessageID(msg)
- view.parent.parent.Render()
+ view.parent.parent.App.Redraw()
}
}
diff --git a/tui/syncing-modal.go b/tui/syncing-modal.go
index 591b7e7..882209d 100644
--- a/tui/syncing-modal.go
+++ b/tui/syncing-modal.go
@@ -51,15 +51,15 @@ func (sm *SyncingModal) SetMessage(text string) {
func (sm *SyncingModal) SetIndeterminate() {
sm.progress.SetIndeterminate(true)
- sm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond)
- sm.parent.parent.app.Redraw()
+ sm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond)
+ sm.parent.parent.App.Redraw()
}
func (sm *SyncingModal) SetSteps(max int) {
sm.progress.SetMax(max)
sm.progress.SetIndeterminate(false)
- sm.parent.parent.app.SetRedrawTicker(1 * time.Minute)
- sm.parent.parent.Render()
+ sm.parent.parent.App.SetRedrawTicker(1 * time.Minute)
+ sm.parent.parent.App.Redraw()
}
func (sm *SyncingModal) Step() {
diff --git a/tui/tag-room-list.go b/tui/tag-room-list.go
new file mode 100644
index 0000000..521b38d
--- /dev/null
+++ b/tui/tag-room-list.go
@@ -0,0 +1,329 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package tui
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+
+ "maunium.net/go/gomuks/debug"
+
+)
+
+type OrderedRoom struct {
+ *rooms.Room
+ order float64
+}
+
+func NewOrderedRoom(order json.Number, room *rooms.Room) *OrderedRoom {
+ numOrder, err := order.Float64()
+ if err != nil {
+ numOrder = 0.5
+ }
+ return &OrderedRoom{
+ Room: room,
+ order: numOrder,
+ }
+}
+
+func NewDefaultOrderedRoom(room *rooms.Room) *OrderedRoom {
+ return NewOrderedRoom("0.5", room)
+}
+
+func (or *OrderedRoom) Draw(roomList *RoomList, screen mauview.Screen, x, y, lineWidth int, isSelected bool) {
+ style := tcell.StyleDefault.
+ Foreground(roomList.mainTextColor).
+ Bold(or.HasNewMessages())
+ if isSelected {
+ style = style.
+ Foreground(roomList.selectedTextColor).
+ Background(roomList.selectedBackgroundColor)
+ }
+
+ unreadCount := or.UnreadCount()
+
+ widget.WriteLinePadded(screen, mauview.AlignLeft, or.GetTitle(), x, y, lineWidth, style)
+
+ if unreadCount > 0 {
+ unreadMessageCount := "99+"
+ if unreadCount < 100 {
+ unreadMessageCount = strconv.Itoa(unreadCount)
+ }
+ if or.Highlighted() {
+ unreadMessageCount += "!"
+ }
+ unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount)
+ widget.WriteLine(screen, mauview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style)
+ lineWidth -= len(unreadMessageCount)
+ }
+}
+
+type TagRoomList struct {
+ mauview.NoopEventHandler
+ // The list of rooms in the list, in reverse order
+ rooms []*OrderedRoom
+ // Maximum number of rooms to show
+ maxShown int
+ // The internal name of this tag
+ name string
+ // The displayname of this tag
+ displayname string
+ // The parent RoomList instance
+ parent *RoomList
+}
+
+func NewTagRoomList(parent *RoomList, name string, rooms ...*OrderedRoom) *TagRoomList {
+ return &TagRoomList{
+ maxShown: 10,
+ rooms: rooms,
+ name: name,
+ displayname: parent.GetTagDisplayName(name),
+ parent: parent,
+ }
+}
+
+func (trl *TagRoomList) Visible() []*OrderedRoom {
+ return trl.rooms[len(trl.rooms)-trl.Length():]
+}
+
+func (trl *TagRoomList) FirstVisible() *rooms.Room {
+ visible := trl.Visible()
+ if len(visible) > 0 {
+ return visible[len(visible)-1].Room
+ }
+ return nil
+}
+
+func (trl *TagRoomList) LastVisible() *rooms.Room {
+ visible := trl.Visible()
+ if len(visible) > 0 {
+ return visible[0].Room
+ }
+ return nil
+}
+
+func (trl *TagRoomList) All() []*OrderedRoom {
+ return trl.rooms
+}
+
+func (trl *TagRoomList) Length() int {
+ if len(trl.rooms) < trl.maxShown {
+ return len(trl.rooms)
+ }
+ return trl.maxShown
+}
+
+func (trl *TagRoomList) TotalLength() int {
+ return len(trl.rooms)
+}
+
+func (trl *TagRoomList) IsEmpty() bool {
+ return len(trl.rooms) == 0
+}
+
+func (trl *TagRoomList) IsCollapsed() bool {
+ return trl.maxShown == 0
+}
+
+func (trl *TagRoomList) ToggleCollapse() {
+ if trl.IsCollapsed() {
+ trl.maxShown = 10
+ } else {
+ trl.maxShown = 0
+ }
+}
+
+func (trl *TagRoomList) HasInvisibleRooms() bool {
+ return trl.maxShown < trl.TotalLength()
+}
+
+func (trl *TagRoomList) HasVisibleRooms() bool {
+ return !trl.IsEmpty() && trl.maxShown > 0
+}
+
+const equalityThreshold = 1e-6
+
+func almostEqual(a, b float64) bool {
+ return math.Abs(a-b) <= equalityThreshold
+}
+
+// ShouldBeAfter returns if the first room should be after the second room in the room list.
+// The manual order and last received message timestamp are considered.
+func (trl *TagRoomList) ShouldBeAfter(room1 *OrderedRoom, room2 *OrderedRoom) bool {
+ // Lower order value = higher in list
+ return room1.order > room2.order ||
+ // Equal order value and more recent message = higher in the list
+ (almostEqual(room1.order, room2.order) && room2.LastReceivedMessage.After(room1.LastReceivedMessage))
+}
+
+func (trl *TagRoomList) Insert(order json.Number, mxRoom *rooms.Room) {
+ room := NewOrderedRoom(order, mxRoom)
+ // The default insert index is the newly added slot.
+ // That index will be used if all other rooms in the list have the same LastReceivedMessage timestamp.
+ insertAt := len(trl.rooms)
+ // Find the spot where the new room should be put according to the last received message timestamps.
+ for i := 0; i < len(trl.rooms); i++ {
+ if trl.rooms[i].Room == mxRoom {
+ debug.Printf("Warning: tried to re-insert room %s into tag %s", mxRoom.ID, trl.name)
+ return
+ } else if trl.ShouldBeAfter(room, trl.rooms[i]) {
+ insertAt = i
+ break
+ }
+ }
+ trl.rooms = append(trl.rooms, nil)
+ copy(trl.rooms[insertAt+1:], trl.rooms[insertAt:len(trl.rooms)-1])
+ trl.rooms[insertAt] = room
+}
+
+func (trl *TagRoomList) Bump(mxRoom *rooms.Room) {
+ var roomBeingBumped *OrderedRoom
+ for i := 0; i < len(trl.rooms); i++ {
+ currentIndexRoom := trl.rooms[i]
+ if roomBeingBumped != nil {
+ if trl.ShouldBeAfter(roomBeingBumped, currentIndexRoom) {
+ // This room should be after the room being bumped, so insert the
+ // room being bumped here and return
+ trl.rooms[i-1] = roomBeingBumped
+ return
+ }
+ // Move older rooms back in the array
+ trl.rooms[i-1] = currentIndexRoom
+ } else if currentIndexRoom.Room == mxRoom {
+ roomBeingBumped = currentIndexRoom
+ }
+ }
+ if roomBeingBumped == nil {
+ debug.Print("Warning: couldn't find room", mxRoom.ID, mxRoom.NameCache, "to bump in tag", trl.name)
+ return
+ }
+ // If the room being bumped should be first in the list, it won't be inserted during the loop.
+ trl.rooms[len(trl.rooms)-1] = roomBeingBumped
+}
+
+func (trl *TagRoomList) Remove(room *rooms.Room) {
+ trl.RemoveIndex(trl.Index(room))
+}
+
+func (trl *TagRoomList) RemoveIndex(index int) {
+ if index < 0 || index > len(trl.rooms) {
+ return
+ }
+ last := len(trl.rooms) - 1
+ if index < last {
+ copy(trl.rooms[index:], trl.rooms[index+1:])
+ }
+ trl.rooms[last] = nil
+ trl.rooms = trl.rooms[:last]
+}
+
+func (trl *TagRoomList) Index(room *rooms.Room) int {
+ return trl.indexInList(trl.All(), room)
+}
+
+func (trl *TagRoomList) IndexVisible(room *rooms.Room) int {
+ return trl.indexInList(trl.Visible(), room)
+}
+
+func (trl *TagRoomList) indexInList(list []*OrderedRoom, room *rooms.Room) int {
+ for index, entry := range list {
+ if entry.Room == room {
+ return index
+ }
+ }
+ return -1
+}
+
+var TagDisplayNameStyle = tcell.StyleDefault.Underline(true).Bold(true)
+var TagRoomCountStyle = tcell.StyleDefault.Italic(true)
+
+func (trl *TagRoomList) RenderHeight() int {
+ if len(trl.displayname) == 0 {
+ return 0
+ }
+
+ if trl.IsCollapsed() {
+ return 1
+ }
+ height := 2 + trl.Length()
+ if trl.HasInvisibleRooms() || trl.maxShown > 10 {
+ height++
+ }
+ return height
+}
+
+func (trl *TagRoomList) DrawHeader(screen mauview.Screen) {
+ width, _ := screen.Size()
+ roomCount := strconv.Itoa(trl.TotalLength())
+
+ // Draw tag name
+ displayNameWidth := width - 1 - len(roomCount)
+ widget.WriteLine(screen, mauview.AlignLeft, trl.displayname, 0, 0, displayNameWidth, TagDisplayNameStyle)
+
+ // Draw tag room count
+ roomCountX := len(trl.displayname) + 1
+ roomCountWidth := width - 2 - len(trl.displayname)
+ widget.WriteLine(screen, mauview.AlignLeft, roomCount, roomCountX, 0, roomCountWidth, TagRoomCountStyle)
+}
+
+func (trl *TagRoomList) Draw(screen mauview.Screen) {
+ if len(trl.displayname) == 0 {
+ return
+ }
+
+ trl.DrawHeader(screen)
+
+ width, height := screen.Size()
+
+ items := trl.Visible()
+
+ if trl.IsCollapsed() {
+ screen.SetCell(width-1, 0, tcell.StyleDefault, '▶')
+ return
+ }
+ screen.SetCell(width-1, 0, tcell.StyleDefault, '▼')
+
+ y := 1
+ for i := len(items) - 1; i >= 0; i-- {
+ if y >= height {
+ return
+ }
+
+ item := items[i]
+
+ lineWidth := width
+ isSelected := trl.name == trl.parent.selectedTag && item.Room == trl.parent.selected
+ item.Draw(trl.parent, screen, 0, y, lineWidth, isSelected)
+ y++
+ }
+ hasLess := trl.maxShown > 10
+ hasMore := trl.HasInvisibleRooms()
+ if (hasLess || hasMore) && y < height {
+ if hasMore {
+ widget.WriteLine(screen, mauview.AlignRight, "More ↓", 0, y, width, tcell.StyleDefault)
+ }
+ if hasLess {
+ widget.WriteLine(screen, mauview.AlignLeft, "↑ Less", 0, y, width, tcell.StyleDefault)
+ }
+ y++
+ }
+}
diff --git a/tui/verification-modal.go b/tui/verification-modal.go
new file mode 100644
index 0000000..ce86329
--- /dev/null
+++ b/tui/verification-modal.go
@@ -0,0 +1,252 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//go:build cgo
+
+package tui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+
+ "maunium.net/go/mautrix/crypto"
+ "maunium.net/go/mautrix/event"
+
+ "maunium.net/go/gomuks/debug"
+)
+
+type EmojiView struct {
+ mauview.SimpleEventHandler
+ Data crypto.SASData
+}
+
+func (e *EmojiView) Draw(screen mauview.Screen) {
+ if e.Data == nil {
+ return
+ }
+ switch e.Data.Type() {
+ case event.SASEmoji:
+ width := 10
+ for i, emoji := range e.Data.(crypto.EmojiSASData) {
+ x := i*width + i
+ y := 0
+ if i >= 4 {
+ x = (i-4)*width + i
+ y = 2
+ }
+ mauview.Print(screen, string(emoji.Emoji), x, y, width, mauview.AlignCenter, tcell.ColorDefault)
+ mauview.Print(screen, emoji.Description, x, y+1, width, mauview.AlignCenter, tcell.ColorDefault)
+ }
+ case event.SASDecimal:
+ maxWidth := 43
+ for i, number := range e.Data.(crypto.DecimalSASData) {
+ mauview.Print(screen, strconv.FormatUint(uint64(number), 10), 0, i, maxWidth, mauview.AlignCenter, tcell.ColorDefault)
+ }
+ }
+}
+
+type VerificationModal struct {
+ mauview.Component
+
+ device *crypto.DeviceIdentity
+
+ container *mauview.Box
+
+ waitingBar *mauview.ProgressBar
+ infoText *mauview.TextView
+ emojiText *EmojiView
+ inputBar *mauview.InputField
+
+ progress int
+ progressMax int
+ stopWaiting chan struct{}
+ confirmChan chan bool
+ done bool
+
+ parent *MainView
+}
+
+func NewVerificationModal(mainView *MainView, device *crypto.DeviceIdentity, timeout time.Duration) *VerificationModal {
+ vm := &VerificationModal{
+ parent: mainView,
+ device: device,
+ stopWaiting: make(chan struct{}),
+ confirmChan: make(chan bool),
+ done: false,
+ }
+
+ vm.progressMax = int(timeout.Seconds())
+ vm.progress = vm.progressMax
+ vm.waitingBar = mauview.NewProgressBar().
+ SetMax(vm.progressMax).
+ SetProgress(vm.progress).
+ SetIndeterminate(false)
+
+ vm.infoText = mauview.NewTextView()
+ vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto accept", device.UserID))
+
+ vm.emojiText = &EmojiView{}
+
+ vm.inputBar = mauview.NewInputField().
+ SetBackgroundColor(tcell.ColorDefault).
+ SetPlaceholderTextColor(tcell.ColorDefault)
+
+ flex := mauview.NewFlex().
+ SetDirection(mauview.FlexRow).
+ AddFixedComponent(vm.waitingBar, 1).
+ AddFixedComponent(vm.infoText, 4).
+ AddFixedComponent(vm.emojiText, 4).
+ AddFixedComponent(vm.inputBar, 1)
+
+ vm.container = mauview.NewBox(flex).
+ SetBorder(true).
+ SetTitle("Interactive verification")
+
+ vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true)
+
+ go vm.decrementWaitingBar()
+
+ return vm
+}
+
+func (vm *VerificationModal) decrementWaitingBar() {
+ for {
+ select {
+ case <-time.Tick(time.Second):
+ if vm.progress <= 0 {
+ vm.waitingBar.SetIndeterminate(true)
+ vm.parent.parent.App.SetRedrawTicker(100 * time.Millisecond)
+ return
+ }
+ vm.progress--
+ vm.waitingBar.SetProgress(vm.progress)
+ vm.parent.parent.App.Redraw()
+ case <-vm.stopWaiting:
+ return
+ }
+ }
+}
+
+func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod {
+ return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}}
+}
+
+func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool {
+ vm.device = device
+ var typeName string
+ if data.Type() == event.SASDecimal {
+ typeName = "numbers"
+ } else if data.Type() == event.SASEmoji {
+ typeName = "emojis"
+ } else {
+ return false
+ }
+ vm.infoText.SetText(fmt.Sprintf(
+ "Check if the other device is showing the\n"+
+ "same %s as below, then type \"yes\" to\n"+
+ "accept, or \"no\" to reject", typeName))
+ vm.inputBar.
+ SetTextColor(tcell.ColorDefault).
+ SetBackgroundColor(tcell.ColorDarkCyan).
+ SetPlaceholder("Type \"yes\" or \"no\"").
+ Focus()
+ vm.emojiText.Data = data
+ vm.parent.parent.App.Redraw()
+ vm.progress = vm.progressMax
+ confirm := <-vm.confirmChan
+ vm.progress = vm.progressMax
+ vm.emojiText.Data = nil
+ vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID))
+ vm.parent.parent.App.Redraw()
+ return confirm
+}
+
+func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) {
+ vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
+ vm.parent.parent.app.SetRedrawTicker(1 * time.Minute)
+ if cancelledByUs {
+ vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason))
+ } else {
+ vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason))
+ }
+ vm.inputBar.SetPlaceholder("Press enter to close the dialog")
+ vm.stopWaiting <- struct{}{}
+ vm.done = true
+ vm.parent.parent.App.Redraw()
+}
+
+func (vm *VerificationModal) OnSuccess() {
+ vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
+ vm.parent.parent.App.SetRedrawTicker(1 * time.Minute)
+ vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID))
+ vm.inputBar.SetPlaceholder("Press enter to close the dialog")
+ vm.stopWaiting <- struct{}{}
+ vm.done = true
+ vm.parent.parent.App.Redraw()
+ if vm.parent.config.SendToVerifiedOnly {
+ // Hacky way to make new group sessions after verified
+ vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID)
+ }
+}
+
+func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool {
+ kb := config.Keybind{
+ Key: event.Key(),
+ Ch: event.Rune(),
+ Mod: event.Modifiers(),
+ }
+ if vm.done {
+ if vm.parent.config.Keybindings.Modal[kb] == "cancel" || vm.parent.config.Keybindings.Modal[kb] == "confirm" {
+ vm.parent.HideModal()
+ return true
+ }
+ return false
+ } else if vm.emojiText.Data == nil {
+ debug.Print("Ignoring pre-emoji key event")
+ return false
+ }
+ if vm.parent.config.Keybindings.Modal[kb] == "confirm" {
+ text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText()))
+ if text == "yes" {
+ debug.Print("Confirming verification")
+ vm.confirmChan <- true
+ } else if text == "no" {
+ debug.Print("Rejecting verification")
+ vm.confirmChan <- false
+ }
+ vm.inputBar.
+ SetPlaceholder("").
+ SetTextAndMoveCursor("").
+ SetBackgroundColor(tcell.ColorDefault).
+ SetTextColor(tcell.ColorDefault)
+ return true
+ } else {
+ return vm.inputBar.OnKeyEvent(event)
+ }
+}
+
+func (vm *VerificationModal) Focus() {
+ vm.container.Focus()
+}
+
+func (vm *VerificationModal) Blur() {
+ vm.container.Blur()
+}
diff --git a/tui/view-main.go b/tui/view-main.go
index c5d855c..50826f6 100644
--- a/tui/view-main.go
+++ b/tui/view-main.go
@@ -18,25 +18,29 @@ package tui
import (
"bufio"
+ "context"
"fmt"
"os"
"sync/atomic"
"time"
-
- sync "github.com/sasha-s/go-deadlock"
-
+ /*
+ sync "github.com/sasha-s/go-deadlock"
+ */
+ "github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
- "go.mau.fi/tcell"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
- "maunium.net/go/gomuks/lib/notification"
- "maunium.net/go/gomuks/matrix/rooms"
+
+ //find what to use
"maunium.net/go/gomuks/gt/messages"
"maunium.net/go/gomuks/gt/widget"
+ "maunium.net/go/gomuks/lib/notification"
+
+ "maunium.net/go/gomuks/matrix/rooms"
)
type MainView struct {
@@ -54,20 +58,19 @@ type MainView struct {
lastFocusTime time.Time
- matrix ifc.MatrixContainer
- gmx ifc.Gomuks
parent *GomuksTUI
}
func (gt *GomuksTUI) NewMainView() mauview.Component {
-
+ mainView := &MainView{
flex: mauview.NewFlex().SetDirection(mauview.FlexColumn),
roomView: mauview.NewBox(nil).SetBorder(false),
rooms: make(map[id.RoomID]*RoomView),
-
- matrix: gt.gmx.Matrix(),
- gmx: gt.gmx,
- config: gt.gmx.Config(),
+ /*crying face*
+ matrix: gt.Gomuks.Matrix(),
+ gmx: gt.Gomuks,
+ config: gt.Gomuks.Config(),
+ */
parent: gt,
}
mainView.roomList = NewRoomList(mainView)
@@ -101,6 +104,7 @@ func (view *MainView) HideModal() {
}
func (view *MainView) Draw(screen mauview.Screen) {
+ //config does not exist
if view.config.Preferences.HideRoomList {
view.roomView.Draw(screen)
} else {
@@ -125,7 +129,8 @@ func (view *MainView) MarkRead(roomView *RoomView) {
if len(msgList) > 0 {
msg := msgList[len(msgList)-1]
if roomView.Room.MarkRead(msg.ID()) {
- view.matrix.MarkRead(roomView.Room.ID, msg.ID())
+ //? receipt type
+ view.parent.Client.MarkRead(context.TODO(), roomView.Room.ID, msg.ID())
}
}
}
@@ -133,7 +138,8 @@ func (view *MainView) MarkRead(roomView *RoomView) {
func (view *MainView) InputChanged(roomView *RoomView, text string) {
if !roomView.config.Preferences.DisableTypingNotifs {
- view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/')
+ //fix args in this too
+ view.parent.Client.SetTyping(context.TODO(), roomView.Room.ID, len(text) > 0 && text[0] != '/')
}
}
@@ -141,8 +147,8 @@ func (view *MainView) ShowBare(roomView *RoomView) {
if roomView == nil {
return
}
- _, height := view.parent.app.Screen().Size()
- view.parent.app.Suspend(func() {
+ _, height := view.parent.App.Screen().Size()
+ view.parent.App.Suspend(func() {
print("\033[2J\033[0;0H")
// We don't know how much space there exactly is. Too few messages looks weird,
// and too many messages shouldn't cause any problems, so we just show too many.
@@ -155,75 +161,84 @@ func (view *MainView) ShowBare(roomView *RoomView) {
})
}
+/*//???? idk what it does
func (view *MainView) OpenSyncingModal() ifc.SyncingModal {
component, modal := NewSyncingModal(view)
view.ShowModal(component)
return modal
}
+*/
-func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool {
- view.BumpFocus(view.currentRoom)
+/*
+ //umm this is some keyboard magic which idk how to implement
- if view.modal != nil {
- return view.modal.OnKeyEvent(event)
- }
+ func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool {
+ view.BumpFocus(view.currentRoom)
+ if view.modal != nil {
+ return view.modal.OnKeyEvent(event)
+ }
+
+ kb := config.Keybind{
+ Key: event.Key(),
+ Ch: event.Rune(),
+ Mod: event.Modifiers(),
+ }
+ switch view.config.Keybindings.Main[kb] {
+ case "next_room":
+ view.SwitchRoom(view.roomList.Next())
+ case "prev_room":
+ view.SwitchRoom(view.roomList.Previous())
+ case "search_rooms":
+ view.ShowModal(NewFuzzySearchModal(view, 42, 12))
+ case "scroll_up":
+ msgView := view.currentRoom.MessageView()
+ msgView.AddScrollOffset(msgView.TotalHeight())
+ case "scroll_down":
+ msgView := view.currentRoom.MessageView()
+ msgView.AddScrollOffset(-msgView.TotalHeight())
+ case "add_newline":
+ return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift))
+ case "next_active_room":
+ view.SwitchRoom(view.roomList.NextWithActivity())
+ case "show_bare":
+ view.ShowBare(view.currentRoom)
+ default:
+ goto defaultHandler
+ }
+ return true
- kb := config.Keybind{
- Key: event.Key(),
- Ch: event.Rune(),
- Mod: event.Modifiers(),
- }
- switch view.config.Keybindings.Main[kb] {
- case "next_room":
- view.SwitchRoom(view.roomList.Next())
- case "prev_room":
- view.SwitchRoom(view.roomList.Previous())
- case "search_rooms":
- view.ShowModal(NewFuzzySearchModal(view, 42, 12))
- case "scroll_up":
- msgView := view.currentRoom.MessageView()
- msgView.AddScrollOffset(msgView.TotalHeight())
- case "scroll_down":
- msgView := view.currentRoom.MessageView()
- msgView.AddScrollOffset(-msgView.TotalHeight())
- case "add_newline":
- return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift))
- case "next_active_room":
- view.SwitchRoom(view.roomList.NextWithActivity())
- case "show_bare":
- view.ShowBare(view.currentRoom)
- default:
- goto defaultHandler
- }
- return true
defaultHandler:
- if view.config.Preferences.HideRoomList {
- return view.roomView.OnKeyEvent(event)
- }
- return view.flex.OnKeyEvent(event)
-}
+ if view.config.Preferences.HideRoomList {
+ return view.roomView.OnKeyEvent(event)
+ }
+ return view.flex.OnKeyEvent(event)
+ }
+*/
const WheelScrollOffsetDiff = 3
-func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool {
- if view.modal != nil {
- return view.modal.OnMouseEvent(event)
- }
- if view.config.Preferences.HideRoomList {
- return view.roomView.OnMouseEvent(event)
- }
- return view.flex.OnMouseEvent(event)
-}
+/*
+//more :( key things
-func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool {
- if view.modal != nil {
- return view.modal.OnPasteEvent(event)
- } else if view.config.Preferences.HideRoomList {
- return view.roomView.OnPasteEvent(event)
+ func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool {
+ if view.modal != nil {
+ return view.modal.OnMouseEvent(event)
+ }
+ if view.config.Preferences.HideRoomList {
+ return view.roomView.OnMouseEvent(event)
+ }
+ return view.flex.OnMouseEvent(event)
}
- return view.flex.OnPasteEvent(event)
-}
+ func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool {
+ if view.modal != nil {
+ return view.modal.OnPasteEvent(event)
+ } else if view.config.Preferences.HideRoomList {
+ return view.roomView.OnPasteEvent(event)
+ }
+ return view.flex.OnPasteEvent(event)
+ }
+*/
func (view *MainView) Focus() {
if view.focused != nil {
view.focused.Focus()
@@ -260,7 +275,7 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) {
view.flex.SetFocused(view.roomView)
view.focused = view.roomView
view.roomView.Focus()
- view.parent.Render()
+ view.parent.App.Redraw()
if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded {
msgView.initialHistoryLoaded = true
@@ -268,13 +283,14 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) {
}
if !room.MembersFetched {
go func() {
- err := view.matrix.FetchMembers(room)
+ //another args thing
+ err := view.parent.Client.GetRoomState(context.TODO())
if err != nil {
debug.Print("Error fetching members:", err)
return
}
roomView.UpdateUserList()
- view.parent.Render()
+ view.parent.App.Redraw()
}()
}
}
@@ -289,6 +305,7 @@ func (view *MainView) addRoomPage(room *rooms.Room) *RoomView {
return nil
}
+// ifc + what do i get?
func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView {
room, ok := view.getRoomView(roomID, true)
if !ok {
@@ -328,7 +345,7 @@ func (view *MainView) RemoveRoom(room *rooms.Room) {
delete(view.rooms, room.ID)
view.roomsLock.Unlock()
- view.parent.Render()
+ view.parent.App.Redraw()
}
func (view *MainView) addRoom(room *rooms.Room) *RoomView {
@@ -374,14 +391,14 @@ func (view *MainView) UpdateTags(room *rooms.Room) {
if reselect {
view.roomList.SetSelected(room.Tags()[0].Tag, room)
}
- view.parent.Render()
+ view.parent.App.Redraw()
}
func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) {
roomView, ok := view.getRoomView(roomID, true)
if ok {
roomView.SetTyping(users)
- view.parent.Render()
+ view.parent.App.Redraw()
}
}
@@ -397,10 +414,11 @@ func (view *MainView) Bump(room *rooms.Room) {
view.roomList.Bump(room)
}
+// another arg mess. Did not find what is userID
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
view.Bump(room)
gtMsg, ok := message.(*messages.UIMessage)
- if ok && gtMsg.SenderID == view.config.UserID {
+ if ok && gtMsg.SenderID == view.UserID {
return
}
// Whether or not the room where the message came is the currently shown room.
@@ -413,9 +431,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, shoul
// The message is not in the current room, show new message status in room list.
room.AddUnread(message.ID(), should.Notify, should.Highlight)
} else {
- view.matrix.MarkRead(room.ID, message.ID())
+ //args & also why not use the func in here
+ view.parent.Client.MarkRead(room.ID, message.ID())
}
-
+ //config
if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications {
// Push rules say notify and the terminal is not focused, send desktop notification.
shouldPlaySound := should.PlaySound &&
@@ -443,13 +462,13 @@ func (view *MainView) LoadHistory(roomID id.RoomID) {
}
defer atomic.StoreInt32(&msgView.loadingMessages, 0)
// Update the "Loading more messages..." text
- view.parent.Render()
-
+ view.parent.App.Redraw()
+ //history?????
history, newLoadPtr, err := view.matrix.GetHistory(roomView.Room, 50, msgView.historyLoadPtr)
if err != nil {
roomView.AddServiceMessage("Failed to fetch history")
debug.Print("Failed to fetch history for", roomView.Room.ID, err)
- view.parent.Render()
+ view.parent.App.Redraw()
return
}
//debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr)
@@ -457,5 +476,5 @@ func (view *MainView) LoadHistory(roomID id.RoomID) {
for _, evt := range history {
roomView.AddHistoryEvent(evt)
}
- view.parent.Render()
+ view.parent.App.Redraw()
}
diff --git a/tui/widget/border.go b/tui/widget/border.go
new file mode 100644
index 0000000..111fe18
--- /dev/null
+++ b/tui/widget/border.go
@@ -0,0 +1,63 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package widget
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+// Border is a simple tview widget that renders a horizontal or vertical bar.
+//
+// If the width of the box is 1, the bar will be vertical.
+// If the height is 1, the bar will be horizontal.
+// If the width nor the height are 1, nothing will be rendered.
+type Border struct {
+ Style tcell.Style
+}
+
+// NewBorder wraps a new tview Box into a new Border.
+func NewBorder() *Border {
+ return &Border{
+ Style: tcell.StyleDefault.Foreground(mauview.Styles.BorderColor),
+ }
+}
+
+func (border *Border) Draw(screen mauview.Screen) {
+ width, height := screen.Size()
+ if width == 1 {
+ for borderY := 0; borderY < height; borderY++ {
+ screen.SetContent(0, borderY, mauview.Borders.Vertical, nil, border.Style)
+ }
+ } else if height == 1 {
+ for borderX := 0; borderX < width; borderX++ {
+ screen.SetContent(borderX, 0, mauview.Borders.Horizontal, nil, border.Style)
+ }
+ }
+}
+
+func (border *Border) OnKeyEvent(event mauview.KeyEvent) bool {
+ return false
+}
+
+func (border *Border) OnPasteEvent(event mauview.PasteEvent) bool {
+ return false
+}
+
+func (border *Border) OnMouseEvent(event mauview.MouseEvent) bool {
+ return false
+}
diff --git a/tui/widget/color.go b/tui/widget/color.go
new file mode 100644
index 0000000..3d7e627
--- /dev/null
+++ b/tui/widget/color.go
@@ -0,0 +1,224 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package widget
+
+import (
+ "fmt"
+ "hash/fnv"
+
+ "github.com/gdamore/tcell/v2"
+
+ "maunium.net/go/mautrix/id"
+)
+
+var colorNames = []string{
+ "maroon",
+ "green",
+ "olive",
+ "navy",
+ "purple",
+ "teal",
+ "silver",
+ "gray",
+ "red",
+ "lime",
+ "yellow",
+ "blue",
+ "fuchsia",
+ "aqua",
+ "white",
+ "aliceblue",
+ "antiquewhite",
+ "aquamarine",
+ "azure",
+ "beige",
+ "bisque",
+ "blanchedalmond",
+ "blueviolet",
+ "brown",
+ "burlywood",
+ "cadetblue",
+ "chartreuse",
+ "chocolate",
+ "coral",
+ "cornflowerblue",
+ "cornsilk",
+ "crimson",
+ "darkblue",
+ "darkcyan",
+ "darkgoldenrod",
+ "darkgray",
+ "darkgreen",
+ "darkkhaki",
+ "darkmagenta",
+ "darkolivegreen",
+ "darkorange",
+ "darkorchid",
+ "darkred",
+ "darksalmon",
+ "darkseagreen",
+ "darkslateblue",
+ "darkslategray",
+ "darkturquoise",
+ "darkviolet",
+ "deeppink",
+ "deepskyblue",
+ "dimgray",
+ "dodgerblue",
+ "firebrick",
+ "floralwhite",
+ "forestgreen",
+ "gainsboro",
+ "ghostwhite",
+ "gold",
+ "goldenrod",
+ "greenyellow",
+ "honeydew",
+ "hotpink",
+ "indianred",
+ "indigo",
+ "ivory",
+ "khaki",
+ "lavender",
+ "lavenderblush",
+ "lawngreen",
+ "lemonchiffon",
+ "lightblue",
+ "lightcoral",
+ "lightcyan",
+ "lightgoldenrodyellow",
+ "lightgray",
+ "lightgreen",
+ "lightpink",
+ "lightsalmon",
+ "lightseagreen",
+ "lightskyblue",
+ "lightslategray",
+ "lightsteelblue",
+ "lightyellow",
+ "limegreen",
+ "linen",
+ "mediumaquamarine",
+ "mediumblue",
+ "mediumorchid",
+ "mediumpurple",
+ "mediumseagreen",
+ "mediumslateblue",
+ "mediumspringgreen",
+ "mediumturquoise",
+ "mediumvioletred",
+ "midnightblue",
+ "mintcream",
+ "mistyrose",
+ "moccasin",
+ "navajowhite",
+ "oldlace",
+ "olivedrab",
+ "orange",
+ "orangered",
+ "orchid",
+ "palegoldenrod",
+ "palegreen",
+ "paleturquoise",
+ "palevioletred",
+ "papayawhip",
+ "peachpuff",
+ "peru",
+ "pink",
+ "plum",
+ "powderblue",
+ "rebeccapurple",
+ "rosybrown",
+ "royalblue",
+ "saddlebrown",
+ "salmon",
+ "sandybrown",
+ "seagreen",
+ "seashell",
+ "sienna",
+ "skyblue",
+ "slateblue",
+ "slategray",
+ "snow",
+ "springgreen",
+ "steelblue",
+ "tan",
+ "thistle",
+ "tomato",
+ "turquoise",
+ "violet",
+ "wheat",
+ "whitesmoke",
+ "yellowgreen",
+ "grey",
+ "dimgrey",
+ "darkgrey",
+ "darkslategrey",
+ "lightgrey",
+ "lightslategrey",
+ "slategrey",
+}
+
+// GetHashColorName gets a color name for the given string based on its FNV-1 hash.
+//
+// The array of possible color names are the alphabetically ordered color
+// names specified in tcell.ColorNames.
+//
+// The algorithm to get the color is as follows:
+//
+// colorNames[ FNV1(string) % len(colorNames) ]
+//
+// With the exception of the three special cases:
+//
+// --> = green
+// <-- = red
+// --- = yellow
+func GetHashColorName(s string) string {
+ switch s {
+ case "-->":
+ return "green"
+ case "<--":
+ return "red"
+ case "---":
+ return "yellow"
+ default:
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(s))
+ return colorNames[h.Sum32()%uint32(len(colorNames))]
+ }
+}
+
+// GetHashColor gets the tcell Color value for the given string.
+//
+// GetHashColor calls GetHashColorName() and gets the Color value from the tcell.ColorNames map.
+func GetHashColor(val interface{}) tcell.Color {
+ switch str := val.(type) {
+ case string:
+ return tcell.ColorNames[GetHashColorName(str)]
+ case *string:
+ return tcell.ColorNames[GetHashColorName(*str)]
+ case id.UserID:
+ return tcell.ColorNames[GetHashColorName(string(str))]
+ default:
+ return tcell.ColorNames["red"]
+ }
+}
+
+// AddColor adds tview color tags to the given string.
+func AddColor(s, color string) string {
+ return fmt.Sprintf("[%s]%s[white]", color, s)
+}
diff --git a/tui/widget/doc.go b/tui/widget/doc.go
new file mode 100644
index 0000000..03d2060
--- /dev/null
+++ b/tui/widget/doc.go
@@ -0,0 +1,2 @@
+// Package widget contains additional tview widgets.
+package widget
diff --git a/tui/widget/util.go b/tui/widget/util.go
new file mode 100644
index 0000000..9c40955
--- /dev/null
+++ b/tui/widget/util.go
@@ -0,0 +1,73 @@
+// gomuks - A terminal Matrix client written in Go.
+// Copyright (C) 2020 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package widget
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/mattn/go-runewidth"
+
+ "github.com/gdamore/tcell/v2"
+ "go.mau.fi/mauview"
+)
+
+func WriteLineSimple(screen mauview.Screen, line string, x, y int) {
+ WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
+}
+
+func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) {
+ WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
+}
+
+func WriteLineColor(screen mauview.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 mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
+ offsetX := 0
+ if align == mauview.AlignRight {
+ offsetX = maxWidth - runewidth.StringWidth(line)
+ }
+ if offsetX < 0 {
+ offsetX = 0
+ }
+ for _, ch := range line {
+ chWidth := runewidth.RuneWidth(ch)
+ if chWidth == 0 {
+ continue
+ }
+
+ for localOffset := 0; localOffset < chWidth; localOffset++ {
+ screen.SetContent(x+offsetX+localOffset, y, ch, nil, style)
+ }
+ offsetX += chWidth
+ if offsetX >= maxWidth {
+ break
+ }
+ }
+}
+
+func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
+ padding := strconv.Itoa(maxWidth)
+ if align == mauview.AlignRight {
+ line = fmt.Sprintf("%"+padding+"s", line)
+ } else {
+ line = fmt.Sprintf("%-"+padding+"s", line)
+ }
+ WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style)
+}