package container import ( "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/internal" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) // TabItem represents a single view in a tab view. // The Text and Icon are used for the tab button and the Content is shown when the corresponding tab is active. // // Since: 1.4 type TabItem struct { Text string Icon fyne.Resource Content fyne.CanvasObject button *tabButton } // Disabled returns whether or not the TabItem is disabled. // // Since: 2.3 func (ti *TabItem) Disabled() bool { if ti.button != nil { return ti.button.Disabled() } return false } func (ti *TabItem) disable() { if ti.button != nil { ti.button.Disable() } } func (ti *TabItem) enable() { if ti.button != nil { ti.button.Enable() } } // TabLocation is the location where the tabs of a tab container should be rendered // // Since: 1.4 type TabLocation int // TabLocation values const ( TabLocationTop TabLocation = iota TabLocationLeading TabLocationBottom TabLocationTrailing ) // NewTabItem creates a new item for a tabbed widget - each item specifies the content and a label for its tab. // // Since: 1.4 func NewTabItem(text string, content fyne.CanvasObject) *TabItem { return &TabItem{Text: text, Content: content} } // NewTabItemWithIcon creates a new item for a tabbed widget - each item specifies the content and a label with an icon for its tab. // // Since: 1.4 func NewTabItemWithIcon(text string, icon fyne.Resource, content fyne.CanvasObject) *TabItem { return &TabItem{Text: text, Icon: icon, Content: content} } type baseTabs interface { onUnselected() func(*TabItem) onSelected() func(*TabItem) items() []*TabItem setItems([]*TabItem) selected() int setSelected(int) tabLocation() TabLocation transitioning() bool setTransitioning(bool) } func tabsAdjustedLocation(l TabLocation) TabLocation { // Mobile has limited screen space, so don't put app tab bar on long edges if d := fyne.CurrentDevice(); d.IsMobile() { if o := d.Orientation(); fyne.IsVertical(o) { if l == TabLocationLeading { return TabLocationTop } else if l == TabLocationTrailing { return TabLocationBottom } } else { if l == TabLocationTop { return TabLocationLeading } else if l == TabLocationBottom { return TabLocationTrailing } } } return l } func buildPopUpMenu(t baseTabs, button *widget.Button, items []*fyne.MenuItem) *widget.PopUpMenu { d := fyne.CurrentApp().Driver() c := d.CanvasForObject(button) popUpMenu := widget.NewPopUpMenu(fyne.NewMenu("", items...), c) buttonPos := d.AbsolutePositionForObject(button) buttonSize := button.Size() popUpMin := popUpMenu.MinSize() var popUpPos fyne.Position switch t.tabLocation() { case TabLocationLeading: popUpPos.X = buttonPos.X + buttonSize.Width popUpPos.Y = buttonPos.Y + buttonSize.Height - popUpMin.Height case TabLocationTrailing: popUpPos.X = buttonPos.X - popUpMin.Width popUpPos.Y = buttonPos.Y + buttonSize.Height - popUpMin.Height case TabLocationTop: popUpPos.X = buttonPos.X + buttonSize.Width - popUpMin.Width popUpPos.Y = buttonPos.Y + buttonSize.Height case TabLocationBottom: popUpPos.X = buttonPos.X + buttonSize.Width - popUpMin.Width popUpPos.Y = buttonPos.Y - popUpMin.Height } if popUpPos.X < 0 { popUpPos.X = 0 } if popUpPos.Y < 0 { popUpPos.Y = 0 } popUpMenu.ShowAtPosition(popUpPos) return popUpMenu } func removeIndex(t baseTabs, index int) { items := t.items() if index < 0 || index >= len(items) { return } setItems(t, append(items[:index], items[index+1:]...)) if s := t.selected(); index < s { t.setSelected(s - 1) } } func removeItem(t baseTabs, item *TabItem) { for index, existingItem := range t.items() { if existingItem == item { removeIndex(t, index) break } } } func selected(t baseTabs) *TabItem { selected := t.selected() items := t.items() if selected < 0 || selected >= len(items) { return nil } return items[selected] } func selectIndex(t baseTabs, index int) { selected := t.selected() if selected == index { // No change, so do nothing return } items := t.items() if f := t.onUnselected(); f != nil && selected >= 0 && selected < len(items) { // Notification of unselected f(items[selected]) } if index < 0 || index >= len(items) { // Out of bounds, so do nothing return } t.setTransitioning(true) t.setSelected(index) if f := t.onSelected(); f != nil { // Notification of selected f(items[index]) } } func selectItem(t baseTabs, item *TabItem) { for i, child := range t.items() { if child == item { selectIndex(t, i) return } } } func setItems(t baseTabs, items []*TabItem) { if internal.HintsEnabled && mismatchedTabItems(items) { internal.LogHint("Tab items should all have the same type of content (text, icons or both)") } t.setItems(items) selected := t.selected() count := len(items) switch { case count == 0: // No items available to be selected selectIndex(t, -1) // Unsure OnUnselected gets called if applicable t.setSelected(-1) case selected < 0: // Current is first tab item selectIndex(t, 0) case selected >= count: // Current doesn't exist, select last tab selectIndex(t, count-1) } } func disableIndex(t baseTabs, index int) { items := t.items() if index < 0 || index >= len(items) { return } item := items[index] item.disable() if selected(t) == item { // the disabled tab is currently selected, so select the first enabled tab for i, it := range items { if !it.Disabled() { selectIndex(t, i) break } } } if selected(t) == item { selectIndex(t, -1) // no other tab is able to be selected } } func disableItem(t baseTabs, item *TabItem) { for i, it := range t.items() { if it == item { disableIndex(t, i) return } } } func enableIndex(t baseTabs, index int) { items := t.items() if index < 0 || index >= len(items) { return } item := items[index] item.enable() } func enableItem(t baseTabs, item *TabItem) { for i, it := range t.items() { if it == item { enableIndex(t, i) return } } } type baseTabsRenderer struct { positionAnimation, sizeAnimation *fyne.Animation lastIndicatorMutex sync.RWMutex lastIndicatorPos fyne.Position lastIndicatorSize fyne.Size lastIndicatorHidden bool action *widget.Button bar *fyne.Container divider, indicator *canvas.Rectangle tabs baseTabs } func (r *baseTabsRenderer) Destroy() { } func (r *baseTabsRenderer) applyTheme(t baseTabs) { if r.action != nil { r.action.SetIcon(moreIcon(t)) } r.divider.FillColor = theme.ShadowColor() r.indicator.FillColor = theme.PrimaryColor() r.indicator.CornerRadius = theme.SelectionRadiusSize() for _, tab := range r.tabs.items() { tab.Content.Refresh() } } func (r *baseTabsRenderer) layout(t baseTabs, size fyne.Size) { var ( barPos, dividerPos, contentPos fyne.Position barSize, dividerSize, contentSize fyne.Size ) barMin := r.bar.MinSize() padding := theme.Padding() switch t.tabLocation() { case TabLocationTop: barHeight := barMin.Height barPos = fyne.NewPos(0, 0) barSize = fyne.NewSize(size.Width, barHeight) dividerPos = fyne.NewPos(0, barHeight) dividerSize = fyne.NewSize(size.Width, padding) contentPos = fyne.NewPos(0, barHeight+padding) contentSize = fyne.NewSize(size.Width, size.Height-barHeight-padding) case TabLocationLeading: barWidth := barMin.Width barPos = fyne.NewPos(0, 0) barSize = fyne.NewSize(barWidth, size.Height) dividerPos = fyne.NewPos(barWidth, 0) dividerSize = fyne.NewSize(padding, size.Height) contentPos = fyne.NewPos(barWidth+theme.Padding(), 0) contentSize = fyne.NewSize(size.Width-barWidth-padding, size.Height) case TabLocationBottom: barHeight := barMin.Height barPos = fyne.NewPos(0, size.Height-barHeight) barSize = fyne.NewSize(size.Width, barHeight) dividerPos = fyne.NewPos(0, size.Height-barHeight-padding) dividerSize = fyne.NewSize(size.Width, padding) contentPos = fyne.NewPos(0, 0) contentSize = fyne.NewSize(size.Width, size.Height-barHeight-padding) case TabLocationTrailing: barWidth := barMin.Width barPos = fyne.NewPos(size.Width-barWidth, 0) barSize = fyne.NewSize(barWidth, size.Height) dividerPos = fyne.NewPos(size.Width-barWidth-padding, 0) dividerSize = fyne.NewSize(padding, size.Height) contentPos = fyne.NewPos(0, 0) contentSize = fyne.NewSize(size.Width-barWidth-padding, size.Height) } r.bar.Move(barPos) r.bar.Resize(barSize) r.divider.Move(dividerPos) r.divider.Resize(dividerSize) selected := t.selected() for i, ti := range t.items() { if i == selected { ti.Content.Move(contentPos) ti.Content.Resize(contentSize) ti.Content.Show() } else { ti.Content.Hide() } } } func (r *baseTabsRenderer) minSize(t baseTabs) fyne.Size { pad := theme.Padding() buttonPad := pad barMin := r.bar.MinSize() tabsMin := r.bar.Objects[0].MinSize() accessory := r.bar.Objects[1] accessoryMin := accessory.MinSize() if scroll, ok := r.bar.Objects[0].(*Scroll); ok && len(scroll.Content.(*fyne.Container).Objects) == 0 { tabsMin = fyne.Size{} // scroller forces 32 where we don't need any space buttonPad = 0 } else if group, ok := r.bar.Objects[0].(*fyne.Container); ok && len(group.Objects) > 0 { tabsMin = group.Objects[0].MinSize() buttonPad = 0 } if !accessory.Visible() || accessoryMin.Width == 0 { buttonPad = 0 accessoryMin = fyne.Size{} } contentMin := fyne.NewSize(0, 0) for _, content := range t.items() { contentMin = contentMin.Max(content.Content.MinSize()) } switch t.tabLocation() { case TabLocationLeading, TabLocationTrailing: return fyne.NewSize(barMin.Width+contentMin.Width+pad, fyne.Max(contentMin.Height, accessoryMin.Height+buttonPad+tabsMin.Height)) default: return fyne.NewSize(fyne.Max(contentMin.Width, accessoryMin.Width+buttonPad+tabsMin.Width), barMin.Height+contentMin.Height+pad) } } func (r *baseTabsRenderer) moveIndicator(pos fyne.Position, siz fyne.Size, animate bool) { r.lastIndicatorMutex.RLock() isSameState := r.lastIndicatorPos.Subtract(pos).IsZero() && r.lastIndicatorSize.Subtract(siz).IsZero() && r.lastIndicatorHidden == r.indicator.Hidden r.lastIndicatorMutex.RUnlock() if isSameState { return } if r.positionAnimation != nil { r.positionAnimation.Stop() r.positionAnimation = nil } if r.sizeAnimation != nil { r.sizeAnimation.Stop() r.sizeAnimation = nil } r.indicator.FillColor = theme.PrimaryColor() if r.indicator.Position().IsZero() { r.indicator.Move(pos) r.indicator.Resize(siz) r.indicator.Refresh() return } r.lastIndicatorMutex.Lock() r.lastIndicatorPos = pos r.lastIndicatorSize = siz r.lastIndicatorHidden = r.indicator.Hidden r.lastIndicatorMutex.Unlock() if animate && fyne.CurrentApp().Settings().ShowAnimations() { r.positionAnimation = canvas.NewPositionAnimation(r.indicator.Position(), pos, canvas.DurationShort, func(p fyne.Position) { r.indicator.Move(p) r.indicator.Refresh() if pos == p { r.positionAnimation.Stop() r.positionAnimation = nil } }) r.sizeAnimation = canvas.NewSizeAnimation(r.indicator.Size(), siz, canvas.DurationShort, func(s fyne.Size) { r.indicator.Resize(s) r.indicator.Refresh() if siz == s { r.sizeAnimation.Stop() r.sizeAnimation = nil } }) r.positionAnimation.Start() r.sizeAnimation.Start() } else { r.indicator.Move(pos) r.indicator.Resize(siz) r.indicator.Refresh() } } func (r *baseTabsRenderer) objects(t baseTabs) []fyne.CanvasObject { objects := []fyne.CanvasObject{r.bar, r.divider, r.indicator} if i, is := t.selected(), t.items(); i >= 0 && i < len(is) { objects = append(objects, is[i].Content) } return objects } func (r *baseTabsRenderer) refresh(t baseTabs) { r.applyTheme(t) r.bar.Refresh() r.divider.Refresh() r.indicator.Refresh() } type buttonIconPosition int const ( buttonIconInline buttonIconPosition = iota buttonIconTop ) var _ fyne.Widget = (*tabButton)(nil) var _ fyne.Tappable = (*tabButton)(nil) var _ desktop.Hoverable = (*tabButton)(nil) type tabButton struct { widget.DisableableWidget hovered bool icon fyne.Resource iconPosition buttonIconPosition importance widget.Importance onTapped func() onClosed func() text string textAlignment fyne.TextAlign } func (b *tabButton) CreateRenderer() fyne.WidgetRenderer { b.ExtendBaseWidget(b) background := canvas.NewRectangle(theme.HoverColor()) background.CornerRadius = theme.SelectionRadiusSize() background.Hide() icon := canvas.NewImageFromResource(b.icon) if b.icon == nil { icon.Hide() } label := canvas.NewText(b.text, theme.ForegroundColor()) label.TextStyle.Bold = true close := &tabCloseButton{ parent: b, onTapped: func() { if f := b.onClosed; f != nil { f() } }, } close.ExtendBaseWidget(close) close.Hide() objects := []fyne.CanvasObject{background, label, close, icon} r := &tabButtonRenderer{ button: b, background: background, icon: icon, label: label, close: close, objects: objects, } r.Refresh() return r } func (b *tabButton) MinSize() fyne.Size { b.ExtendBaseWidget(b) return b.BaseWidget.MinSize() } func (b *tabButton) MouseIn(*desktop.MouseEvent) { b.hovered = true b.Refresh() } func (b *tabButton) MouseMoved(*desktop.MouseEvent) { } func (b *tabButton) MouseOut() { b.hovered = false b.Refresh() } func (b *tabButton) Tapped(*fyne.PointEvent) { if b.Disabled() { return } b.onTapped() } type tabButtonRenderer struct { button *tabButton background *canvas.Rectangle icon *canvas.Image label *canvas.Text close *tabCloseButton objects []fyne.CanvasObject } func (r *tabButtonRenderer) Destroy() { } func (r *tabButtonRenderer) Layout(size fyne.Size) { r.background.Resize(size) padding := r.padding() innerSize := size.Subtract(padding) innerOffset := fyne.NewPos(padding.Width/2, padding.Height/2) labelShift := float32(0) if r.icon.Visible() { iconSize := r.iconSize() var iconOffset fyne.Position if r.button.iconPosition == buttonIconTop { iconOffset = fyne.NewPos((innerSize.Width-iconSize)/2, 0) } else { iconOffset = fyne.NewPos(0, (innerSize.Height-iconSize)/2) } r.icon.Resize(fyne.NewSquareSize(iconSize)) r.icon.Move(innerOffset.Add(iconOffset)) labelShift = iconSize + theme.Padding() } if r.label.Text != "" { var labelOffset fyne.Position var labelSize fyne.Size if r.button.iconPosition == buttonIconTop { labelOffset = fyne.NewPos(0, labelShift) labelSize = fyne.NewSize(innerSize.Width, r.label.MinSize().Height) } else { labelOffset = fyne.NewPos(labelShift, 0) labelSize = fyne.NewSize(innerSize.Width-labelShift, innerSize.Height) } r.label.Resize(labelSize) r.label.Move(innerOffset.Add(labelOffset)) } inlineIconSize := theme.IconInlineSize() r.close.Move(fyne.NewPos(size.Width-inlineIconSize-theme.Padding(), (size.Height-inlineIconSize)/2)) r.close.Resize(fyne.NewSquareSize(inlineIconSize)) } func (r *tabButtonRenderer) MinSize() fyne.Size { var contentWidth, contentHeight float32 textSize := r.label.MinSize() iconSize := r.iconSize() padding := theme.Padding() if r.button.iconPosition == buttonIconTop { contentWidth = fyne.Max(textSize.Width, iconSize) if r.icon.Visible() { contentHeight += iconSize } if r.label.Text != "" { if r.icon.Visible() { contentHeight += padding } contentHeight += textSize.Height } } else { contentHeight = fyne.Max(textSize.Height, iconSize) if r.icon.Visible() { contentWidth += iconSize } if r.label.Text != "" { if r.icon.Visible() { contentWidth += padding } contentWidth += textSize.Width } } if r.button.onClosed != nil { inlineIconSize := theme.IconInlineSize() contentWidth += inlineIconSize + padding contentHeight = fyne.Max(contentHeight, inlineIconSize) } return fyne.NewSize(contentWidth, contentHeight).Add(r.padding()) } func (r *tabButtonRenderer) Objects() []fyne.CanvasObject { return r.objects } func (r *tabButtonRenderer) Refresh() { if r.button.hovered && !r.button.Disabled() { r.background.FillColor = theme.HoverColor() r.background.CornerRadius = theme.SelectionRadiusSize() r.background.Show() } else { r.background.Hide() } r.background.Refresh() r.label.Text = r.button.text r.label.Alignment = r.button.textAlignment if !r.button.Disabled() { if r.button.importance == widget.HighImportance { r.label.Color = theme.PrimaryColor() } else { r.label.Color = theme.ForegroundColor() } } else { r.label.Color = theme.DisabledColor() } r.label.TextSize = theme.TextSize() if r.button.text == "" { r.label.Hide() } else { r.label.Show() } r.icon.Resource = r.button.icon if r.icon.Resource != nil { r.icon.Show() switch res := r.icon.Resource.(type) { case *theme.ThemedResource: if r.button.importance == widget.HighImportance { r.icon.Resource = theme.NewPrimaryThemedResource(res) r.icon.Refresh() } case *theme.PrimaryThemedResource: if r.button.importance != widget.HighImportance { r.icon.Resource = res.Original() r.icon.Refresh() } } } else { r.icon.Hide() } if d := fyne.CurrentDevice(); r.button.onClosed != nil && (d.IsMobile() || r.button.hovered || r.close.hovered) { r.close.Show() } else { r.close.Hide() } r.close.Refresh() canvas.Refresh(r.button) } func (r *tabButtonRenderer) iconSize() float32 { if r.button.iconPosition == buttonIconTop { return 2 * theme.IconInlineSize() } return theme.IconInlineSize() } func (r *tabButtonRenderer) padding() fyne.Size { padding := theme.InnerPadding() if r.label.Text != "" && r.button.iconPosition == buttonIconInline { return fyne.NewSquareSize(padding * 2) } return fyne.NewSize(padding, padding*2) } var _ fyne.Widget = (*tabCloseButton)(nil) var _ fyne.Tappable = (*tabCloseButton)(nil) var _ desktop.Hoverable = (*tabCloseButton)(nil) type tabCloseButton struct { widget.BaseWidget parent *tabButton hovered bool onTapped func() } func (b *tabCloseButton) CreateRenderer() fyne.WidgetRenderer { b.ExtendBaseWidget(b) background := canvas.NewRectangle(theme.HoverColor()) background.CornerRadius = theme.SelectionRadiusSize() background.Hide() icon := canvas.NewImageFromResource(theme.CancelIcon()) r := &tabCloseButtonRenderer{ button: b, background: background, icon: icon, objects: []fyne.CanvasObject{background, icon}, } r.Refresh() return r } func (b *tabCloseButton) MinSize() fyne.Size { b.ExtendBaseWidget(b) return b.BaseWidget.MinSize() } func (b *tabCloseButton) MouseIn(*desktop.MouseEvent) { b.hovered = true b.parent.Refresh() } func (b *tabCloseButton) MouseMoved(*desktop.MouseEvent) { } func (b *tabCloseButton) MouseOut() { b.hovered = false b.parent.Refresh() } func (b *tabCloseButton) Tapped(*fyne.PointEvent) { b.onTapped() } type tabCloseButtonRenderer struct { button *tabCloseButton background *canvas.Rectangle icon *canvas.Image objects []fyne.CanvasObject } func (r *tabCloseButtonRenderer) Destroy() { } func (r *tabCloseButtonRenderer) Layout(size fyne.Size) { r.background.Resize(size) r.icon.Resize(size) } func (r *tabCloseButtonRenderer) MinSize() fyne.Size { return fyne.NewSquareSize(theme.IconInlineSize()) } func (r *tabCloseButtonRenderer) Objects() []fyne.CanvasObject { return r.objects } func (r *tabCloseButtonRenderer) Refresh() { if r.button.hovered { r.background.FillColor = theme.HoverColor() r.background.CornerRadius = theme.SelectionRadiusSize() r.background.Show() } else { r.background.Hide() } r.background.Refresh() switch res := r.icon.Resource.(type) { case *theme.ThemedResource: if r.button.parent.importance == widget.HighImportance { r.icon.Resource = theme.NewPrimaryThemedResource(res) } case *theme.PrimaryThemedResource: if r.button.parent.importance != widget.HighImportance { r.icon.Resource = res.Original() } } r.icon.Refresh() } func mismatchedTabItems(items []*TabItem) bool { var hasText, hasIcon bool for _, tab := range items { hasText = hasText || tab.Text != "" hasIcon = hasIcon || tab.Icon != nil } mismatch := false for _, tab := range items { if (hasText && tab.Text == "") || (hasIcon && tab.Icon == nil) { mismatch = true break } } return mismatch } func moreIcon(t baseTabs) fyne.Resource { if l := t.tabLocation(); l == TabLocationLeading || l == TabLocationTrailing { return theme.MoreVerticalIcon() } return theme.MoreHorizontalIcon() }