Skip to content

Commit 7da2794

Browse files
committed
feat(tui): add request builder view
- Adds a request builder component with tabs for body, headers, query params - Adds a textarea component for editing the request body - Adds tab switching - Adds basic key handling
1 parent 56038fc commit 7da2794

5 files changed

Lines changed: 764 additions & 40 deletions

File tree

internal/tui/components/text_input.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func NewTextInput(label, placeholder string) TextInput {
1717
ti := textinput.New()
1818
ti.Placeholder = placeholder
1919
ti.Focus()
20-
ti.CharLimit = 100
20+
ti.CharLimit = 5000 // Allow long content like JSON
2121
ti.Width = 50
2222

2323
return TextInput{
@@ -37,7 +37,20 @@ func (t TextInput) Value() string {
3737

3838
func (t *TextInput) SetWidth(width int) {
3939
t.width = width
40-
t.textInput.Width = width - len(t.label) - 4 // Account for label and spacing
40+
// Account for label, colon, spacing, and border padding
41+
containerWidth := width - 12 - 1 - 2 // 12 for label, 1 for colon, 2 for spacing
42+
if containerWidth < 15 {
43+
containerWidth = 15
44+
}
45+
46+
// The actual input width inside the container (subtract border and padding)
47+
inputWidth := containerWidth - 4 // 2 for border, 2 for padding
48+
if inputWidth < 10 {
49+
inputWidth = 10
50+
}
51+
52+
// Ensure the underlying textinput respects the width
53+
t.textInput.Width = inputWidth
4154
}
4255

4356
func (t *TextInput) Focus() {
@@ -67,10 +80,27 @@ func (t TextInput) View() string {
6780
Width(12).
6881
Align(lipgloss.Right)
6982

83+
// Create a fixed-width container for the input to prevent overflow
84+
containerWidth := t.width - 12 - 1 - 2 // Account for label, colon, spacing
85+
if containerWidth < 15 {
86+
containerWidth = 15
87+
}
88+
89+
inputContainer := styles.ListItemStyle.Copy().
90+
Width(containerWidth).
91+
Height(1).
92+
Border(lipgloss.RoundedBorder()).
93+
BorderForeground(styles.Secondary).
94+
Padding(0, 1)
95+
96+
if t.textInput.Focused() {
97+
inputContainer = inputContainer.BorderForeground(styles.Primary)
98+
}
99+
70100
return lipgloss.JoinHorizontal(
71101
lipgloss.Top,
72102
labelStyle.Render(t.label+":"),
73103
" ",
74-
t.textInput.View(),
104+
inputContainer.Render(t.textInput.View()),
75105
)
76106
}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
package components
2+
3+
import (
4+
"strings"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/maniac-en/req/internal/tui/styles"
9+
)
10+
11+
type Textarea struct {
12+
label string
13+
content string
14+
width int
15+
height int
16+
focused bool
17+
cursor int
18+
lines []string
19+
cursorRow int
20+
cursorCol int
21+
scrollOffset int
22+
}
23+
24+
func NewTextarea(label, placeholder string) Textarea {
25+
return Textarea{
26+
label: label,
27+
content: "",
28+
width: 50,
29+
height: 6,
30+
focused: false,
31+
cursor: 0,
32+
lines: []string{""},
33+
cursorRow: 0,
34+
cursorCol: 0,
35+
scrollOffset: 0,
36+
}
37+
}
38+
39+
func (t *Textarea) SetValue(value string) {
40+
t.content = value
41+
rawLines := strings.Split(value, "\n")
42+
if len(rawLines) == 0 {
43+
rawLines = []string{""}
44+
}
45+
46+
// Wrap long lines to fit within the textarea width
47+
t.lines = []string{}
48+
contentWidth := t.getContentWidth()
49+
50+
for _, line := range rawLines {
51+
if len(line) <= contentWidth {
52+
t.lines = append(t.lines, line)
53+
} else {
54+
// Wrap long lines
55+
wrapped := t.wrapLine(line, contentWidth)
56+
t.lines = append(t.lines, wrapped...)
57+
}
58+
}
59+
60+
if len(t.lines) == 0 {
61+
t.lines = []string{""}
62+
}
63+
64+
// Set cursor to end
65+
t.cursorRow = len(t.lines) - 1
66+
t.cursorCol = len(t.lines[t.cursorRow])
67+
}
68+
69+
func (t Textarea) Value() string {
70+
return strings.Join(t.lines, "\n")
71+
}
72+
73+
func (t *Textarea) SetSize(width, height int) {
74+
t.width = width
75+
t.height = height
76+
}
77+
78+
func (t *Textarea) Focus() {
79+
t.focused = true
80+
}
81+
82+
func (t *Textarea) Blur() {
83+
t.focused = false
84+
}
85+
86+
func (t Textarea) Focused() bool {
87+
return t.focused
88+
}
89+
90+
func (t *Textarea) moveCursor(row, col int) {
91+
// Ensure row is in bounds
92+
if row < 0 {
93+
row = 0
94+
}
95+
if row >= len(t.lines) {
96+
row = len(t.lines) - 1
97+
}
98+
99+
// Ensure col is in bounds for the row
100+
if col < 0 {
101+
col = 0
102+
}
103+
if col > len(t.lines[row]) {
104+
col = len(t.lines[row])
105+
}
106+
107+
t.cursorRow = row
108+
t.cursorCol = col
109+
}
110+
111+
112+
func (t Textarea) Update(msg tea.Msg) (Textarea, tea.Cmd) {
113+
if !t.focused {
114+
return t, nil
115+
}
116+
117+
switch msg := msg.(type) {
118+
case tea.KeyMsg:
119+
switch msg.String() {
120+
case "enter":
121+
// Insert new line
122+
currentLine := t.lines[t.cursorRow]
123+
beforeCursor := currentLine[:t.cursorCol]
124+
afterCursor := currentLine[t.cursorCol:]
125+
126+
t.lines[t.cursorRow] = beforeCursor
127+
newLines := make([]string, len(t.lines)+1)
128+
copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1])
129+
newLines[t.cursorRow+1] = afterCursor
130+
copy(newLines[t.cursorRow+2:], t.lines[t.cursorRow+1:])
131+
t.lines = newLines
132+
133+
t.cursorRow++
134+
t.cursorCol = 0
135+
136+
case "tab":
137+
// Insert 2 spaces for indentation
138+
currentLine := t.lines[t.cursorRow]
139+
t.lines[t.cursorRow] = currentLine[:t.cursorCol] + " " + currentLine[t.cursorCol:]
140+
t.cursorCol += 2
141+
142+
case "backspace":
143+
if t.cursorCol > 0 {
144+
// Remove character
145+
currentLine := t.lines[t.cursorRow]
146+
t.lines[t.cursorRow] = currentLine[:t.cursorCol-1] + currentLine[t.cursorCol:]
147+
t.cursorCol--
148+
} else if t.cursorRow > 0 {
149+
// Join with previous line
150+
prevLine := t.lines[t.cursorRow-1]
151+
currentLine := t.lines[t.cursorRow]
152+
t.lines[t.cursorRow-1] = prevLine + currentLine
153+
154+
newLines := make([]string, len(t.lines)-1)
155+
copy(newLines[:t.cursorRow], t.lines[:t.cursorRow])
156+
copy(newLines[t.cursorRow:], t.lines[t.cursorRow+1:])
157+
t.lines = newLines
158+
159+
t.cursorRow--
160+
t.cursorCol = len(prevLine)
161+
}
162+
163+
case "delete":
164+
if t.cursorCol < len(t.lines[t.cursorRow]) {
165+
// Remove character
166+
currentLine := t.lines[t.cursorRow]
167+
t.lines[t.cursorRow] = currentLine[:t.cursorCol] + currentLine[t.cursorCol+1:]
168+
} else if t.cursorRow < len(t.lines)-1 {
169+
// Join with next line
170+
currentLine := t.lines[t.cursorRow]
171+
nextLine := t.lines[t.cursorRow+1]
172+
t.lines[t.cursorRow] = currentLine + nextLine
173+
174+
newLines := make([]string, len(t.lines)-1)
175+
copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1])
176+
copy(newLines[t.cursorRow+1:], t.lines[t.cursorRow+2:])
177+
t.lines = newLines
178+
}
179+
180+
case "up":
181+
t.moveCursor(t.cursorRow-1, t.cursorCol)
182+
case "down":
183+
t.moveCursor(t.cursorRow+1, t.cursorCol)
184+
case "left":
185+
if t.cursorCol > 0 {
186+
t.cursorCol--
187+
} else if t.cursorRow > 0 {
188+
t.cursorRow--
189+
t.cursorCol = len(t.lines[t.cursorRow])
190+
}
191+
case "right":
192+
if t.cursorCol < len(t.lines[t.cursorRow]) {
193+
t.cursorCol++
194+
} else if t.cursorRow < len(t.lines)-1 {
195+
t.cursorRow++
196+
t.cursorCol = 0
197+
}
198+
case "home":
199+
t.cursorCol = 0
200+
case "end":
201+
t.cursorCol = len(t.lines[t.cursorRow])
202+
203+
default:
204+
// Insert printable characters
205+
if len(msg.String()) == 1 && msg.String() >= " " {
206+
char := msg.String()
207+
currentLine := t.lines[t.cursorRow]
208+
t.lines[t.cursorRow] = currentLine[:t.cursorCol] + char + currentLine[t.cursorCol:]
209+
t.cursorCol++
210+
}
211+
}
212+
}
213+
214+
return t, nil
215+
}
216+
217+
func (t Textarea) View() string {
218+
labelStyle := styles.TitleStyle.Copy().
219+
Width(12).
220+
Align(lipgloss.Right)
221+
222+
// Use helper method for consistent width calculation
223+
containerWidth := t.width - 12 - 1 - 2
224+
if containerWidth < 20 {
225+
containerWidth = 20
226+
}
227+
228+
// Create the textarea container
229+
containerHeight := t.height
230+
if containerHeight < 3 {
231+
containerHeight = 3
232+
}
233+
234+
container := styles.ListItemStyle.Copy().
235+
Width(containerWidth).
236+
Height(containerHeight).
237+
Border(lipgloss.RoundedBorder()).
238+
BorderForeground(styles.Secondary).
239+
Padding(0, 1)
240+
241+
if t.focused {
242+
container = container.BorderForeground(styles.Primary)
243+
}
244+
245+
// Prepare visible lines with cursor
246+
visibleLines := make([]string, containerHeight-2) // Account for border
247+
for i := 0; i < len(visibleLines); i++ {
248+
lineIndex := i // No scrolling for now
249+
if lineIndex < len(t.lines) {
250+
line := t.lines[lineIndex]
251+
252+
// Add cursor if this is the cursor row and textarea is focused
253+
if t.focused && lineIndex == t.cursorRow {
254+
if t.cursorCol <= len(line) {
255+
line = line[:t.cursorCol] + "│" + line[t.cursorCol:]
256+
}
257+
}
258+
259+
// Lines should already be wrapped, no need to truncate
260+
261+
visibleLines[i] = line
262+
} else {
263+
visibleLines[i] = ""
264+
}
265+
}
266+
267+
content := strings.Join(visibleLines, "\n")
268+
textareaView := container.Render(content)
269+
270+
return lipgloss.JoinHorizontal(
271+
lipgloss.Top,
272+
labelStyle.Render(t.label+":"),
273+
" ",
274+
textareaView,
275+
)
276+
}
277+
278+
func (t Textarea) getContentWidth() int {
279+
// Calculate content width (accounting for label, colon, spacing, border, padding)
280+
containerWidth := t.width - 12 - 1 - 2 // label + colon + spacing
281+
if containerWidth < 20 {
282+
containerWidth = 20
283+
}
284+
contentWidth := containerWidth - 4 // border + padding
285+
if contentWidth < 10 {
286+
contentWidth = 10
287+
}
288+
return contentWidth
289+
}
290+
291+
func (t Textarea) wrapLine(line string, maxWidth int) []string {
292+
if len(line) <= maxWidth {
293+
return []string{line}
294+
}
295+
296+
var wrapped []string
297+
for len(line) > maxWidth {
298+
// Find the best place to break (prefer spaces)
299+
breakPoint := maxWidth
300+
for i := maxWidth - 1; i >= maxWidth-20 && i >= 0; i-- {
301+
if line[i] == ' ' {
302+
breakPoint = i
303+
break
304+
}
305+
}
306+
307+
wrapped = append(wrapped, line[:breakPoint])
308+
line = line[breakPoint:]
309+
310+
// Skip leading space on continuation lines
311+
if len(line) > 0 && line[0] == ' ' {
312+
line = line[1:]
313+
}
314+
}
315+
316+
if len(line) > 0 {
317+
wrapped = append(wrapped, line)
318+
}
319+
320+
return wrapped
321+
}
322+

0 commit comments

Comments
 (0)