ui.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. package main
  2. import (
  3. "fmt"
  4. "io"
  5. "github.com/charmbracelet/bubbles/key"
  6. "github.com/charmbracelet/bubbles/list"
  7. "github.com/charmbracelet/lipgloss"
  8. )
  9. // Constants for UI dimensions
  10. const (
  11. minEmailWidth = 50
  12. maxEmailWidth = 70
  13. minLoadingWidth = 40
  14. maxLoadingWidth = 60
  15. inputCharLimit = 256
  16. inputWidth = 50
  17. listHeightOffset = 2
  18. )
  19. // Color scheme
  20. var (
  21. primaryColor = lipgloss.Color("205")
  22. secondaryColor = lipgloss.Color("240")
  23. successColor = lipgloss.Color("42")
  24. subtleColor = lipgloss.Color("241")
  25. borderColor = lipgloss.Color("236")
  26. errorColor = lipgloss.Color("196")
  27. )
  28. // UI style definitions grouped by purpose
  29. var (
  30. // Text styles
  31. titleStyle = lipgloss.NewStyle().
  32. Bold(true).
  33. Foreground(primaryColor).
  34. MarginBottom(1)
  35. labelStyle = lipgloss.NewStyle().
  36. Foreground(secondaryColor).
  37. MarginRight(1)
  38. helpTextStyle = lipgloss.NewStyle().
  39. Foreground(subtleColor).
  40. Italic(true).
  41. MarginTop(1)
  42. loadingStyle = lipgloss.NewStyle().
  43. Foreground(primaryColor).
  44. Margin(1, 0)
  45. errorStyle = lipgloss.NewStyle().
  46. Foreground(errorColor).
  47. Bold(true)
  48. // Container styles
  49. containerStyle = lipgloss.NewStyle().
  50. Border(lipgloss.RoundedBorder()).
  51. BorderForeground(borderColor).
  52. Background(lipgloss.NoColor{}).
  53. Padding(1, 2).
  54. Margin(1, 0)
  55. // Checkbox styles
  56. checkboxStyle = lipgloss.NewStyle().
  57. Foreground(primaryColor).
  58. MarginRight(1)
  59. checkboxCheckedStyle = lipgloss.NewStyle().
  60. Foreground(successColor).
  61. MarginRight(1)
  62. // Status styles
  63. statusConnectedStyle = lipgloss.NewStyle().
  64. Foreground(successColor).
  65. Bold(true)
  66. statusDisconnectedStyle = lipgloss.NewStyle().
  67. Foreground(subtleColor)
  68. )
  69. // listKeyMap defines custom key bindings for the list view.
  70. type listKeyMap struct {
  71. connect key.Binding
  72. disconnect key.Binding
  73. disconnAll key.Binding
  74. refresh key.Binding
  75. }
  76. var customKeys = listKeyMap{
  77. connect: key.NewBinding(
  78. key.WithKeys("enter"),
  79. key.WithHelp("enter", "connect"),
  80. ),
  81. disconnect: key.NewBinding(
  82. key.WithKeys("d"),
  83. key.WithHelp("d", "disconnect"),
  84. ),
  85. disconnAll: key.NewBinding(
  86. key.WithKeys("ctrl+x"),
  87. key.WithHelp("ctrl+x", "disconnect all"),
  88. ),
  89. refresh: key.NewBinding(
  90. key.WithKeys("r"),
  91. key.WithHelp("r", "refresh"),
  92. ),
  93. }
  94. // item represents a connection item in the list view.
  95. type item struct {
  96. name string
  97. address string
  98. connected bool
  99. connType string
  100. }
  101. // Title returns the display title for the item, with a bullet indicator if connected.
  102. func (i item) Title() string {
  103. if i.connected {
  104. return "● " + i.name
  105. }
  106. return i.name
  107. }
  108. // Description returns the formatted description showing the address and connection status.
  109. func (i item) Description() string {
  110. addressStyle := statusDisconnectedStyle
  111. prefix := ""
  112. if i.connType != "" {
  113. prefix = "[" + i.connType + "] "
  114. }
  115. if i.connected {
  116. addressStyle = statusConnectedStyle
  117. return addressStyle.Render(prefix+i.address) + " " + statusConnectedStyle.Render("• Connected")
  118. }
  119. return addressStyle.Render(prefix + i.address)
  120. }
  121. // FilterValue returns the value used for filtering items in the list.
  122. func (i item) FilterValue() string { return i.name }
  123. // customDelegate provides custom rendering for list items with connection status styling.
  124. type customDelegate struct {
  125. list.DefaultDelegate
  126. }
  127. // Render renders a list item with custom styling based on selection and connection status.
  128. func (d customDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
  129. itm, ok := listItem.(item)
  130. if !ok {
  131. d.DefaultDelegate.Render(w, m, index, listItem)
  132. return
  133. }
  134. isSelected := index == m.Index()
  135. titleStyle := d.getTitleStyle(isSelected, itm.connected)
  136. title := titleStyle.Render(itm.Title())
  137. descStyle := d.Styles.NormalDesc
  138. if isSelected {
  139. descStyle = d.Styles.SelectedDesc
  140. }
  141. desc := descStyle.Render(itm.Description())
  142. fmt.Fprint(w, lipgloss.JoinVertical(lipgloss.Left, title, desc))
  143. }
  144. // getTitleStyle returns the appropriate title style based on selection and connection status.
  145. func (d customDelegate) getTitleStyle(isSelected, isConnected bool) lipgloss.Style {
  146. baseStyle := lipgloss.NewStyle().
  147. Background(lipgloss.NoColor{}).
  148. Padding(0, 0, 0, 1)
  149. if isSelected {
  150. selectedStyle := d.Styles.SelectedTitle.
  151. Border(lipgloss.NormalBorder(), false, false, false, true).
  152. BorderForeground(primaryColor).
  153. Bold(true).
  154. Background(lipgloss.NoColor{}).
  155. Padding(0, 0, 0, 1)
  156. if isConnected {
  157. return selectedStyle.Foreground(successColor)
  158. }
  159. return selectedStyle.Foreground(primaryColor)
  160. }
  161. // Normal (unselected) item
  162. if isConnected {
  163. return baseStyle.
  164. Foreground(successColor).
  165. Bold(true)
  166. }
  167. return baseStyle.Foreground(lipgloss.Color("252"))
  168. }
  169. // newList creates and configures a new list model with custom styling.
  170. func newList(items []list.Item, width, height int) list.Model {
  171. delegate := customDelegate{DefaultDelegate: list.NewDefaultDelegate()}
  172. delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.
  173. Border(lipgloss.NormalBorder(), false, false, false, true).
  174. BorderForeground(primaryColor).
  175. Foreground(primaryColor).
  176. Bold(true).
  177. Background(lipgloss.NoColor{}).
  178. Padding(0, 0, 0, 1)
  179. delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.
  180. Border(lipgloss.NormalBorder(), false, false, false, true).
  181. BorderForeground(primaryColor).
  182. Foreground(secondaryColor).
  183. Background(lipgloss.NoColor{}).
  184. Padding(0, 0, 0, 1)
  185. delegate.Styles.NormalDesc = delegate.Styles.NormalDesc.
  186. Foreground(subtleColor).
  187. Background(lipgloss.NoColor{}).
  188. Padding(0, 0, 0, 1)
  189. l := list.New(items, delegate, width, height)
  190. l.Title = ""
  191. l.SetShowStatusBar(false)
  192. l.SetShowPagination(false)
  193. l.SetFilteringEnabled(true)
  194. l.SetShowHelp(true)
  195. l.AdditionalShortHelpKeys = func() []key.Binding {
  196. return []key.Binding{
  197. customKeys.connect,
  198. customKeys.disconnect,
  199. customKeys.disconnAll,
  200. customKeys.refresh,
  201. }
  202. }
  203. l.Styles.StatusBar = lipgloss.NewStyle().
  204. Foreground(subtleColor).
  205. Background(lipgloss.NoColor{}).
  206. MarginTop(1)
  207. l.Styles.Title = lipgloss.NewStyle().
  208. Background(lipgloss.NoColor{})
  209. l.Styles.FilterPrompt = lipgloss.NewStyle().
  210. Background(lipgloss.NoColor{})
  211. l.Styles.FilterCursor = lipgloss.NewStyle().
  212. Background(lipgloss.NoColor{})
  213. return l
  214. }
  215. // isQuitKey checks if the given key string represents a quit command.
  216. func isQuitKey(key string) bool {
  217. return key == "q" || key == "ctrl+c" || key == "esc"
  218. }
  219. // renderEmailPrompt renders the email authentication prompt view.
  220. func renderEmailPrompt(m model) string {
  221. if m.loggingIn {
  222. title := titleStyle.Render("Authenticating...")
  223. spinnerText := loadingStyle.Render(m.spinner.View() + " Opening browser for authentication...")
  224. content := lipgloss.JoinVertical(lipgloss.Center,
  225. "",
  226. title,
  227. "",
  228. spinnerText,
  229. "",
  230. )
  231. width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
  232. return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
  233. containerStyle.Width(width).Align(lipgloss.Center).Render(content))
  234. }
  235. checkbox := checkboxStyle.Render("[ ]")
  236. if m.rememberEmail {
  237. checkbox = checkboxCheckedStyle.Render("[x]")
  238. }
  239. title := titleStyle.Render("Authentication Required")
  240. message := labelStyle.Render("You are not authenticated. Please login.")
  241. emailLabel := labelStyle.Render("Email:")
  242. emailInput := m.emailInput.View()
  243. rememberText := checkbox + labelStyle.Render("Remember email (press Space to toggle)")
  244. helpText := helpTextStyle.Render("Press Enter to login, Esc to quit")
  245. content := lipgloss.JoinVertical(lipgloss.Left,
  246. title,
  247. "",
  248. message,
  249. "",
  250. emailLabel+emailInput,
  251. "",
  252. rememberText,
  253. "",
  254. helpText,
  255. )
  256. width := clampWidth(m.width, minEmailWidth, maxEmailWidth)
  257. return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
  258. containerStyle.Width(width).Render(content))
  259. }
  260. // renderLoading renders the loading spinner view.
  261. func renderLoading(m model) string {
  262. title := titleStyle.Render("Loading Connections")
  263. spinnerText := loadingStyle.Render(m.spinner.View() + " Loading connections...")
  264. content := lipgloss.JoinVertical(lipgloss.Center,
  265. "",
  266. title,
  267. "",
  268. spinnerText,
  269. "",
  270. )
  271. width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
  272. return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
  273. containerStyle.Width(width).Align(lipgloss.Center).Render(content))
  274. }
  275. // renderError renders the error view with retry and quit options.
  276. func renderError(m model) string {
  277. title := titleStyle.Render("Error")
  278. errText := errorStyle.Render(m.err)
  279. helpText := helpTextStyle.Render("Press r to retry, q/Esc to quit")
  280. content := lipgloss.JoinVertical(lipgloss.Center,
  281. "",
  282. title,
  283. "",
  284. errText,
  285. "",
  286. helpText,
  287. "",
  288. )
  289. width := clampWidth(m.width, minLoadingWidth, maxLoadingWidth)
  290. return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
  291. containerStyle.Width(width).Align(lipgloss.Center).Render(content))
  292. }
  293. // clampWidth constrains the width value between min and max.
  294. func clampWidth(width, min, max int) int {
  295. if width > max {
  296. return max
  297. }
  298. if width < min {
  299. return min
  300. }
  301. return width
  302. }