commit 28b1907

sewn  ·  2026-02-27 22:21:11 +0000 UTC
parent 94ab5ff
Add inserting formatted text via toggle keybind

This patch adds the ability to insert formatted text by pressing Ctrl+s
to enable formatting, making a set format characters specify how the text
is to be formatting, which is also shown in the input area.
This single-character method is preferred over traditional ** for bold
and italic since it requires read-ahead, and is a seperate mode as to
not have to also implement backwards reading and styling, eg. inputting
a single asterisk will cause the rest of the text to be bold.

To get around this, backslashing can be implemented with backward reading
and the basic formatting characters can be enabled by default, but that
can be a later patch.

Resolves https://todo.sr.ht/~delthas/senpai/93
Credit: https://lists.sr.ht/~delthas/senpai-dev/patches/58333
5 files changed,  +138, -1
M app.go
M app.go
+14, -0
 1@@ -110,6 +110,7 @@ type App struct {
 2 	sessions         map[string]*irc.Session // map of network IDs to their current session
 3 	pasting          bool
 4 	pastingInputOnly bool // true is pasting started when the editor input was empty
 5+	formatting       bool // true for keybind to enable text formatting
 6 
 7 	// events MUST NOT be posted to directly; instead, use App.postEvent.
 8 	events chan event
 9@@ -838,6 +839,8 @@ func (app *App) handleAction(action string, args ...string) {
10 		if len(app.win.InputContent()) == 0 {
11 			app.win.InputSet(strings.Join(args, " "))
12 		}
13+	case "format":
14+		app.formatting = !app.formatting
15 	case "cursor-start":
16 		app.win.InputHome()
17 	case "cursor-end":
18@@ -969,6 +972,7 @@ var defaultCommands = map[string][]string{
19 	"Control+k":       {"set-editor", "/buffer "},
20 	"Control+v":       {"paste-hint"},
21 	"Control+Alt+v":   {"set-editor", "/upload"},
22+	"Control+s":       {"format"},
23 	"Control+a":       {"cursor-start"},
24 	"Control+e":       {"cursor-end"},
25 	"Control+l":       {"redraw"},
26@@ -1048,6 +1052,12 @@ func (app *App) handleKeyEvent(ev vaxis.Key) {
27 	}
28 	if ev.Text != "" {
29 		for _, r := range ev.Text {
30+			if app.formatting {
31+				f, ok := ui.FormattingChars[r]
32+				if ok {
33+					r = f
34+				}
35+			}
36 			app.win.InputRune(r)
37 		}
38 		app.typing()
39@@ -2301,6 +2311,10 @@ func (app *App) updatePrompt() {
40 		prompt = ui.Styled(">", vaxis.Style{
41 			Foreground: app.cfg.Colors.Prompt,
42 		})
43+	} else if app.formatting {
44+		prompt = ui.Styled("<fmt>", vaxis.Style{
45+			Attribute: vaxis.AttrBold,
46+		})
47 	} else if s == nil {
48 		prompt = ui.Styled("<offline>", vaxis.Style{
49 			Foreground: ui.ColorRed,
+68, -0
 1@@ -97,6 +97,9 @@ These shortcuts can be customized in the configuration, see senpai(5).
 2 *CTRL-E*
 3 	Move the cursor to the end of the input field.
 4 
 5+*CTRL-S*
 6+	Enable text formatting - see the _FORMATTING_ section.
 7+
 8 *CTRL-C*
 9 	Clear input line, or prepare for exit by adding /quit to input line.
10 
11@@ -162,6 +165,71 @@ These shortcuts can be customized in the configuration, see senpai(5).
12 *F8*
13 	Show/hide the vertical member list.
14 
15+# FORMATTING
16+
17+Senpai can insert formatted text like italics, bold, underline, colors, etc.
18+To do this, Toggle text formatting (default *CTRL+S*) followed by a key
19+indicating the format character. The prompt will turn into *<fmt>* when text
20+formatting is enabled. The format characters are:
21+
22+*\_*
23+	Italic. Inserts the ASCII character 0x1D.
24+
25+*\**
26+	*Bold*. Inserts the ASCII character 0x02.
27+
28+*=*
29+	_Underline_. Inserts the ASCII character 0x1F.
30+
31+*~*
32+	Strikethrough. Inserts the ASCII character 0x1E. May not be supported
33+	by all IRC clients.
34+
35+*|*
36+	Reverse. Inserts the ASCII character 0x16. May not provide consistent
37+	behavior across all IRC clients, if supported.
38+
39+*`*
40+	Monospace. Inserts the ASCII character 0x11. Only a few IRC clients
41+	with a graphical interface support this character.
42+
43+*^*
44+	Color. Inserts the ASCII character 0x03.++
45+	The color is inserted by inserting a number or two after the character.
46+	These numbers are defined by color codes and include a foreground and
47+	(optionally) a background color. For example,
48+	```
49+	^04Hello World
50+	```
51+	will output Red text, and
52+	```
53+	^07,09Hello World
54+	```
55+	will output Orange text on a Light Green background. Color codes 0-15 are
56+	represented by terminal colors, 16-98 represents set RGB values, and 99
57+	represents default foreground or background. Refer to the end of this
58+	section for more details.
59+
60+*#*
61+	Hexadecimal color. Inserts the ASCII character 0x04.++
62+	The color is inserted by inserting a six hexadecimal digits that arepresent
63+	the hexadecimal color. For example,
64+	```
65+	^FF0000Hello World
66+	```
67+	will output bright red text. Specifying the background is similar to the
68+	color format character (*^*).
69+
70+*-*
71+	Reset. Clears formatting applied thus far in the input buffer. Inserts
72+	ASCII character 0x1F.
73+
74+Note that all of the formatting characters are able to be previewed in the
75+input area except for the color specifiers.
76+
77+Refer to <https://modern.ircdocs.horse/formatting> for more information
78+about IRC formatting.
79+
80 # COMMANDS
81 
82 If you type and send a message that starts with a slash (*/*), it will instead
+31, -0
 1@@ -13,10 +13,34 @@ import (
 2 
 3 var asciiStringCache []string
 4 
 5+var FormattingChars = map[rune]rune{
 6+	'*': '\x02', // bold
 7+	'^': '\x03', // custom color (terminal color, 0-99)
 8+	'#': '\x04', // custom color (hexadecimal color)
 9+	'-': '\x0F', // reset all attributes
10+	'`': '\x11', // monospace text
11+	'|': '\x16', // reverse color
12+	'_': '\x1D', // italics
13+	'~': '\x1E', // strikethrough
14+	'=': '\x1F', // underline
15+}
16+
17+// allow for reverse lookup
18+var formattingRunes map[rune]rune
19+
20 func init() {
21+	formattingRunes = make(map[rune]rune, len(FormattingChars))
22+	for c, r := range FormattingChars {
23+		formattingRunes[r] = c
24+	}
25+
26 	asciiStringCache = make([]string, 0x80)
27 	for i := range asciiStringCache {
28 		asciiStringCache[i] = string(rune(i))
29+
30+		if f, ok := formattingRunes[rune(i)]; ok {
31+			asciiStringCache[i] = string(f)
32+		}
33 	}
34 }
35 
36@@ -30,6 +54,10 @@ func runeWidth(vx *Vaxis, r rune) int {
37 		r = '↲'
38 	}
39 	if r <= 0x1F {
40+		if f, ok := formattingRunes[r]; ok {
41+			r = f
42+			return 1
43+		}
44 		return 0
45 	}
46 	if r <= 0x7F {
47@@ -52,6 +80,9 @@ func stringWidth(vx *Vaxis, s string) int {
48 			return 1
49 		}
50 		if s[0] <= 0x1F {
51+			if _, ok := formattingRunes[rune(s[0])]; ok {
52+				return 1
53+			}
54 			return 0
55 		}
56 		if s[0] <= 0x7F {
+23, -1
 1@@ -618,10 +618,32 @@ func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
 2 		if i == autoStart {
 3 			autoX = x
 4 		}
 5-		if r[0] == '\n' {
 6+		switch r[0] {
 7+		case '\n':
 8 			s.Attribute |= vaxis.AttrBold
 9 			s.Foreground = ColorRed
10 			r = []rune{'↲'}
11+		case 0x02:
12+			st.Attribute ^= vaxis.AttrBold
13+		case 0x03, 0x04:
14+			// TODO: Not possible to show the color without implementing
15+			//       custom character to mark the end or add state
16+		case 0x0F:
17+			st.Attribute = vaxis.AttrNone
18+		case 0x11:
19+			st.Attribute ^= vaxis.AttrDim
20+		case 0x16:
21+			st.Attribute ^= vaxis.AttrReverse
22+		case 0x1D:
23+			st.Attribute ^= vaxis.AttrItalic
24+		case 0x1E:
25+			st.Attribute ^= vaxis.AttrStrikethrough
26+		case 0x1F:
27+			if st.UnderlineStyle == vaxis.UnderlineOff {
28+				st.UnderlineStyle = vaxis.UnderlineSingle
29+			} else {
30+				st.UnderlineStyle = vaxis.UnderlineOff
31+			}
32 		}
33 		dx, di := printCluster(vx, x, y, x0+e.width, r, s)
34 		if di == 0 {
+2, -0
1@@ -314,6 +314,8 @@ func IRCString(raw string) StyledString {
2 				current.Foreground = fg
3 				current.Background = bg
4 			}
5+		} else if r == 0x11 {
6+			current.Attribute ^= vaxis.AttrDim
7 		} else if r == 0x16 {
8 			current.Attribute ^= vaxis.AttrReverse
9 		} else if r == 0x1D {