on main help sign in
chore: cleanup image component
597fd7fKujtim Hoxha
cspell.json+1−1
@@ -1 +1 @@
1−{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph"],"version":"0.2","language":"en","flagWords":[]}
1+{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos"],"version":"0.2"}
internal/tui/components/dialog/filepicker.godeleted−468
@@ -1,468 +0,0 @@
1−package dialog
2−
3−// import (
4−// "fmt"
5−// "net/http"
6−// "os"
7−// "path/filepath"
8−// "sort"
9−// "strings"
10−// "time"
11−//
12−// "github.com/charmbracelet/bubbles/v2/key"
13−// "github.com/charmbracelet/bubbles/v2/textinput"
14−// "github.com/charmbracelet/bubbles/v2/viewport"
15−// tea "github.com/charmbracelet/bubbletea/v2"
16−// "github.com/charmbracelet/lipgloss/v2"
17−// "github.com/opencode-ai/opencode/internal/app"
18−// "github.com/opencode-ai/opencode/internal/logging"
19−// "github.com/opencode-ai/opencode/internal/message"
20−// "github.com/opencode-ai/opencode/internal/tui/image"
21−// "github.com/opencode-ai/opencode/internal/tui/styles"
22−// "github.com/opencode-ai/opencode/internal/tui/util"
23−// )
24−//
25−// const (
26−// maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
27−// downArrow = "down"
28−// upArrow = "up"
29−// )
30−//
31−// type FilePrickerKeyMap struct {
32−// Enter key.Binding
33−// Down key.Binding
34−// Up key.Binding
35−// Forward key.Binding
36−// Backward key.Binding
37−// OpenFilePicker key.Binding
38−// Esc key.Binding
39−// InsertCWD key.Binding
40−// }
41−//
42−// var filePickerKeyMap = FilePrickerKeyMap{
43−// Enter: key.NewBinding(
44−// key.WithKeys("enter"),
45−// key.WithHelp("enter", "select file/enter directory"),
46−// ),
47−// Down: key.NewBinding(
48−// key.WithKeys("j", downArrow),
49−// key.WithHelp("↓/j", "down"),
50−// ),
51−// Up: key.NewBinding(
52−// key.WithKeys("k", upArrow),
53−// key.WithHelp("↑/k", "up"),
54−// ),
55−// Forward: key.NewBinding(
56−// key.WithKeys("l"),
57−// key.WithHelp("l", "enter directory"),
58−// ),
59−// Backward: key.NewBinding(
60−// key.WithKeys("h", "backspace"),
61−// key.WithHelp("h/backspace", "go back"),
62−// ),
63−// OpenFilePicker: key.NewBinding(
64−// key.WithKeys("ctrl+f"),
65−// key.WithHelp("ctrl+f", "open file picker"),
66−// ),
67−// Esc: key.NewBinding(
68−// key.WithKeys("esc"),
69−// key.WithHelp("esc", "close/exit"),
70−// ),
71−// InsertCWD: key.NewBinding(
72−// key.WithKeys("i"),
73−// key.WithHelp("i", "manual path input"),
74−// ),
75−// }
76−//
77−// type filepickerCmp struct {
78−// basePath string
79−// width int
80−// height int
81−// cursor int
82−// err error
83−// cursorChain stack
84−// viewport viewport.Model
85−// dirs []os.DirEntry
86−// cwdDetails *DirNode
87−// selectedFile string
88−// cwd textinput.Model
89−// ShowFilePicker bool
90−// app *app.App
91−// }
92−//
93−// type DirNode struct {
94−// parent *DirNode
95−// child *DirNode
96−// directory string
97−// }
98−// type stack []int
99−//
100−// func (s stack) Push(v int) stack {
101−// return append(s, v)
102−// }
103−//
104−// func (s stack) Pop() (stack, int) {
105−// l := len(s)
106−// return s[:l-1], s[l-1]
107−// }
108−//
109−// type AttachmentAddedMsg struct {
110−// Attachment message.Attachment
111−// }
112−//
113−// func (f *filepickerCmp) Init() tea.Cmd {
114−// return nil
115−// }
116−//
117−// func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118−// var cmd tea.Cmd
119−// switch msg := msg.(type) {
120−// case tea.WindowSizeMsg:
121−// f.width = 60
122−// f.height = 20
123−// f.viewport.SetWidth(80)
124−// f.viewport.SetHeight(22)
125−// f.cursor = 0
126−// f.getCurrentFileBelowCursor()
127−// case tea.KeyPressMsg:
128−// if f.cwd.Focused() {
129−// f.cwd, cmd = f.cwd.Update(msg)
130−// }
131−// switch {
132−// case key.Matches(msg, filePickerKeyMap.InsertCWD):
133−// f.cwd.Focus()
134−// return f, cmd
135−// case key.Matches(msg, filePickerKeyMap.Esc):
136−// if f.cwd.Focused() {
137−// f.cwd.Blur()
138−// }
139−// case key.Matches(msg, filePickerKeyMap.Down):
140−// if !f.cwd.Focused() || msg.String() == downArrow {
141−// if f.cursor < len(f.dirs)-1 {
142−// f.cursor++
143−// f.getCurrentFileBelowCursor()
144−// }
145−// }
146−// case key.Matches(msg, filePickerKeyMap.Up):
147−// if !f.cwd.Focused() || msg.String() == upArrow {
148−// if f.cursor > 0 {
149−// f.cursor--
150−// f.getCurrentFileBelowCursor()
151−// }
152−// }
153−// case key.Matches(msg, filePickerKeyMap.Enter):
154−// var path string
155−// var isPathDir bool
156−// if f.cwd.Focused() {
157−// path = f.cwd.Value()
158−// fileInfo, err := os.Stat(path)
159−// if err != nil {
160−// logging.ErrorPersist("Invalid path")
161−// return f, cmd
162−// }
163−// isPathDir = fileInfo.IsDir()
164−// } else {
165−// path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
166−// isPathDir = f.dirs[f.cursor].IsDir()
167−// }
168−// if isPathDir {
169−// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
170−// f.cwdDetails.child = &newWorkingDir
171−// f.cwdDetails = f.cwdDetails.child
172−// f.cursorChain = f.cursorChain.Push(f.cursor)
173−// f.dirs = readDir(f.cwdDetails.directory, false)
174−// f.cursor = 0
175−// f.cwd.SetValue(f.cwdDetails.directory)
176−// f.getCurrentFileBelowCursor()
177−// } else {
178−// f.selectedFile = path
179−// return f.addAttachmentToMessage()
180−// }
181−// case key.Matches(msg, filePickerKeyMap.Esc):
182−// if !f.cwd.Focused() {
183−// f.cursorChain = make(stack, 0)
184−// f.cursor = 0
185−// } else {
186−// f.cwd.Blur()
187−// }
188−// case key.Matches(msg, filePickerKeyMap.Forward):
189−// if !f.cwd.Focused() {
190−// if f.dirs[f.cursor].IsDir() {
191−// path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
192−// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
193−// f.cwdDetails.child = &newWorkingDir
194−// f.cwdDetails = f.cwdDetails.child
195−// f.cursorChain = f.cursorChain.Push(f.cursor)
196−// f.dirs = readDir(f.cwdDetails.directory, false)
197−// f.cursor = 0
198−// f.cwd.SetValue(f.cwdDetails.directory)
199−// f.getCurrentFileBelowCursor()
200−// }
201−// }
202−// case key.Matches(msg, filePickerKeyMap.Backward):
203−// if !f.cwd.Focused() {
204−// if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
205−// f.cursorChain, f.cursor = f.cursorChain.Pop()
206−// f.cwdDetails = f.cwdDetails.parent
207−// f.cwdDetails.child = nil
208−// f.dirs = readDir(f.cwdDetails.directory, false)
209−// f.cwd.SetValue(f.cwdDetails.directory)
210−// f.getCurrentFileBelowCursor()
211−// }
212−// }
213−// case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
214−// f.dirs = readDir(f.cwdDetails.directory, false)
215−// f.cursor = 0
216−// f.getCurrentFileBelowCursor()
217−// }
218−// }
219−// return f, cmd
220−// }
221−//
222−// func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
223−// // modeInfo := GetSelectedModel(config.Get())
224−// // if !modeInfo.SupportsAttachments {
225−// // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
226−// // return f, nil
227−// // }
228−//
229−// selectedFilePath := f.selectedFile
230−// if !isExtSupported(selectedFilePath) {
231−// logging.ErrorPersist("Unsupported file")
232−// return f, nil
233−// }
234−//
235−// isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
236−// if err != nil {
237−// logging.ErrorPersist("unable to read the image")
238−// return f, nil
239−// }
240−// if isFileLarge {
241−// logging.ErrorPersist("file too large, max 5MB")
242−// return f, nil
243−// }
244−//
245−// content, err := os.ReadFile(selectedFilePath)
246−// if err != nil {
247−// logging.ErrorPersist("Unable read selected file")
248−// return f, nil
249−// }
250−//
251−// mimeBufferSize := min(512, len(content))
252−// mimeType := http.DetectContentType(content[:mimeBufferSize])
253−// fileName := filepath.Base(selectedFilePath)
254−// attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
255−// f.selectedFile = ""
256−// return f, util.CmdHandler(AttachmentAddedMsg{attachment})
257−// }
258−//
259−// func (f *filepickerCmp) View() tea.View {
260−// t := styles.CurrentTheme()
261−// baseStyle := t.S().Base
262−// const maxVisibleDirs = 20
263−// const maxWidth = 80
264−//
265−// adjustedWidth := maxWidth
266−// for _, file := range f.dirs {
267−// if len(file.Name()) > adjustedWidth-4 { // Account for padding
268−// adjustedWidth = len(file.Name()) + 4
269−// }
270−// }
271−// adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
272−//
273−// files := make([]string, 0, maxVisibleDirs)
274−// startIdx := 0
275−//
276−// if len(f.dirs) > maxVisibleDirs {
277−// halfVisible := maxVisibleDirs / 2
278−// if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
279−// startIdx = f.cursor - halfVisible
280−// } else if f.cursor >= len(f.dirs)-halfVisible {
281−// startIdx = len(f.dirs) - maxVisibleDirs
282−// }
283−// }
284−//
285−// endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
286−//
287−// for i := startIdx; i < endIdx; i++ {
288−// file := f.dirs[i]
289−// itemStyle := t.S().Text.Width(adjustedWidth)
290−//
291−// if i == f.cursor {
292−// itemStyle = itemStyle.
293−// Background(t.Primary).
294−// Bold(true)
295−// }
296−// filename := file.Name()
297−//
298−// if len(filename) > adjustedWidth-4 {
299−// filename = filename[:adjustedWidth-7] + "..."
300−// }
301−// if file.IsDir() {
302−// filename = filename + "/"
303−// }
304−// // No need to reassign filename if it's not changing
305−//
306−// files = append(files, itemStyle.Padding(0, 1).Render(filename))
307−// }
308−//
309−// // Pad to always show exactly 21 lines
310−// for len(files) < maxVisibleDirs {
311−// files = append(files, baseStyle.Width(adjustedWidth).Render(""))
312−// }
313−//
314−// currentPath := baseStyle.
315−// Height(1).
316−// Width(adjustedWidth).
317−// Render(f.cwd.View())
318−//
319−// viewportstyle := baseStyle.
320−// Width(f.viewport.Width()).
321−// Border(lipgloss.RoundedBorder()).
322−// BorderForeground(t.BorderFocus).
323−// Padding(2).
324−// Render(f.viewport.View())
325−// var insertExitText string
326−// if f.IsCWDFocused() {
327−// insertExitText = "Press esc to exit typing path"
328−// } else {
329−// insertExitText = "Press i to start typing path"
330−// }
331−//
332−// content := lipgloss.JoinVertical(
333−// lipgloss.Left,
334−// currentPath,
335−// baseStyle.Width(adjustedWidth).Render(""),
336−// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
337−// baseStyle.Width(adjustedWidth).Render(""),
338−// t.S().Muted.Width(adjustedWidth).Render(insertExitText),
339−// )
340−//
341−// f.cwd.SetValue(f.cwd.Value())
342−// contentStyle := baseStyle.Padding(1, 2).
343−// Border(lipgloss.RoundedBorder()).
344−// BorderForeground(t.BorderFocus).
345−// Width(lipgloss.Width(content) + 4)
346−//
347−// return tea.NewView(
348−// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
349−// )
350−// }
351−//
352−// type FilepickerCmp interface {
353−// util.Model
354−// ToggleFilepicker(showFilepicker bool)
355−// IsCWDFocused() bool
356−// }
357−//
358−// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
359−// f.ShowFilePicker = showFilepicker
360−// }
361−//
362−// func (f *filepickerCmp) IsCWDFocused() bool {
363−// return f.cwd.Focused()
364−// }
365−//
366−// func NewFilepickerCmp(app *app.App) FilepickerCmp {
367−// homepath, err := os.UserHomeDir()
368−// if err != nil {
369−// logging.Error("error loading user files")
370−// return nil
371−// }
372−// baseDir := DirNode{parent: nil, directory: homepath}
373−// dirs := readDir(homepath, false)
374−// viewport := viewport.New()
375−// currentDirectory := textinput.New()
376−// currentDirectory.CharLimit = 200
377−// currentDirectory.SetWidth(44)
378−// currentDirectory.Cursor().Blink = true
379−// currentDirectory.SetValue(baseDir.directory)
380−// return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
381−// }
382−//
383−// func (f *filepickerCmp) getCurrentFileBelowCursor() {
384−// if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
385−// logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
386−// f.viewport.SetContent("Preview unavailable")
387−// return
388−// }
389−//
390−// dir := f.dirs[f.cursor]
391−// filename := dir.Name()
392−// if !dir.IsDir() && isExtSupported(filename) {
393−// fullPath := f.cwdDetails.directory + "/" + dir.Name()
394−//
395−// go func() {
396−// imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
397−// if err != nil {
398−// logging.Error(err.Error())
399−// f.viewport.SetContent("Preview unavailable")
400−// return
401−// }
402−//
403−// f.viewport.SetContent(imageString)
404−// }()
405−// } else {
406−// f.viewport.SetContent("Preview unavailable")
407−// }
408−// }
409−//
410−// func readDir(path string, showHidden bool) []os.DirEntry {
411−// logging.Info(fmt.Sprintf("Reading directory: %s", path))
412−//
413−// entriesChan := make(chan []os.DirEntry, 1)
414−// errChan := make(chan error, 1)
415−//
416−// go func() {
417−// dirEntries, err := os.ReadDir(path)
418−// if err != nil {
419−// logging.ErrorPersist(err.Error())
420−// errChan <- err
421−// return
422−// }
423−// entriesChan <- dirEntries
424−// }()
425−//
426−// select {
427−// case dirEntries := <-entriesChan:
428−// sort.Slice(dirEntries, func(i, j int) bool {
429−// if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
430−// return dirEntries[i].Name() < dirEntries[j].Name()
431−// }
432−// return dirEntries[i].IsDir()
433−// })
434−//
435−// if showHidden {
436−// return dirEntries
437−// }
438−//
439−// var sanitizedDirEntries []os.DirEntry
440−// for _, dirEntry := range dirEntries {
441−// isHidden, _ := IsHidden(dirEntry.Name())
442−// if !isHidden {
443−// if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
444−// sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
445−// }
446−// }
447−// }
448−//
449−// return sanitizedDirEntries
450−//
451−// case err := <-errChan:
452−// logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
453−// return []os.DirEntry{}
454−//
455−// case <-time.After(5 * time.Second):
456−// logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
457−// return []os.DirEntry{}
458−// }
459−// }
460−//
461−// func IsHidden(file string) (bool, error) {
462−// return strings.HasPrefix(file, "."), nil
463−// }
464−//
465−// func isExtSupported(path string) bool {
466−// ext := strings.ToLower(filepath.Ext(path))
467−// return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
468−// }
internal/tui/components/dialogs/filepicker/filepicker.go+41−24
@@ -5,12 +5,14 @@ import (
5 "strings"
5 "strings"
6
6
7 "github.com/charmbracelet/bubbles/v2/filepicker"
7 "github.com/charmbracelet/bubbles/v2/filepicker"
8+ "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/opencode-ai/opencode/internal/tui/components/core"
11 "github.com/opencode-ai/opencode/internal/tui/components/core"
11 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
12 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
12 "github.com/opencode-ai/opencode/internal/tui/components/image"
13 "github.com/opencode-ai/opencode/internal/tui/components/image"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/styles"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
14)
16)
15
17
16const (
18const (
@@ -19,16 +21,19 @@ const (
19 fileSelectionHight = 10
21 fileSelectionHight = 10
20)
22)
21
23
24+type FilePickedMsg struct {
25+ FilePath string
26+}
27+
22type FilePicker interface {
28type FilePicker interface {
23 dialogs.DialogModel
29 dialogs.DialogModel
24}
30}
25
31
26−type filePicker struct {
32+type model struct {
27 wWidth int
33 wWidth int
28 wHeight int
34 wHeight int
29 width int
35 width int
30− filepicker filepicker.Model
36+ filePicker filepicker.Model
31− selectedFile string
32 highlightedFile string
37 highlightedFile string
33 image image.Model
38 image image.Model
34}
39}
@@ -46,31 +51,40 @@ func NewFilePickerCmp() FilePicker {
46 fp.SetHeight(fileSelectionHight)
51 fp.SetHeight(fileSelectionHight)
47
52
48 image := image.New(1, 1, "")
53 image := image.New(1, 1, "")
49− return &filePicker{filepicker: fp, image: image}
54+ return &model{filePicker: fp, image: image}
50}
55}
51
56
52−func (m *filePicker) Init() tea.Cmd {
57+func (m *model) Init() tea.Cmd {
53− return m.filepicker.Init()
58+ return m.filePicker.Init()
54}
59}
55
60
56−func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 switch msg := msg.(type) {
62 switch msg := msg.(type) {
58 case tea.WindowSizeMsg:
63 case tea.WindowSizeMsg:
59 m.wWidth = msg.Width
64 m.wWidth = msg.Width
60 m.wHeight = msg.Height
65 m.wHeight = msg.Height
61 m.width = min(70, m.wWidth)
66 m.width = min(70, m.wWidth)
62− styles := m.filepicker.Styles
67+ styles := m.filePicker.Styles
63 styles.Directory = styles.Directory.Width(m.width - 4)
68 styles.Directory = styles.Directory.Width(m.width - 4)
64 styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
69 styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
65 styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
70 styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
66 styles.File = styles.File.Width(m.width)
71 styles.File = styles.File.Width(m.width)
67− m.filepicker.Styles = styles
72+ m.filePicker.Styles = styles
68 return m, nil
73 return m, nil
74+
75+ case tea.KeyPressMsg:
76+ if key.Matches(msg, m.filePicker.KeyMap.Back) {
77+ // make sure we don't go back if we are at the home directory
78+ homeDir, _ := os.UserHomeDir()
79+ if m.filePicker.CurrentDirectory == homeDir {
80+ return m, nil
81+ }
82+ }
69 }
83 }
70
84
71 var cmd tea.Cmd
85 var cmd tea.Cmd
72 var cmds []tea.Cmd
86 var cmds []tea.Cmd
73− m.filepicker, cmd = m.filepicker.Update(msg)
87+ m.filePicker, cmd = m.filePicker.Update(msg)
74 cmds = append(cmds, cmd)
88 cmds = append(cmds, cmd)
75 if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
89 if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
76 w, h := m.imagePreviewSize()
90 w, h := m.imagePreviewSize()
@@ -80,37 +94,40 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 m.highlightedFile = m.currentImage()
94 m.highlightedFile = m.currentImage()
81
95
82 // Did the user select a file?
96 // Did the user select a file?
83− if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
97+ if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
84 // Get the path of the selected file.
98 // Get the path of the selected file.
85− m.selectedFile = path
99+ return m, tea.Sequence(
100+ util.CmdHandler(dialogs.CloseDialogMsg{}),
101+ util.CmdHandler(FilePickedMsg{FilePath: path}),
102+ )
86 }
103 }
87 m.image, cmd = m.image.Update(msg)
104 m.image, cmd = m.image.Update(msg)
88 cmds = append(cmds, cmd)
105 cmds = append(cmds, cmd)
89 return m, tea.Batch(cmds...)
106 return m, tea.Batch(cmds...)
90}
107}
91
108
92−func (m *filePicker) View() tea.View {
109+func (m *model) View() tea.View {
93 t := styles.CurrentTheme()
110 t := styles.CurrentTheme()
94
111
95 content := lipgloss.JoinVertical(
112 content := lipgloss.JoinVertical(
96 lipgloss.Left,
113 lipgloss.Left,
97 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
114 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
98 m.imagePreview(),
115 m.imagePreview(),
99− m.filepicker.View(),
116+ m.filePicker.View(),
100 )
117 )
101 return tea.NewView(m.style().Render(content))
118 return tea.NewView(m.style().Render(content))
102}
119}
103
120
104−func (m *filePicker) currentImage() string {
121+func (m *model) currentImage() string {
105− for _, ext := range m.filepicker.AllowedTypes {
122+ for _, ext := range m.filePicker.AllowedTypes {
106− if strings.HasSuffix(m.filepicker.HighlightedPath(), ext) {
123+ if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
107− return m.filepicker.HighlightedPath()
124+ return m.filePicker.HighlightedPath()
108 }
125 }
109 }
126 }
110 return ""
127 return ""
111}
128}
112
129
113−func (m *filePicker) imagePreview() string {
130+func (m *model) imagePreview() string {
114 t := styles.CurrentTheme()
131 t := styles.CurrentTheme()
115 w, h := m.imagePreviewSize()
132 w, h := m.imagePreviewSize()
116 if m.currentImage() == "" {
133 if m.currentImage() == "" {
@@ -125,16 +142,16 @@ func (m *filePicker) imagePreview() string {
125 return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
142 return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
126}
143}
127
144
128−func (m *filePicker) imagePreviewStyle() lipgloss.Style {
145+func (m *model) imagePreviewStyle() lipgloss.Style {
129 t := styles.CurrentTheme()
146 t := styles.CurrentTheme()
130 return t.S().Base.Padding(1, 1, 1, 1)
147 return t.S().Base.Padding(1, 1, 1, 1)
131}
148}
132
149
133−func (m *filePicker) imagePreviewSize() (int, int) {
150+func (m *model) imagePreviewSize() (int, int) {
134 return m.width - 4, min(20, m.wHeight/2)
151 return m.width - 4, min(20, m.wHeight/2)
135}
152}
136
153
137−func (m *filePicker) style() lipgloss.Style {
154+func (m *model) style() lipgloss.Style {
138 t := styles.CurrentTheme()
155 t := styles.CurrentTheme()
139 return t.S().Base.
156 return t.S().Base.
140 Width(m.width).
157 Width(m.width).
@@ -143,12 +160,12 @@ func (m *filePicker) style() lipgloss.Style {
143}
160}
144
161
145// ID implements FilePicker.
162// ID implements FilePicker.
146−func (m *filePicker) ID() dialogs.DialogID {
163+func (m *model) ID() dialogs.DialogID {
147 return FilePickerID
164 return FilePickerID
148}
165}
149
166
150// Position implements FilePicker.
167// Position implements FilePicker.
151−func (m *filePicker) Position() (int, int) {
168+func (m *model) Position() (int, int) {
152 row := m.wHeight/4 - 2 // just a bit above the center
169 row := m.wHeight/4 - 2 // just a bit above the center
153 col := m.wWidth / 2
170 col := m.wWidth / 2
154 col -= m.width / 2
171 col -= m.width / 2
internal/tui/components/image/image.go+6−9
@@ -3,7 +3,6 @@
3package image
3package image
4
4
5import (
5import (
6− "context"
7 "fmt"
6 "fmt"
8 _ "image/jpeg"
7 _ "image/jpeg"
9 _ "image/png"
8 _ "image/png"
@@ -17,8 +16,6 @@ type Model struct {
17 width uint
16 width uint
18 height uint
17 height uint
19 err error
18 err error
20−
21− cancelAnimation context.CancelFunc
22}
19}
23
20
24func New(width, height uint, url string) Model {
21func New(width, height uint, url string) Model {
@@ -38,11 +35,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
38 case errMsg:
35 case errMsg:
39 m.err = msg
36 m.err = msg
40 return m, nil
37 return m, nil
41− case rewdrawMsg:
38+ case redrawMsg:
42 m.width = msg.width
39 m.width = msg.width
43 m.height = msg.height
40 m.height = msg.height
44 m.url = msg.url
41 m.url = msg.url
45− return m, loadUrl(m.url)
42+ return m, loadURL(m.url)
46 case loadMsg:
43 case loadMsg:
47 return handleLoadMsg(m, msg)
44 return handleLoadMsg(m, msg)
48 }
45 }
@@ -60,7 +57,7 @@ type errMsg struct{ error }
60
57
61func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
58func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
62 return func() tea.Msg {
59 return func() tea.Msg {
63− return rewdrawMsg{
60+ return redrawMsg{
64 width: width,
61 width: width,
65 height: height,
62 height: height,
66 url: url,
63 url: url,
@@ -68,9 +65,9 @@ func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
68 }
65 }
69}
66}
70
67
71−func (m Model) UpdateUrl(url string) tea.Cmd {
68+func (m Model) UpdateURL(url string) tea.Cmd {
72 return func() tea.Msg {
69 return func() tea.Msg {
73− return rewdrawMsg{
70+ return redrawMsg{
74 width: m.width,
71 width: m.width,
75 height: m.height,
72 height: m.height,
76 url: url,
73 url: url,
@@ -78,7 +75,7 @@ func (m Model) UpdateUrl(url string) tea.Cmd {
78 }
75 }
79}
76}
80
77
81−type rewdrawMsg struct {
78+type redrawMsg struct {
82 width uint
79 width uint
83 height uint
80 height uint
84 url string
81 url string
internal/tui/components/image/load.go+10−22
@@ -6,7 +6,6 @@ import (
6 "image"
6 "image"
7 "image/png"
7 "image/png"
8 "io"
8 "io"
9− "io/ioutil"
10 "net/http"
9 "net/http"
11 "os"
10 "os"
12 "strings"
11 "strings"
@@ -24,7 +23,7 @@ type loadMsg struct {
24 io.ReadCloser
23 io.ReadCloser
25}
24}
26
25
27−func loadUrl(url string) tea.Cmd {
26+func loadURL(url string) tea.Cmd {
28 var r io.ReadCloser
27 var r io.ReadCloser
29 var err error
28 var err error
30
29
@@ -52,20 +51,9 @@ func load(r io.ReadCloser) tea.Cmd {
52}
51}
53
52
54func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
53func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
55− if m.cancelAnimation != nil {
56− m.cancelAnimation()
57− }
58−
59− // blank out image so it says "loading..."
60− m.image = ""
61−
62− return handleLoadMsgStatic(m, msg)
63−}
64−
65−func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
66 defer msg.Close()
54 defer msg.Close()
67
55
68− img, err := readerToimage(m.width, m.height, m.url, msg)
56+ img, err := readerToImage(m.width, m.height, m.url, msg)
69 if err != nil {
57 if err != nil {
70 return m, func() tea.Msg { return errMsg{err} }
58 return m, func() tea.Msg { return errMsg{err} }
71 }
59 }
@@ -73,7 +61,7 @@ func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
73 return m, nil
61 return m, nil
74}
62}
75
63
76−func imageToString(width, height uint, url string, img image.Image) (string, error) {
64+func imageToString(width, height uint, img image.Image) (string, error) {
77 img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
65 img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
78 b := img.Bounds()
66 b := img.Bounds()
79 w := b.Max.X
67 w := b.Max.X
@@ -84,7 +72,7 @@ func imageToString(width, height uint, url string, img image.Image) (string, err
84 for x := w; x < int(width); x = x + 2 {
72 for x := w; x < int(width); x = x + 2 {
85 str.WriteString(" ")
73 str.WriteString(" ")
86 }
74 }
87− for x := 0; x < w; x++ {
75+ for x := range w {
88 c1, _ := colorful.MakeColor(img.At(x, y))
76 c1, _ := colorful.MakeColor(img.At(x, y))
89 color1 := p.Color(c1.Hex())
77 color1 := p.Color(c1.Hex())
90 c2, _ := colorful.MakeColor(img.At(x, y+1))
78 c2, _ := colorful.MakeColor(img.At(x, y+1))
@@ -99,9 +87,9 @@ func imageToString(width, height uint, url string, img image.Image) (string, err
99 return str.String(), nil
87 return str.String(), nil
100}
88}
101
89
102−func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) {
90+func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
103 if strings.HasSuffix(strings.ToLower(url), ".svg") {
91 if strings.HasSuffix(strings.ToLower(url), ".svg") {
104− return svgToimage(width, height, url, r)
92+ return svgToImage(width, height, r)
105 }
93 }
106
94
107 img, _, err := imageorient.Decode(r)
95 img, _, err := imageorient.Decode(r)
@@ -109,15 +97,15 @@ func readerToimage(width uint, height uint, url string, r io.Reader) (string, er
109 return "", err
97 return "", err
110 }
98 }
111
99
112− return imageToString(width, height, url, img)
100+ return imageToString(width, height, img)
113}
101}
114
102
115−func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) {
103+func svgToImage(width uint, height uint, r io.Reader) (string, error) {
116 // Original author: https://stackoverflow.com/users/10826783/usual-human
104 // Original author: https://stackoverflow.com/users/10826783/usual-human
117 // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
105 // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
118 // Adapted to use size from SVG, and to use temp file.
106 // Adapted to use size from SVG, and to use temp file.
119
107
120− tmpPngFile, err := ioutil.TempFile("", "imgcat.*.png")
108+ tmpPngFile, err := os.CreateTemp("", "img.*.png")
121 if err != nil {
109 if err != nil {
122 return "", err
110 return "", err
123 }
111 }
@@ -153,5 +141,5 @@ func svgToimage(width uint, height uint, url string, r io.Reader) (string, error
153 if err != nil {
141 if err != nil {
154 return "", err
142 return "", err
155 }
143 }
156− return imageToString(width, height, url, img)
144+ return imageToString(width, height, img)
157}
145}