Skip to content

Commit d7207ae

Browse files
committed
use textArea for title to support word wrap for long inputs
1 parent 36ddf13 commit d7207ae

8 files changed

Lines changed: 171 additions & 71 deletions

File tree

‎internal/tui/submitview/editor.go‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func (m Model) renderEditBody(innerW int) string {
163163
// TITLE
164164
b.WriteString(sectionLabelFor("TITLE", m.focusedField == fieldTitle))
165165
b.WriteString("\n")
166-
b.WriteString(fieldBox(m.titleInput.View(), innerW, m.focusedField == fieldTitle))
166+
b.WriteString(fieldBox(m.titleContent(), innerW, m.focusedField == fieldTitle))
167167
b.WriteString("\n")
168168

169169
// DESCRIPTION with edit/preview sub-toggle on the right.
@@ -316,6 +316,26 @@ func (m Model) descContent(innerW int) string {
316316
return addScrollbar(rows, scroll, len(lines), height, textWidth)
317317
}
318318

319+
// titleContent renders the editable title as soft-wrapped visual rows with the
320+
// block cursor overlaid, mirroring descContent but without a scrollbar. The
321+
// title is a single logical line, so its cursor row is the wrapped row offset.
322+
func (m Model) titleContent() string {
323+
width := m.titleTextWidth()
324+
h := m.titleAreaHeight()
325+
lines := wrapDescLines(m.titleArea.Value(), width)
326+
cursorRow := m.titleArea.LineInfo().RowOffset
327+
scroll := clampScroll(cursorViewTop(cursorRow, h), len(lines), h)
328+
if m.focusedField == fieldTitle && m.titleArea.Focused() &&
329+
cursorRow >= scroll && cursorRow < scroll+h && cursorRow < len(lines) {
330+
lines = overlayCursor(lines, cursorRow, m.titleArea.LineInfo().ColumnOffset)
331+
}
332+
rows := clipScrollRows(lines, scroll, h)
333+
for len(rows) < h {
334+
rows = append(rows, "")
335+
}
336+
return strings.Join(rows, "\n")
337+
}
338+
319339
// clampScroll bounds a scroll offset to [0, total-height].
320340
func clampScroll(scroll, total, height int) int {
321341
if max := total - height; scroll > max {

‎internal/tui/submitview/integration_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
func TestSubmit_EmptyTitleGuard(t *testing.T) {
1212
m := testModel(t, newNodes())
13-
m.titleInput.SetValue("") // blank the focused branch's title
13+
m.titleArea.SetValue("") // blank the focused branch's title
1414

1515
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
1616
m = updated.(Model)
@@ -32,7 +32,7 @@ func TestSubmit_SucceedsWhenTitlesPresent(t *testing.T) {
3232
func TestSubmit_SkippedBranchWithoutTitleDoesNotBlock(t *testing.T) {
3333
m := testModel(t, newNodes())
3434
// Blank the first branch's title, then skip it; submit should proceed.
35-
m.titleInput.SetValue("")
35+
m.titleArea.SetValue("")
3636
m.saveEditor()
3737
m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip branch 0
3838

‎internal/tui/submitview/model.go‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package submitview
33
import (
44
"github.com/charmbracelet/bubbles/key"
55
"github.com/charmbracelet/bubbles/textarea"
6-
"github.com/charmbracelet/bubbles/textinput"
76
tea "github.com/charmbracelet/bubbletea"
87
"github.com/charmbracelet/lipgloss"
98
"github.com/github/gh-stack/internal/stack"
@@ -60,7 +59,7 @@ type Model struct {
6059
width, height int
6160

6261
// Editor state for the focused branch.
63-
titleInput textinput.Model
62+
titleArea textarea.Model
6463
descArea textarea.Model
6564
focusedField editField
6665
descPreview bool // description preview (vs edit)
@@ -115,25 +114,32 @@ func New(opts Options) Model {
115114
}
116115
}
117116

118-
ti := textinput.New()
119-
ti.Prompt = ""
120-
ti.CharLimit = 256
121-
122117
ta := textarea.New()
123118
ta.Prompt = ""
124119
ta.ShowLineNumbers = false
125120
ta.CharLimit = 0 // unlimited
126121
// Soften the textarea chrome; the panel provides the border.
127122
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
128123

124+
// The title is a single logical line shown soft-wrapped: its box grows in
125+
// height as the text wraps (capped, with the description shrinking to fit).
126+
// It is a textarea (not a single-line input) so the cursor can move between
127+
// wrapped rows. Newlines are blocked in updateScreen so the value stays a
128+
// single line.
129+
tia := textarea.New()
130+
tia.Prompt = ""
131+
tia.ShowLineNumbers = false
132+
tia.CharLimit = 256
133+
tia.FocusedStyle.CursorLine = lipgloss.NewStyle()
134+
129135
m := Model{
130136
nodes: opts.Nodes,
131137
trunk: opts.Trunk,
132138
repoLabel: opts.RepoLabel,
133139
version: opts.Version,
134140
cursor: cursor,
135141

136-
titleInput: ti,
142+
titleArea: tia,
137143
descArea: ta,
138144
focusedField: fieldTitle,
139145
}
@@ -161,7 +167,7 @@ func (m Model) Nodes() []SubmitNode { return m.nodes }
161167
// --- Bubble Tea interface ---
162168

163169
func (m Model) Init() tea.Cmd {
164-
return textinput.Blink
170+
return textarea.Blink
165171
}
166172

167173
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

‎internal/tui/submitview/model_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestNew_OpensEditorOnBottomMostNew(t *testing.T) {
7878
m := testModel(t, newNodes())
7979
assert.Equal(t, 1, m.cursor, "opens on the bottom-most NEW branch (closest to trunk)")
8080
assert.Equal(t, fieldTitle, m.focusedField)
81-
assert.True(t, m.titleInput.Focused(), "title is focused for immediate editing")
81+
assert.True(t, m.titleArea.Focused(), "title is focused for immediate editing")
8282
}
8383

8484
func TestNew_AllNewIncludedByDefault(t *testing.T) {
@@ -121,7 +121,7 @@ func TestNavigation_FocusesLockedWithNoTextInput(t *testing.T) {
121121
m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown})
122122
m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked
123123
require.True(t, m.nodes[m.cursor].State.Locked())
124-
assert.False(t, m.titleInput.Focused())
124+
assert.False(t, m.titleArea.Focused())
125125
assert.False(t, m.descArea.Focused())
126126
}
127127

@@ -135,11 +135,11 @@ func TestCtrlX_TogglesInclude(t *testing.T) {
135135

136136
m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
137137
assert.False(t, m.nodes[0].Included, "^x skips the focused NEW branch")
138-
assert.False(t, m.titleInput.Focused(), "a skipped branch's title is non-interactive")
138+
assert.False(t, m.titleArea.Focused(), "a skipped branch's title is non-interactive")
139139

140140
m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX})
141141
assert.True(t, m.nodes[0].Included, "^x includes it again")
142-
assert.True(t, m.titleInput.Focused(), "re-including focuses the title for editing")
142+
assert.True(t, m.titleArea.Focused(), "re-including focuses the title for editing")
143143
}
144144

145145
func TestCtrlX_WorksWhileTypingTitle(t *testing.T) {

‎internal/tui/submitview/mouse.go‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) {
188188

189189
rel := y - m.panelTopRow()
190190
titleLine, descLabel, descTop, descBot, draftLine := m.rightZones()
191+
titleH := m.titleAreaHeight()
191192
rightEdge := leftW + 1 + rightW
192193

193194
// Include chip (header row 0, right side) toggles inclusion.
@@ -227,12 +228,12 @@ func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) {
227228
return m, cmd
228229
}
229230
return m, nil
230-
case rel >= titleLine-1 && rel <= titleLine+1:
231+
case rel >= titleLine-1 && rel <= titleLine+titleH:
231232
cmd := m.focusField(fieldTitle)
232-
// Clicking inside the title box content row positions the cursor at the
233-
// clicked column (the border rows just focus the field).
234-
if rel == titleLine {
235-
m.positionTitleCursor(x - (leftW + 5))
233+
// Clicking inside the title box content rows positions the cursor at the
234+
// clicked row and column (the border rows just focus the field).
235+
if rel >= titleLine && rel < titleLine+titleH {
236+
m.positionTitleCursor(rel-titleLine, x-(leftW+5))
236237
}
237238
return m, cmd
238239
case rel >= descTop && rel <= descBot:
@@ -266,14 +267,17 @@ func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) {
266267
}
267268

268269
// rightZones returns the right-panel line offsets (from panelTopRow) of the
269-
// interactive editor elements for the included-NEW layout.
270+
// interactive editor elements for the included-NEW layout. The title box grows
271+
// with its wrapped content, so the description and footer offsets shift down by
272+
// the title's extra rows.
270273
func (m Model) rightZones() (titleLine, descLabel, descTop, descBot, draftLine int) {
274+
titleH := m.titleAreaHeight()
271275
descH := m.descAreaHeight()
272-
titleLine = 4 // title box content (header 0, rule 1, label 2, box 3..5)
273-
descLabel = 6
274-
descTop = 7 // description box top border
275-
descBot = 8 + descH
276-
draftLine = 9 + descH
276+
titleLine = 4 // first title box content row (header 0, rule 1, label 2, box top 3)
277+
descLabel = 5 + titleH
278+
descTop = 6 + titleH // description box top border
279+
descBot = 7 + titleH + descH
280+
draftLine = 8 + titleH + descH
277281
return
278282
}
279283

@@ -302,17 +306,27 @@ func (m *Model) positionDescCursor(visRow, col int) {
302306
m.descArea.SetCursor(m.descArea.LineInfo().StartColumn + col)
303307
}
304308

305-
// positionTitleCursor moves the title input's cursor to the clicked column,
306-
// where col is the offset from the title box's text start. It is exact when the
307-
// title fits the field (the single-line input isn't horizontally scrolled) and
308-
// approximate otherwise, mirroring positionDescCursor. col is treated as a rune
309-
// offset, so it is exact for the typical ASCII title.
310-
func (m *Model) positionTitleCursor(col int) {
309+
// positionTitleCursor moves the title's cursor to the clicked visual row and
310+
// column, where col is the offset from the title box's text start. Like
311+
// positionDescCursor it resets to the top of the (single logical, soft-wrapped)
312+
// line and walks down by visual row, so it is exact when the title is not
313+
// scrolled (the common case) and approximate otherwise.
314+
func (m *Model) positionTitleCursor(visRow, col int) {
315+
if visRow < 0 {
316+
visRow = 0
317+
}
311318
if col < 0 {
312319
col = 0
313320
}
314-
if n := len([]rune(m.titleInput.Value())); col > n {
315-
col = n
321+
for guard := 0; guard < 1000; guard++ {
322+
row, off := m.titleArea.Line(), m.titleArea.LineInfo().RowOffset
323+
m.titleArea.CursorUp()
324+
if m.titleArea.Line() == row && m.titleArea.LineInfo().RowOffset == off {
325+
break // reached the top-left of the buffer
326+
}
327+
}
328+
for i := 0; i < visRow; i++ {
329+
m.titleArea.CursorDown()
316330
}
317-
m.titleInput.SetCursor(col)
331+
m.titleArea.SetCursor(m.titleArea.LineInfo().StartColumn + col)
318332
}

‎internal/tui/submitview/mousefilter_test.go‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@ func TestConsumeLeakedMouseKey_SwallowsSplitSequences(t *testing.T) {
3434
t.Run(fmt.Sprintf("%q@%d", seq, cut), func(t *testing.T) {
3535
m := testModel(t, newNodes())
3636
require.Equal(t, fieldTitle, m.focusedField)
37-
before := m.titleInput.Value()
37+
before := m.titleArea.Value()
3838

3939
for _, msg := range splitSGRBurst(seq, cut) {
4040
updated, _ := m.Update(msg)
4141
m = updated.(Model)
4242
}
4343

44-
assert.Equal(t, before, m.titleInput.Value(), "no fragment should reach the title field")
44+
assert.Equal(t, before, m.titleArea.Value(), "no fragment should reach the title field")
4545
assert.False(t, m.mouseLeakActive, "the sequence resets once its terminator is consumed")
4646

4747
// A normal keystroke afterwards must still register.
4848
m = sendKey(t, m, runeKey('x'))
49-
assert.Equal(t, before+"x", m.titleInput.Value(), "typing works after a swallowed sequence")
49+
assert.Equal(t, before+"x", m.titleArea.Value(), "typing works after a swallowed sequence")
5050
})
5151
}
5252
}
@@ -56,45 +56,45 @@ func TestConsumeLeakedMouseKey_SwallowsSingleRunTail(t *testing.T) {
5656
// When the parser consumes "\x1b" as a lone Escape, the rest of the body can
5757
// arrive as one run.
5858
m := testModel(t, newNodes())
59-
before := m.titleInput.Value()
59+
before := m.titleArea.Value()
6060
m = sendKey(t, m, runeMsg("[<65;54;51M", false))
61-
assert.Equal(t, before, m.titleInput.Value(), "a whole leaked body in one run is dropped")
61+
assert.Equal(t, before, m.titleArea.Value(), "a whole leaked body in one run is dropped")
6262
}
6363

6464
func TestConsumeLeakedMouseKey_PreservesRealTyping(t *testing.T) {
6565
m := testModel(t, newNodes())
66-
before := m.titleInput.Value()
66+
before := m.titleArea.Value()
6767

6868
// Characters from the mouse alphabet typed normally must pass through. Each
6969
// real keystroke is a single-rune message, never a complete SGR body.
7070
for _, r := range []rune{'<', '3', ';', '5', 'M', 'm'} {
7171
m = sendKey(t, m, runeKey(r))
7272
}
73-
assert.Equal(t, before+"<3;5Mm", m.titleInput.Value(), "ordinary characters are never filtered")
73+
assert.Equal(t, before+"<3;5Mm", m.titleArea.Value(), "ordinary characters are never filtered")
7474
}
7575

7676
func TestConsumeLeakedMouseKey_StrayAltBracketDoesNotEatNextKey(t *testing.T) {
7777
m := testModel(t, newNodes())
78-
before := m.titleInput.Value()
78+
before := m.titleArea.Value()
7979

8080
// A lone Alt+"[" opens a sequence, but the following key is not a mouse body,
8181
// so it must still be handled (here: typed into the field).
8282
m = sendKey(t, m, runeMsg("[", true))
8383
require.True(t, m.mouseLeakActive)
8484
m = sendKey(t, m, runeKey('z'))
8585
assert.False(t, m.mouseLeakActive, "a non-body rune ends the sequence")
86-
assert.Equal(t, before+"z", m.titleInput.Value(), "the following real key is not eaten")
86+
assert.Equal(t, before+"z", m.titleArea.Value(), "the following real key is not eaten")
8787
}
8888

8989
func TestConsumeLeakedMouseKey_BracketedPasteIsNotFiltered(t *testing.T) {
9090
m := testModel(t, newNodes())
91-
before := m.titleInput.Value()
91+
before := m.titleArea.Value()
9292

9393
// A genuine paste that happens to look like a mouse body is preserved.
9494
paste := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("<65;54;51M"), Paste: true}
9595
updated, _ := m.Update(paste)
9696
m = updated.(Model)
97-
assert.Equal(t, before+"<65;54;51M", m.titleInput.Value(), "pasted content is never filtered")
97+
assert.Equal(t, before+"<65;54;51M", m.titleArea.Value(), "pasted content is never filtered")
9898
}
9999

100100
func TestConsumeLeakedMouseKey_DescriptionField(t *testing.T) {

0 commit comments

Comments
 (0)