diff --git a/format/mdext/rainbow/goldmark.go b/format/mdext/rainbow/goldmark.go new file mode 100644 index 00000000..59a36178 --- /dev/null +++ b/format/mdext/rainbow/goldmark.go @@ -0,0 +1,120 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package rainbow + +import ( + "fmt" + "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" + "go.mau.fi/util/random" +) + +// Extension is a goldmark extension that adds rainbow text coloring to the HTML renderer. +var Extension = &extRainbow{} + +type extRainbow struct{} +type rainbowRenderer struct { + HardWraps bool + ColorID string +} + +var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: random.String(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/format/mdext/rainbow/gradient.go b/format/mdext/rainbow/gradient.go new file mode 100644 index 00000000..34c499e6 --- /dev/null +++ b/format/mdext/rainbow/gradient.go @@ -0,0 +1,56 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package rainbow + +import ( + "regexp" + "strings" + + "github.com/lucasb-eyer/go-colorful" +) + +// 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 Gradient = 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}, +} + +func ApplyColor(htmlBody string) string { + count := strings.Count(htmlBody, defaultRB.ColorID) + i := -1 + return regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string { + i++ + return Gradient.GetInterpolatedColorFor(float64(i) / float64(count)).Hex() + }) +} diff --git a/go.mod b/go.mod index 8ef08be8..a1e97f8c 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/lib/pq v1.10.9 + github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v1.14.23 + github.com/rivo/uniseg v0.4.7 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e diff --git a/go.sum b/go.sum index ac8e03f6..b825326f 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -31,6 +33,8 @@ github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7c github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= diff --git a/hicli/json-commands.go b/hicli/json-commands.go index 12026f6b..a81cf097 100644 --- a/hicli/json-commands.go +++ b/hicli/json-commands.go @@ -38,7 +38,11 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return true, nil }) case "send_message": - return unmarshalAndCall(req.Data, func(params *sendParams) (*database.Event, error) { + return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) { + return h.SendMessage(ctx, params.RoomID, params.Text, params.MediaPath) + }) + case "send_event": + return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { return h.Send(ctx, params.RoomID, params.EventType, params.Content) }) case "get_event": @@ -100,7 +104,13 @@ type cancelRequestParams struct { Reason string `json:"reason"` } -type sendParams struct { +type sendMessageParams struct { + RoomID id.RoomID `json:"room_id"` + Text string `json:"text"` + MediaPath string `json:"media_path"` +} + +type sendEventParams struct { RoomID id.RoomID `json:"room_id"` EventType event.Type `json:"type"` Content json.RawMessage `json:"content"` diff --git a/hicli/send.go b/hicli/send.go index 42b309a0..d16cadf6 100644 --- a/hicli/send.go +++ b/hicli/send.go @@ -11,17 +11,43 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/rs/zerolog" + "github.com/yuin/goldmark" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/format/mdext/rainbow" "maunium.net/go/mautrix/hicli/database" "maunium.net/go/mautrix/id" ) +var ( + rainbowWithHTML = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(rainbow.Extension)) +) + +func (h *HiClient) SendMessage(ctx context.Context, roomID id.RoomID, text, mediaPath string) (*database.Event, error) { + var content event.MessageEventContent + if strings.HasPrefix(text, "/rainbow ") { + text = strings.TrimPrefix(text, "/rainbow ") + content = format.RenderMarkdownCustom(text, rainbowWithHTML) + content.FormattedBody = rainbow.ApplyColor(content.FormattedBody) + } else if strings.HasPrefix(text, "/plain ") { + text = strings.TrimPrefix(text, "/plain ") + content = format.RenderMarkdown(text, false, false) + } else if strings.HasPrefix(text, "/html ") { + text = strings.TrimPrefix(text, "/html ") + content = format.RenderMarkdown(text, false, true) + } else { + content = format.RenderMarkdown(text, true, false) + } + return h.Send(ctx, roomID, event.EventMessage, &content) +} + func (h *HiClient) Send(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (*database.Event, error) { roomMeta, err := h.DB.Room.Get(ctx, roomID) if err != nil {