select.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. package widget
  2. import (
  3. "image/color"
  4. "fyne.io/fyne/v2"
  5. "fyne.io/fyne/v2/canvas"
  6. "fyne.io/fyne/v2/driver/desktop"
  7. "fyne.io/fyne/v2/theme"
  8. )
  9. const defaultPlaceHolder string = "(Select one)"
  10. // Select widget has a list of options, with the current one shown, and triggers an event func when clicked
  11. type Select struct {
  12. DisableableWidget
  13. // Alignment sets the text alignment of the select and its list of options.
  14. //
  15. // Since: 2.1
  16. Alignment fyne.TextAlign
  17. Selected string
  18. Options []string
  19. PlaceHolder string
  20. OnChanged func(string) `json:"-"`
  21. focused bool
  22. hovered bool
  23. popUp *PopUpMenu
  24. tapAnim *fyne.Animation
  25. }
  26. var _ fyne.Widget = (*Select)(nil)
  27. var _ desktop.Hoverable = (*Select)(nil)
  28. var _ fyne.Tappable = (*Select)(nil)
  29. var _ fyne.Focusable = (*Select)(nil)
  30. var _ fyne.Disableable = (*Select)(nil)
  31. // NewSelect creates a new select widget with the set list of options and changes handler
  32. func NewSelect(options []string, changed func(string)) *Select {
  33. s := &Select{
  34. OnChanged: changed,
  35. Options: options,
  36. PlaceHolder: defaultPlaceHolder,
  37. }
  38. s.ExtendBaseWidget(s)
  39. return s
  40. }
  41. // ClearSelected clears the current option of the select widget. After
  42. // clearing the current option, the Select widget's PlaceHolder will
  43. // be displayed.
  44. func (s *Select) ClearSelected() {
  45. s.updateSelected("")
  46. }
  47. // CreateRenderer is a private method to Fyne which links this widget to its renderer
  48. func (s *Select) CreateRenderer() fyne.WidgetRenderer {
  49. s.ExtendBaseWidget(s)
  50. s.propertyLock.RLock()
  51. icon := NewIcon(theme.MenuDropDownIcon())
  52. if s.PlaceHolder == "" {
  53. s.PlaceHolder = defaultPlaceHolder
  54. }
  55. txtProv := NewRichTextWithText(s.Selected)
  56. txtProv.inset = fyne.NewSize(theme.Padding(), theme.Padding())
  57. txtProv.ExtendBaseWidget(txtProv)
  58. txtProv.Wrapping = fyne.TextTruncate
  59. if s.disabled {
  60. txtProv.Segments[0].(*TextSegment).Style.ColorName = theme.ColorNameDisabled
  61. }
  62. background := &canvas.Rectangle{}
  63. line := canvas.NewRectangle(theme.ShadowColor())
  64. tapBG := canvas.NewRectangle(color.Transparent)
  65. s.tapAnim = newButtonTapAnimation(tapBG, s)
  66. s.tapAnim.Curve = fyne.AnimationEaseOut
  67. objects := []fyne.CanvasObject{background, line, tapBG, txtProv, icon}
  68. r := &selectRenderer{icon, txtProv, background, line, objects, s}
  69. background.FillColor, line.FillColor = r.bgLineColor()
  70. r.updateIcon()
  71. s.propertyLock.RUnlock() // updateLabel and some text handling isn't quite right, resolve in text refactor for 2.0
  72. r.updateLabel()
  73. return r
  74. }
  75. // FocusGained is called after this Select has gained focus.
  76. //
  77. // Implements: fyne.Focusable
  78. func (s *Select) FocusGained() {
  79. s.focused = true
  80. s.Refresh()
  81. }
  82. // FocusLost is called after this Select has lost focus.
  83. //
  84. // Implements: fyne.Focusable
  85. func (s *Select) FocusLost() {
  86. s.focused = false
  87. s.Refresh()
  88. }
  89. // Hide hides the select.
  90. //
  91. // Implements: fyne.Widget
  92. func (s *Select) Hide() {
  93. if s.popUp != nil {
  94. s.popUp.Hide()
  95. s.popUp = nil
  96. }
  97. s.BaseWidget.Hide()
  98. }
  99. // MinSize returns the size that this widget should not shrink below
  100. func (s *Select) MinSize() fyne.Size {
  101. s.ExtendBaseWidget(s)
  102. return s.BaseWidget.MinSize()
  103. }
  104. // MouseIn is called when a desktop pointer enters the widget
  105. func (s *Select) MouseIn(*desktop.MouseEvent) {
  106. s.hovered = true
  107. s.Refresh()
  108. }
  109. // MouseMoved is called when a desktop pointer hovers over the widget
  110. func (s *Select) MouseMoved(*desktop.MouseEvent) {
  111. }
  112. // MouseOut is called when a desktop pointer exits the widget
  113. func (s *Select) MouseOut() {
  114. s.hovered = false
  115. s.Refresh()
  116. }
  117. // Move changes the relative position of the select.
  118. //
  119. // Implements: fyne.Widget
  120. func (s *Select) Move(pos fyne.Position) {
  121. s.BaseWidget.Move(pos)
  122. if s.popUp != nil {
  123. s.popUp.Move(s.popUpPos())
  124. }
  125. }
  126. // Resize sets a new size for a widget.
  127. // Note this should not be used if the widget is being managed by a Layout within a Container.
  128. func (s *Select) Resize(size fyne.Size) {
  129. s.BaseWidget.Resize(size)
  130. if s.popUp != nil {
  131. s.popUp.Resize(fyne.NewSize(size.Width, s.popUp.MinSize().Height))
  132. }
  133. }
  134. // SelectedIndex returns the index value of the currently selected item in Options list.
  135. // It will return -1 if there is no selection.
  136. func (s *Select) SelectedIndex() int {
  137. for i, option := range s.Options {
  138. if s.Selected == option {
  139. return i
  140. }
  141. }
  142. return -1 // not selected/found
  143. }
  144. // SetSelected sets the current option of the select widget
  145. func (s *Select) SetSelected(text string) {
  146. for _, option := range s.Options {
  147. if text == option {
  148. s.updateSelected(text)
  149. }
  150. }
  151. }
  152. // SetSelectedIndex will set the Selected option from the value in Options list at index position.
  153. func (s *Select) SetSelectedIndex(index int) {
  154. if index < 0 || index >= len(s.Options) {
  155. return
  156. }
  157. s.updateSelected(s.Options[index])
  158. }
  159. // Tapped is called when a pointer tapped event is captured and triggers any tap handler
  160. func (s *Select) Tapped(*fyne.PointEvent) {
  161. if s.Disabled() {
  162. return
  163. }
  164. s.tapAnimation()
  165. s.Refresh()
  166. s.showPopUp()
  167. }
  168. // TypedKey is called if a key event happens while this Select is focused.
  169. //
  170. // Implements: fyne.Focusable
  171. func (s *Select) TypedKey(event *fyne.KeyEvent) {
  172. switch event.Name {
  173. case fyne.KeySpace, fyne.KeyUp, fyne.KeyDown:
  174. s.showPopUp()
  175. case fyne.KeyRight:
  176. i := s.SelectedIndex() + 1
  177. if i >= len(s.Options) {
  178. i = 0
  179. }
  180. s.SetSelectedIndex(i)
  181. case fyne.KeyLeft:
  182. i := s.SelectedIndex() - 1
  183. if i < 0 {
  184. i = len(s.Options) - 1
  185. }
  186. s.SetSelectedIndex(i)
  187. }
  188. }
  189. // TypedRune is called if a text event happens while this Select is focused.
  190. //
  191. // Implements: fyne.Focusable
  192. func (s *Select) TypedRune(_ rune) {
  193. // intentionally left blank
  194. }
  195. func (s *Select) popUpPos() fyne.Position {
  196. buttonPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(s.super())
  197. return buttonPos.Add(fyne.NewPos(0, s.Size().Height-theme.InputBorderSize()))
  198. }
  199. func (s *Select) showPopUp() {
  200. items := make([]*fyne.MenuItem, len(s.Options))
  201. for i := range s.Options {
  202. text := s.Options[i] // capture
  203. items[i] = fyne.NewMenuItem(text, func() {
  204. s.updateSelected(text)
  205. s.popUp = nil
  206. })
  207. }
  208. c := fyne.CurrentApp().Driver().CanvasForObject(s.super())
  209. s.popUp = NewPopUpMenu(fyne.NewMenu("", items...), c)
  210. s.popUp.alignment = s.Alignment
  211. s.popUp.ShowAtPosition(s.popUpPos())
  212. s.popUp.Resize(fyne.NewSize(s.Size().Width, s.popUp.MinSize().Height))
  213. }
  214. func (s *Select) tapAnimation() {
  215. if s.tapAnim == nil {
  216. return
  217. }
  218. s.tapAnim.Stop()
  219. s.tapAnim.Start()
  220. }
  221. func (s *Select) updateSelected(text string) {
  222. s.Selected = text
  223. if s.OnChanged != nil {
  224. s.OnChanged(s.Selected)
  225. }
  226. s.Refresh()
  227. }
  228. type selectRenderer struct {
  229. icon *Icon
  230. label *RichText
  231. background, line *canvas.Rectangle
  232. objects []fyne.CanvasObject
  233. combo *Select
  234. }
  235. func (s *selectRenderer) Objects() []fyne.CanvasObject {
  236. return s.objects
  237. }
  238. func (s *selectRenderer) Destroy() {}
  239. // Layout the components of the button widget
  240. func (s *selectRenderer) Layout(size fyne.Size) {
  241. s.line.Resize(fyne.NewSize(size.Width, theme.InputBorderSize()))
  242. s.line.Move(fyne.NewPos(0, size.Height-theme.InputBorderSize()))
  243. s.background.Resize(fyne.NewSize(size.Width, size.Height-theme.InputBorderSize()*2))
  244. s.background.Move(fyne.NewPos(0, theme.InputBorderSize()))
  245. s.label.inset = fyne.NewSize(theme.Padding(), theme.Padding())
  246. iconPos := fyne.NewPos(size.Width-theme.IconInlineSize()-theme.InnerPadding(), (size.Height-theme.IconInlineSize())/2)
  247. labelSize := fyne.NewSize(iconPos.X-theme.Padding(), s.label.MinSize().Height)
  248. s.label.Resize(labelSize)
  249. s.label.Move(fyne.NewPos(theme.Padding(), (size.Height-labelSize.Height)/2))
  250. s.icon.Resize(fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize()))
  251. s.icon.Move(iconPos)
  252. }
  253. // MinSize calculates the minimum size of a select button.
  254. // This is based on the selected text, the drop icon and a standard amount of padding added.
  255. func (s *selectRenderer) MinSize() fyne.Size {
  256. s.combo.propertyLock.RLock()
  257. defer s.combo.propertyLock.RUnlock()
  258. minPlaceholderWidth := fyne.MeasureText(s.combo.PlaceHolder, theme.TextSize(), fyne.TextStyle{}).Width
  259. min := s.label.MinSize()
  260. min.Width = minPlaceholderWidth
  261. min = min.Add(fyne.NewSize(theme.InnerPadding()*3, theme.InnerPadding()))
  262. return min.Add(fyne.NewSize(theme.IconInlineSize()+theme.InnerPadding(), 0))
  263. }
  264. func (s *selectRenderer) Refresh() {
  265. s.combo.propertyLock.RLock()
  266. s.updateLabel()
  267. s.updateIcon()
  268. s.background.FillColor, s.line.FillColor = s.bgLineColor()
  269. s.combo.propertyLock.RUnlock()
  270. s.Layout(s.combo.Size())
  271. if s.combo.popUp != nil {
  272. s.combo.popUp.alignment = s.combo.Alignment
  273. s.combo.popUp.Move(s.combo.popUpPos())
  274. s.combo.popUp.Resize(fyne.NewSize(s.combo.size.Width, s.combo.popUp.MinSize().Height))
  275. s.combo.popUp.Refresh()
  276. }
  277. s.background.Refresh()
  278. canvas.Refresh(s.combo.super())
  279. }
  280. func (s *selectRenderer) bgLineColor() (bg color.Color, line color.Color) {
  281. if s.combo.Disabled() {
  282. return theme.InputBackgroundColor(), theme.DisabledColor()
  283. }
  284. if s.combo.focused {
  285. return theme.FocusColor(), theme.PrimaryColor()
  286. }
  287. if s.combo.hovered {
  288. return theme.HoverColor(), theme.ShadowColor()
  289. }
  290. return theme.InputBackgroundColor(), theme.ShadowColor()
  291. }
  292. func (s *selectRenderer) updateIcon() {
  293. if s.combo.Disabled() {
  294. s.icon.Resource = theme.NewDisabledResource(theme.MenuDropDownIcon())
  295. } else {
  296. s.icon.Resource = theme.MenuDropDownIcon()
  297. }
  298. s.icon.Refresh()
  299. }
  300. func (s *selectRenderer) updateLabel() {
  301. if s.combo.PlaceHolder == "" {
  302. s.combo.PlaceHolder = defaultPlaceHolder
  303. }
  304. s.label.Segments[0].(*TextSegment).Style.Alignment = s.combo.Alignment
  305. if s.combo.disabled {
  306. s.label.Segments[0].(*TextSegment).Style.ColorName = theme.ColorNameDisabled
  307. } else {
  308. s.label.Segments[0].(*TextSegment).Style.ColorName = theme.ColorNameForeground
  309. }
  310. if s.combo.Selected == "" {
  311. s.label.Segments[0].(*TextSegment).Text = s.combo.PlaceHolder
  312. } else {
  313. s.label.Segments[0].(*TextSegment).Text = s.combo.Selected
  314. }
  315. s.label.Refresh()
  316. }