diff --git a/cmd/fyne_demo/screens/window.go b/cmd/fyne_demo/screens/window.go index f79d68b27a..ac2d8fe977 100644 --- a/cmd/fyne_demo/screens/window.go +++ b/cmd/fyne_demo/screens/window.go @@ -187,7 +187,12 @@ func loadWindowGroup() fyne.Widget { }), widget.NewButton("Fixed size window", func() { w := fyne.CurrentApp().NewWindow("Fixed") - w.SetContent(fyne.NewContainerWithLayout(layout.NewCenterLayout(), widget.NewLabel("Hello World!"))) + w.SetContent(fyne.NewContainerWithLayout( + layout.NewGridLayoutWithRows(3), + widget.NewLabel("Hello World!"), + widget.NewButton("Cancel", func() { w.Close() }), + widget.NewButton("OK", func() { w.Close() })), + ) w.Resize(fyne.NewSize(240, 180)) w.SetFixedSize(true) diff --git a/dialog/base.go b/dialog/base.go index f6ab445c22..ae12f28cca 100644 --- a/dialog/base.go +++ b/dialog/base.go @@ -38,6 +38,7 @@ type dialog struct { content, label fyne.CanvasObject dismiss *widget.Button parent fyne.Window + oldFocus fyne.Focusable } // SetOnClosed allows to set a callback function that is called when @@ -144,9 +145,16 @@ func newButtonList(buttons ...*widget.Button) fyne.CanvasObject { func (d *dialog) Show() { d.sendResponse = true d.win.Show() + d.oldFocus = d.win.Canvas.Focused() + if d.dismiss != nil { + d.win.Canvas.Focus(d.dismiss) + } else { + d.win.Canvas.Focus(nil) + } } func (d *dialog) Hide() { + d.win.Canvas.Focus(d.oldFocus) d.hideWithResponse(false) } diff --git a/internal/app/focus.go b/internal/app/focus.go index ec0cd937ec..aacc21aae0 100644 --- a/internal/app/focus.go +++ b/internal/app/focus.go @@ -13,7 +13,11 @@ type FocusManager struct { func (f *FocusManager) nextInChain(current fyne.Focusable) fyne.Focusable { var first, next fyne.Focusable found := current == nil // if we have no starting point then pretend we matched already - driver.WalkVisibleObjectTree(f.canvas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool { + content := f.canvas.Overlays().Top() + if content == nil { + content = f.canvas.Content() + } + driver.WalkVisibleObjectTree(content, func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool { if w, ok := obj.(fyne.Disableable); ok && w.Disabled() { // disabled widget cannot receive focus return false @@ -48,7 +52,12 @@ func (f *FocusManager) nextInChain(current fyne.Focusable) fyne.Focusable { func (f *FocusManager) previousInChain(current fyne.Focusable) fyne.Focusable { var last, previous fyne.Focusable found := false - driver.WalkVisibleObjectTree(f.canvas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool { + + content := f.canvas.Overlays().Top() + if content == nil { + content = f.canvas.Content() + } + driver.WalkVisibleObjectTree(content, func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool { if w, ok := obj.(fyne.Disableable); ok && w.Disabled() { // disabled widget cannot receive focus return false diff --git a/internal/driver/glfw/menu_bar.go b/internal/driver/glfw/menu_bar.go index 90305858a9..087ca8cba8 100644 --- a/internal/driver/glfw/menu_bar.go +++ b/internal/driver/glfw/menu_bar.go @@ -16,11 +16,41 @@ var _ fyne.Widget = (*MenuBar)(nil) // MenuBar is a widget for displaying a fyne.MainMenu in a bar. type MenuBar struct { widget.Base - Items []fyne.CanvasObject - - active bool - activeItem *menuBarItem - canvas fyne.Canvas + Items []fyne.CanvasObject + currentItem int + active bool + canvas fyne.Canvas + activeItem *menuBarItem +} + +// handleKey will process keys pressed when menu is open +func (b *MenuBar) handleKey(key fyne.KeyName) { + if key == fyne.KeyEscape { + b.deactivate() + } else if key == fyne.KeyEnter || key == fyne.KeyReturn || key == fyne.KeySpace { + // Simulate tapping on menu entry + b.Items[b.currentItem].(*menuBarItem).Child().HandleEnterKey() + } else if key == fyne.KeyRight { + // Move to the menu item at right + if b.currentItem < len(b.Items)-1 { + b.Items[b.currentItem].(*menuBarItem).Tapped(nil) + b.currentItem++ + b.Items[b.currentItem].(*menuBarItem).Tapped(nil) + } + } else if key == fyne.KeyLeft { + // Move to the menu item at left + if b.currentItem > 0 { + b.Items[b.currentItem].(*menuBarItem).Tapped(nil) + b.currentItem-- + b.Items[b.currentItem].(*menuBarItem).Tapped(nil) + } + } else if key == fyne.KeyUp { + // Move focus to menu item above + b.Items[b.currentItem].(*menuBarItem).Child().HandleUpKey() + } else if key == fyne.KeyDown { + // Move focus to menu item below + b.Items[b.currentItem].(*menuBarItem).Child().HandleDownKey() + } } // NewMenuBar creates a menu bar populated with items from the passed main menu structure. @@ -114,7 +144,6 @@ func (b *MenuBar) deactivate() { if !b.active { return } - b.active = false if b.activeItem != nil { defer b.activeItem.Child().Dismiss() diff --git a/internal/driver/glfw/menu_bar_item.go b/internal/driver/glfw/menu_bar_item.go index b5c9636ae6..c451743a6b 100644 --- a/internal/driver/glfw/menu_bar_item.go +++ b/internal/driver/glfw/menu_bar_item.go @@ -112,11 +112,22 @@ func (i *menuBarItem) Show() { // Tapped toggles the activation state of the menu bar. // It shows the item’s menu if the bar is activated and hides it if the bar is deactivated. // Implements: fyne.Tappable -func (i *menuBarItem) Tapped(*fyne.PointEvent) { +func (i *menuBarItem) Tapped(pos *fyne.PointEvent) { if i.Parent.active { i.Parent.deactivate() } else { + // Set then menu bar's index to the tapped items index and update focus i.Parent.activateChild(i) + for j := 0; j < len(i.Parent.Items); j++ { + if i.Parent.Items[j] == i { + i.Parent.currentItem = j + if pos == nil { + i.Parent.Items[i.Parent.currentItem].(*menuBarItem).Child().HandleUpKey() + } else { + i.Parent.Items[i.Parent.currentItem].(*menuBarItem).Child().Defocus() + } + } + } } } @@ -130,7 +141,6 @@ func (r *menuBarItemRenderer) BackgroundColor() color.Color { if r.i.hovered || (r.i.child != nil && r.i.child.Visible()) { return theme.HoverColor() } - return color.Transparent } diff --git a/internal/driver/glfw/menu_bar_test.go b/internal/driver/glfw/menu_bar_test.go index f4d48a5a30..4bac76046d 100644 --- a/internal/driver/glfw/menu_bar_test.go +++ b/internal/driver/glfw/menu_bar_test.go @@ -65,7 +65,7 @@ func TestMenuBar(t *testing.T) { } themeCounter++ }) - + button.FocusLost() container := fyne.NewContainer(button, menuBar) w.SetContent(container) @@ -112,7 +112,7 @@ func TestMenuBar(t *testing.T) { }, { actions: []action{{"tap", buttonPos}}, - wantImage: "menu_bar_hovered_content.png", + wantImage: "menu_bar_hovered_focused_content.png", }, }, }, @@ -305,6 +305,8 @@ func TestMenuBar(t *testing.T) { t.Run(name, func(t *testing.T) { test.MoveMouse(c, fyne.NewPos(0, 0)) test.TapCanvas(c, fyne.NewPos(0, 0)) + // Make sure that the button is unfocused at start of the test steps + c.Unfocus() test.AssertImageMatches(t, "menu_bar_initial.png", c.Capture()) for i, s := range tt.steps { t.Run("step "+strconv.Itoa(i+1), func(t *testing.T) { diff --git a/internal/driver/glfw/testdata/menu_bar_hovered_content_dark.png b/internal/driver/glfw/testdata/menu_bar_hovered_content_dark.png index d57acd46bb..7b5c9c1f49 100644 Binary files a/internal/driver/glfw/testdata/menu_bar_hovered_content_dark.png and b/internal/driver/glfw/testdata/menu_bar_hovered_content_dark.png differ diff --git a/internal/driver/glfw/testdata/menu_bar_hovered_content_test_theme.png b/internal/driver/glfw/testdata/menu_bar_hovered_content_test_theme.png index edd9b27532..2df9d968ec 100644 Binary files a/internal/driver/glfw/testdata/menu_bar_hovered_content_test_theme.png and b/internal/driver/glfw/testdata/menu_bar_hovered_content_test_theme.png differ diff --git a/internal/driver/glfw/testdata/menu_bar_hovered_focused_content.png b/internal/driver/glfw/testdata/menu_bar_hovered_focused_content.png new file mode 100644 index 0000000000..3c9b8a3333 Binary files /dev/null and b/internal/driver/glfw/testdata/menu_bar_hovered_focused_content.png differ diff --git a/internal/driver/glfw/window.go b/internal/driver/glfw/window.go index 5ef2945806..105cf8ee2e 100644 --- a/internal/driver/glfw/window.go +++ b/internal/driver/glfw/window.go @@ -15,6 +15,7 @@ import ( "fyne.io/fyne/internal/cache" "fyne.io/fyne/internal/driver" "fyne.io/fyne/internal/painter/gl" + "fyne.io/fyne/widget" "github.com/go-gl/glfw/v3.3/glfw" ) @@ -622,7 +623,13 @@ func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw. } if layer != 1 { // 0 - overlay, 1 - menu, 2 - content - if wid, ok := co.(fyne.Focusable); ok { + if _, isToolbarButton := co.(*widget.ToolbarButton); isToolbarButton { + // Avoid changing focus when a toolbar button is tapped + w.canvas.Unfocus() + } else if _, isButton := co.(*widget.Button); isButton { + // Avoid changing focus when an ordinary button is tapped + w.canvas.Unfocus() + } else if wid, ok := co.(fyne.Focusable); ok { w.canvas.Focus(wid) } else { w.canvas.Unfocus() @@ -857,16 +864,29 @@ func (w *window) keyPressed(viewport *glfw.Window, key glfw.Key, scancode int, a keyEvent := &fyne.KeyEvent{Name: keyName} keyDesktopModifier := desktopModifier(mods) - if keyName == fyne.KeyTab { - if keyDesktopModifier == 0 { - if action != glfw.Release { - w.canvas.focusMgr.FocusNext(w.canvas.focused) + // Activate the menu when the Alt key is pressed + focused := w.canvas.Focused() + if menu, ok := w.canvas.menu.(*MenuBar); ok { + if !menu.active { + if action == glfw.Press && (key == glfw.KeyLeftAlt || key == glfw.KeyRightAlt) && + keyDesktopModifier == desktop.AltModifier { + mbi := menu.Items[0].(*menuBarItem) + mbi.Tapped(nil) + } + } else { + if action == glfw.Release { + menu.handleKey(keyName) } return + } + } + + if keyName == fyne.KeyTab && action != glfw.Release { + if keyDesktopModifier == 0 { + w.canvas.focusMgr.FocusNext(w.canvas.focused) + return } else if keyDesktopModifier == desktop.ShiftModifier { - if action != glfw.Release { - w.canvas.focusMgr.FocusPrevious(w.canvas.focused) - } + w.canvas.focusMgr.FocusPrevious(w.canvas.focused) return } } @@ -950,7 +970,6 @@ func (w *window) keyPressed(viewport *glfw.Window, key glfw.Key, scancode int, a } // No shortcut detected, pass down to TypedKey - focused := w.canvas.Focused() if focused != nil { w.queueEvent(func() { focused.TypedKey(keyEvent) }) } else if w.canvas.onTypedKey != nil { diff --git a/theme/theme.go b/theme/theme.go index ebf7575f2a..58f1a2ec52 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -61,6 +61,44 @@ func DarkTheme() fyne.Theme { return theme } +// Shade will darken a light color and lighten a light color by the given % value +// should use 4,8, 12 or 14 percent darken/lighten to implement google material design rules +// Note that the percent lightening is twice the percent given. See material.io/design/interaction/states. +func Shade(c color.Color, pct uint32) color.Color { + if pct == 0 { + return c + } + r, g, b, a := c.RGBA() + if pct > 50 { + pct = 50 + } + if r+g+b < 3*0x8080 { + // Lighten by twice the percent given. + return color.NRGBA{ + R: uint8((r + (0x10000-r)*pct/50) >> 8), + G: uint8((g + (0x10000-g)*pct/50) >> 8), + B: uint8((b + (0x10000-b)*pct/50) >> 8), + A: uint8(a >> 8), + } + } + // Darken by given percent + return color.NRGBA{ + R: uint8((r * (100 - pct) / 100) >> 8), + G: uint8((g * (100 - pct) / 100) >> 8), + B: uint8((b * (100 - pct) / 100) >> 8), + A: uint8(a >> 8), + } +} + +// PressedShade is the shade used for pressed buttons, in % +const PressedShade = 14 + +// HoveredShade is the shade used for hovered buttons, in % +const HoveredShade = 4 + +// FocusedShade is the shade used for focused buttons, in %. +const FocusedShade = 8 + func (t *builtinTheme) BackgroundColor() color.Color { return t.background } @@ -117,7 +155,7 @@ func (t *builtinTheme) HoverColor() color.Color { // FocusColor returns the color used to highlight a focused widget func (t *builtinTheme) FocusColor() color.Color { - return t.primary + return Shade(t.primary, FocusedShade) } // ScrollBarColor returns the color (and translucency) for a scrollBar @@ -276,6 +314,16 @@ func HoverColor() color.Color { return current().HoverColor() } +// PressedColor returns the colour used for a pressed button +func PressedColor() color.Color { + return Shade(FocusColor(), PressedShade) +} + +// HoverFocusedColor returns the colour used for a focused/primary hovered button +func HoverFocusedColor() color.Color { + return Shade(FocusColor(), HoveredShade) +} + // FocusColor returns the color used to highlight a focussed widget func FocusColor() color.Color { return current().FocusColor() diff --git a/theme/theme_test.go b/theme/theme_test.go index 2fa17e61ae..d785f6239d 100644 --- a/theme/theme_test.go +++ b/theme/theme_test.go @@ -95,7 +95,7 @@ func Test_HoverColor(t *testing.T) { func Test_FocusColor(t *testing.T) { fyne.CurrentApp().Settings().SetTheme(DarkTheme()) c := FocusColor() - assert.Equal(t, PrimaryColor(), c, "wrong focus color") + assert.Equal(t, DarkTheme().FocusColor(), c, "wrong focus color") } func Test_ScrollBarColor(t *testing.T) { diff --git a/widget/button.go b/widget/button.go index bdeae4c8b2..67c0a83c04 100644 --- a/widget/button.go +++ b/widget/button.go @@ -118,10 +118,16 @@ func (b *buttonRenderer) BackgroundColor() color.Color { switch { case b.button.Disabled(): return theme.DisabledButtonColor() - case b.button.Style == PrimaryButton: - return theme.PrimaryColor() + case b.button.pressed: + return theme.PressedColor() + case b.button.hovered && b.button.focused: + return theme.HoverFocusedColor() case b.button.hovered, b.button.tapped: // TODO tapped will be different to hovered when we have animation return theme.HoverColor() + case b.button.focused: + return theme.FocusColor() + case b.button.Style == PrimaryButton: + return theme.PrimaryColor() default: return theme.ButtonColor() } @@ -171,7 +177,10 @@ type Button struct { OnTapped func() `json:"-"` HideShadow bool - hovered, tapped bool + focused bool + pressed bool + hovered bool + tapped bool } // ButtonStyle determines the behaviour and rendering of a button. @@ -182,6 +191,8 @@ const ( DefaultButton ButtonStyle = iota // PrimaryButton that should be more prominent to the user PrimaryButton + // CancelButton is a style used for buttons activated by the escape key + CancelButton ) // ButtonAlign represents the horizontal alignment of a button. @@ -221,6 +232,56 @@ func (b *Button) Tapped(*fyne.PointEvent) { } } +// FocusGained is called when the Entry has been given focus. +func (b *Button) FocusGained() { + if b.Disabled() { + return + } + b.focused = true + b.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (b *Button) FocusLost() { + b.focused = false + b.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (b *Button) Focused() bool { + return b.focused +} + +// TypedRune receives text input events when the Check is focused. +func (b *Button) TypedRune(rune) { +} + +// TypedKey is called when a key is pressed +func (b *Button) TypedKey(key *fyne.KeyEvent) { + if b.Disabled() { + return + } + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + b.Tapped(nil) + } +} + +// KeyUp is called when a button is released +func (b *Button) KeyUp(key *fyne.KeyEvent) { + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + b.pressed = false + b.Refresh() + } +} + +// KeyDown is called when a button is pressed +func (b *Button) KeyDown(key *fyne.KeyEvent) { + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + b.pressed = true + b.Refresh() + } +} + // MouseIn is called when a desktop pointer enters the widget func (b *Button) MouseIn(*desktop.MouseEvent) { b.hovered = true @@ -229,6 +290,7 @@ func (b *Button) MouseIn(*desktop.MouseEvent) { // MouseOut is called when a desktop pointer exits the widget func (b *Button) MouseOut() { + b.pressed = false b.hovered = false b.Refresh() } @@ -237,6 +299,21 @@ func (b *Button) MouseOut() { func (b *Button) MouseMoved(*desktop.MouseEvent) { } +// MouseDown called on mouse click, this triggers a mouse click which can move the cursor, +// update the existing selection (if shift is held), or start a selection dragging operation. +func (b *Button) MouseDown(m *desktop.MouseEvent) { + b.pressed = true + b.Refresh() +} + +// MouseUp called on mouse release +// If a mouse drag event has completed then check to see if it has resulted in an empty selection, +// if so, and if a text select key isn't held, then disable selecting +func (b *Button) MouseUp(m *desktop.MouseEvent) { + b.pressed = false + b.Refresh() +} + // MinSize returns the size that this widget should not shrink below func (b *Button) MinSize() fyne.Size { b.ExtendBaseWidget(b) diff --git a/widget/entry.go b/widget/entry.go index 3f4ed0f873..12413d196c 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/canvas" "fyne.io/fyne/driver/desktop" "fyne.io/fyne/driver/mobile" + "fyne.io/fyne/internal/driver" "fyne.io/fyne/internal/widget" "fyne.io/fyne/theme" ) @@ -247,9 +248,19 @@ func (e *Entry) KeyDown(key *fyne.KeyEvent) { } e.selectKeyDown = true } + if key.Name == fyne.KeyEscape { + if cancelBtn := e.findButton(CancelButton); cancelBtn != nil { + cancelBtn.KeyDown(&fyne.KeyEvent{Name: fyne.KeyEnter}) + } + } + if !e.MultiLine && (key.Name == fyne.KeyEnter || key.Name == fyne.KeyReturn) { + if okBtn := e.findButton(PrimaryButton); okBtn != nil { + okBtn.KeyDown(&fyne.KeyEvent{Name: fyne.KeyEnter}) + } + } } -// KeyUp handler for key release events - used to reset shift modifier state for text selection +// KeyUp handler for key release events - used to reset shift modifier state for text selection. // Implements: desktop.Keyable func (e *Entry) KeyUp(key *fyne.KeyEvent) { // Handle shift release for keyboard selection @@ -257,6 +268,18 @@ func (e *Entry) KeyUp(key *fyne.KeyEvent) { if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { e.selectKeyDown = false } + if key.Name == fyne.KeyEscape { + if cancelBtn := e.findButton(CancelButton); cancelBtn != nil { + cancelBtn.KeyUp(&fyne.KeyEvent{Name: fyne.KeyEnter}) + cancelBtn.Tapped(nil) + } + } + if !e.MultiLine && (key.Name == fyne.KeyEnter || key.Name == fyne.KeyReturn) { + if okBtn := e.findButton(PrimaryButton); okBtn != nil { + okBtn.KeyUp(&fyne.KeyEvent{Name: fyne.KeyEnter}) + okBtn.Tapped(nil) + } + } } // MinSize returns the size that this widget should not shrink below. @@ -409,6 +432,32 @@ func (e *Entry) TappedSecondary(pe *fyne.PointEvent) { e.popUp.ShowAtPosition(popUpPos) } +// findButton returns the button from a form with required style. +// Used to find the OK or Cancel buttons on a form. +func (e *Entry) findButton(requiredStyle ButtonStyle) *Button { + var btn *Button + d := fyne.CurrentApp().Driver() + c := d.CanvasForObject(e) + if c == nil { + return nil + } + driver.WalkVisibleObjectTree( + c.Content(), + func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool { + if w, ok := obj.(fyne.Disableable); ok && w.Disabled() { + // disabled widget cannot receive focus + return false + } + if b, ok := obj.(*Button); ok && b.Style == requiredStyle { + btn = b + return true + } + return false + }, nil) + + return btn +} + // TypedKey receives key input events when the Entry widget is focused. // Implements: fyne.Focusable func (e *Entry) TypedKey(key *fyne.KeyEvent) { diff --git a/widget/entry_test.go b/widget/entry_test.go index 1791e35eee..6cffa7f02a 100644 --- a/widget/entry_test.go +++ b/widget/entry_test.go @@ -1264,7 +1264,6 @@ func TestEntry_Select(t *testing.T) { entry, window := setupSelection(tt.setupReverse) defer teardownImageTest(window) c := window.Canvas() - if tt.setupReverse { test.AssertImageMatches(t, "entry/selection_reverse_initial.png", c.Capture()) } else { diff --git a/widget/form.go b/widget/form.go index f2edd910b1..20e5c19691 100644 --- a/widget/form.go +++ b/widget/form.go @@ -119,6 +119,7 @@ func (f *Form) CreateRenderer() fyne.WidgetRenderer { f.itemGrid = itemGrid f.cancelButton = NewButtonWithIcon("", theme.CancelIcon(), f.OnCancel) + f.cancelButton.Style = CancelButton f.submitButton = NewButtonWithIcon("", theme.ConfirmIcon(), f.OnSubmit) f.submitButton.Style = PrimaryButton f.buttonBox = NewHBox(layout.NewSpacer(), f.cancelButton, f.submitButton) diff --git a/widget/menu.go b/widget/menu.go index acf15521e4..23ecfc0e9e 100644 --- a/widget/menu.go +++ b/widget/menu.go @@ -18,6 +18,82 @@ type Menu struct { OnDismiss func() activeItem *menuItem customSized bool + currentItem int +} + +// Defocus is used to defocus all menu items when a menuBarItem is tapped +func (m *Menu) Defocus() { + for _, o := range m.Items { + if mi, ok := o.(*menuItem); ok { + mi.FocusLost() + } + } +} + +// HandleEnterKey is called when the Enter key is pressed, and will simulate tapping +func (m *Menu) HandleEnterKey() { + if m.activeItem != nil { + m.activeItem.child.HandleEnterKey() + return + } + if o, ok := m.Items[m.currentItem].(*menuItem); ok { + o.MouseIn(nil) + o.Tapped(nil) + } +} + +func (m *Menu) selectCurrent(option string) { + m.currentItem = 0 + for i := 0; i < len(m.Items); i++ { + if o, ok := m.Items[i].(*menuItem); ok { + if o.Item.Label == option { + m.currentItem = i + break + } + } + } + if o, ok := m.Items[m.currentItem].(*menuItem); ok { + o.FocusGained() + } + m.Refresh() +} + +func (m *Menu) moveSelection(delta int) { + i := m.currentItem + for true { + i += delta + if i < 0 { + i = 0 + } + if i >= len(m.Items) { + i = len(m.Items) - 1 + } + if o, ok := m.Items[i].(*menuItem); ok { + m.Items[m.currentItem].(*menuItem).FocusLost() + m.currentItem = i + o.FocusGained() + m.Refresh() + break + } + } +} + +// HandleUpKey is called when the keyboard up arrow is pressed and will select previous menu item +func (m *Menu) HandleUpKey() { + if m.activeItem != nil { + m.activeItem.child.HandleUpKey() + return + } + m.moveSelection(-1) +} + +// HandleDownKey is called when the keyboard down arrow is pressed and will select next menu item +func (m *Menu) HandleDownKey() { + if m.activeItem != nil { + m.activeItem.child.HandleDownKey() + return + } + m.moveSelection(1) } // NewMenu creates a new Menu. diff --git a/widget/menu_item.go b/widget/menu_item.go index 1bbf2454b2..a47f7de11e 100644 --- a/widget/menu_item.go +++ b/widget/menu_item.go @@ -15,14 +15,31 @@ var _ fyne.Widget = (*menuItem)(nil) // menuItem is a widget for displaying a fyne.menuItem. type menuItem struct { widget.Base - Item *fyne.MenuItem - Parent *Menu - + Item *fyne.MenuItem + Parent *Menu child *Menu hovered bool + focused bool onActivateChild func(*menuItem) } +// FocusGained is called when the Entry has been given focus. +func (i *menuItem) FocusGained() { + i.focused = true + i.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (i *menuItem) FocusLost() { + i.focused = false + i.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (i *menuItem) Focused() bool { + return i.focused +} + // newMenuItem creates a new menuItem. func newMenuItem(item *fyne.MenuItem, parent *Menu, onActivateChild func(*menuItem)) *menuItem { return &menuItem{Item: item, Parent: parent, onActivateChild: onActivateChild} @@ -78,8 +95,20 @@ func (i *menuItem) MinSize() fyne.Size { // MouseIn changes the item to be hovered and shows the submenu if the item has one. // The submenu of any sibling of the item will be hidden. // Implements: desktop.Hoverable -func (i *menuItem) MouseIn(*desktop.MouseEvent) { +func (i *menuItem) MouseIn(e *desktop.MouseEvent) { i.hovered = true + if i.Child() != nil { + i.Child().Defocus() + if e == nil && i.Child().Items[0] != nil { + // A nil event means this is actualy a keyboard event activating the menu + // So focus the first menu item + i.Parent.currentItem = 0 + for j, o := range i.Child().Items { + o.(*menuItem).focused = (j == 0) + o.(*menuItem).hovered = false + } + } + } i.onActivateChild(i) i.Refresh() } @@ -145,10 +174,12 @@ type menuItemRenderer struct { } func (r *menuItemRenderer) BackgroundColor() color.Color { + if r.i.focused { + return theme.FocusColor() + } if !fyne.CurrentDevice().IsMobile() && (r.i.hovered || (r.i.child != nil && r.i.child.Visible())) { return theme.HoverColor() } - return color.Transparent } diff --git a/widget/popup_menu.go b/widget/popup_menu.go index 0f80697934..f4e32cbe8a 100644 --- a/widget/popup_menu.go +++ b/widget/popup_menu.go @@ -15,6 +15,39 @@ type PopUpMenu struct { *Menu canvas fyne.Canvas overlay *widget.OverlayContainer + parent fyne.CanvasObject +} + +// TypedRune receives text input events when the Check is focused. +func (p *PopUpMenu) TypedRune(r rune) { +} + +// TypedKey receives text input events when the menu item is focused. +func (p *PopUpMenu) TypedKey(key *fyne.KeyEvent) { + if key.Name == fyne.KeyDown { + p.Menu.HandleDownKey() + } else if key.Name == fyne.KeyUp { + p.Menu.HandleUpKey() + } else if key.Name == fyne.KeyEscape { + p.Menu.Dismiss() + p.canvas.Focus(p.parent.(fyne.Focusable)) + } else if key.Name == fyne.KeyEnter || key.Name == fyne.KeyReturn || key.Name == fyne.KeyTab { + p.Menu.HandleEnterKey() + p.canvas.Focus(p.parent.(fyne.Focusable)) + } +} + +// Focused returns whether or not this Entry has focus. +func (p *PopUpMenu) Focused() bool { + return false +} + +// FocusGained is called when the Entry has been given focus. +func (p *PopUpMenu) FocusGained() { +} + +// FocusLost is called when the Entry has had focus removed. +func (p *PopUpMenu) FocusLost() { } // ShowPopUpMenuAtPosition creates a PopUp menu populated with items from the passed menu structure. diff --git a/widget/radio.go b/widget/radio.go index f9576e97bf..f10092c80e 100644 --- a/widget/radio.go +++ b/widget/radio.go @@ -144,6 +144,8 @@ func (r *radioRenderer) updateItems() { if r.radio.Disabled() { item.focusIndicator.FillColor = theme.BackgroundColor() + } else if r.radio.focused { + item.focusIndicator.FillColor = theme.FocusColor() } else if r.radio.hovered && r.radio.hoveredItemIndex == i { item.focusIndicator.FillColor = theme.HoverColor() } else { @@ -164,6 +166,62 @@ type Radio struct { hoveredItemIndex int hovered bool + focused bool +} + +// FocusGained is called when the Entry has been given focus. +func (r *Radio) FocusGained() { + r.focused = true + r.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (r *Radio) FocusLost() { + r.focused = false + r.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (r *Radio) Focused() bool { + return r.focused +} + +//TypedRune is not used here +func (r *Radio) TypedRune(rune) { +} + +// TypedKey is called when a key is pressed +func (r *Radio) TypedKey(key *fyne.KeyEvent) { + index := -1 + for i := 0; i < len(r.Options); i++ { + if r.Selected == r.Options[i] { + index = i + break + } + } + if key.Name == fyne.KeyLeft || key.Name == fyne.KeyUp { + index-- + if index >= 0 { + r.Selected = r.Options[index] + } else { + r.Selected = "" + } + } else if key.Name == fyne.KeyRight || key.Name == fyne.KeyDown { + index++ + if index >= len(r.Options) { + index = len(r.Options) - 1 + } + r.Selected = r.Options[index] + } + r.Refresh() +} + +// KeyUp is not used here +func (r *Radio) KeyUp(*fyne.KeyEvent) { +} + +// KeyDown is not used here +func (r *Radio) KeyDown(*fyne.KeyEvent) { } // indexByPosition returns the item index for a specified position or noRadioItemIndex if any diff --git a/widget/select.go b/widget/select.go index 7304c54c09..d35621d0dc 100644 --- a/widget/select.go +++ b/widget/select.go @@ -59,6 +59,9 @@ func (s *selectRenderer) Layout(size fyne.Size) { } func (s *selectRenderer) BackgroundColor() color.Color { + if s.combo.focused { + return theme.FocusColor() + } if s.combo.hovered || s.combo.tapped { // TODO tapped will be different to hovered when we have animation return theme.HoverColor() } @@ -107,12 +110,46 @@ type Select struct { PlaceHolder string OnChanged func(string) `json:"-"` - hovered, tapped bool - popUp *PopUpMenu + hovered bool + focused bool + tapped bool + popUp *PopUpMenu } var _ fyne.Widget = (*Select)(nil) +// FocusGained is called when the Entry has been given focus. +func (s *Select) FocusGained() { + s.focused = true + s.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (s *Select) FocusLost() { + s.focused = false + s.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (s *Select) Focused() bool { + return s.focused +} + +// TypedRune is not used here +func (s *Select) TypedRune(rune) { +} + +// TypedKey receives text input when the widget is focused +func (s *Select) TypedKey(key *fyne.KeyEvent) { + if key.Name == fyne.KeyEnter || key.Name == fyne.KeyReturn || key.Name == fyne.KeySpace { + if s.popUp == nil || !s.popUp.Visible() { + s.Tapped(nil) + } else { + s.popUp.Menu.HandleEnterKey() + } + } +} + // Hide hides the select. // Implements: fyne.Widget func (s *Select) Hide() { @@ -149,7 +186,7 @@ func (s *Select) optionTapped(text string) { } // Tapped is called when a pointer tapped event is captured and triggers any tap handler -func (s *Select) Tapped(*fyne.PointEvent) { +func (s *Select) Tapped(pos *fyne.PointEvent) { c := fyne.CurrentApp().Driver().CanvasForObject(s.super()) s.tapped = true defer func() { // TODO move to a real animation @@ -167,10 +204,14 @@ func (s *Select) Tapped(*fyne.PointEvent) { }) items = append(items, item) } - s.popUp = newPopUpMenu(fyne.NewMenu("", items...), c) s.popUp.ShowAtPosition(s.popUpPos()) s.popUp.Resize(fyne.NewSize(s.Size().Width, s.popUp.MinSize().Height)) + c.Focus(s.popUp) + s.popUp.parent = s + if pos == nil { + s.popUp.Menu.selectCurrent(s.Selected) + } } func (s *Select) popUpPos() fyne.Position { @@ -241,17 +282,15 @@ func (s *Select) SetSelected(text string) { func (s *Select) updateSelected(text string) { s.Selected = text - if s.OnChanged != nil { s.OnChanged(s.Selected) } - s.Refresh() } // NewSelect creates a new select widget with the set list of options and changes handler func NewSelect(options []string, changed func(string)) *Select { - s := &Select{BaseWidget{}, "", options, defaultPlaceHolder, changed, false, false, nil} + s := &Select{BaseWidget{}, "", options, defaultPlaceHolder, changed, false, false, false, nil} s.ExtendBaseWidget(s) return s } diff --git a/widget/select_entry.go b/widget/select_entry.go index 7820c4ed3f..173c7720b4 100644 --- a/widget/select_entry.go +++ b/widget/select_entry.go @@ -56,15 +56,21 @@ func (e *SelectEntry) SetOptions(options []string) { items = append(items, fyne.NewMenuItem(option, func() { e.SetText(option) })) } e.dropDown = fyne.NewMenu("", items...) - dropDownButton := NewButton("", func() { + var dropDownButton *Button + dropDownButton = NewButton("", func() { c := fyne.CurrentApp().Driver().CanvasForObject(e.super()) - + if e.popUp != nil { + e.popUp.Hide() + e.popUp = nil + } entryPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(e.super()) popUpPos := entryPos.Add(fyne.NewPos(0, e.Size().Height)) - e.popUp = newPopUpMenu(fyne.NewMenu("", items...), c) e.popUp.ShowAtPosition(popUpPos) + c.Focus(e.popUp) + e.popUp.parent = dropDownButton e.popUp.Resize(fyne.NewSize(e.Size().Width, e.popUp.MinSize().Height)) + e.popUp.Menu.selectCurrent(e.Text) }) dropDownButton.SetIcon(theme.MenuDropDownIcon()) e.ActionItem = dropDownButton diff --git a/widget/slider.go b/widget/slider.go index 7bee062080..05bdb64e18 100644 --- a/widget/slider.go +++ b/widget/slider.go @@ -1,6 +1,7 @@ package widget import ( + "image/color" "math" "fyne.io/fyne" @@ -24,10 +25,11 @@ var _ fyne.Draggable = (*Slider)(nil) type Slider struct { BaseWidget - Value float64 - Min float64 - Max float64 - Step float64 + Value float64 + Min float64 + Max float64 + Step float64 + focused bool Orientation Orientation OnChanged func(float64) @@ -46,6 +48,53 @@ func NewSlider(min, max float64) *Slider { return slider } +// FocusGained is called when the Entry has been given focus. +func (s *Slider) FocusGained() { + s.focused = true + s.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (s *Slider) FocusLost() { + s.focused = false + s.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (s *Slider) Focused() bool { + return s.focused +} + +// TypedRune is not used here +func (s *Slider) TypedRune(rune) { +} + +// TypedKey recieves keyboard events when slider is focused +func (s *Slider) TypedKey(key *fyne.KeyEvent) { + if key.Name == fyne.KeyLeft || key.Name == fyne.KeyDown { + if s.Value > s.Min+s.Step { + s.Value -= s.Step + } else { + s.Value = s.Min + } + } else if key.Name == fyne.KeyRight || key.Name == fyne.KeyUp { + if s.Value < s.Max-s.Step { + s.Value += s.Step + } else { + s.Value = s.Max + } + } + s.Refresh() +} + +// KeyUp is not used here +func (s *Slider) KeyUp(*fyne.KeyEvent) { +} + +// KeyDown is not used here +func (s *Slider) KeyDown(*fyne.KeyEvent) { +} + // DragEnd function. func (s *Slider) DragEnd() { } @@ -210,6 +259,16 @@ func (s *sliderRenderer) MinSize() fyne.Size { return fyne.Size{Width: 0, Height: 0} } +func (s *sliderRenderer) BackgroundColor() color.Color { + if s.slider.focused { + return theme.FocusColor() + } + return theme.BackgroundColor() +} + +func (s *sliderRenderer) Destroy() { +} + func (s *sliderRenderer) getOffset() int { w := s.slider size := s.track.Size() diff --git a/widget/tabcontainer.go b/widget/tabcontainer.go index 89572ec7ca..3b910604db 100644 --- a/widget/tabcontainer.go +++ b/widget/tabcontainer.go @@ -20,6 +20,7 @@ type TabContainer struct { OnChanged func(tab *TabItem) current int tabLocation TabLocation + focused bool } // TabItem represents a single view in a TabContainer. @@ -49,7 +50,6 @@ func NewTabContainer(items ...*TabItem) *TabContainer { tabs.current = 0 } tabs.ExtendBaseWidget(tabs) - if tabs.mismatchedContent() { internal.LogHint("TabContainer items should all have the same type of content (text, icons or both)") } @@ -299,8 +299,10 @@ func (r *tabContainerRenderer) Refresh() { } for i, button := range r.tabBar.Objects { if i == current { + button.(*tabButton).focused = r.container.focused button.(*tabButton).Style = PrimaryButton } else { + button.(*tabButton).focused = false button.(*tabButton).Style = DefaultButton } @@ -411,6 +413,8 @@ var _ desktop.Hoverable = (*tabButton)(nil) type tabButton struct { BaseWidget hovered bool + focused bool + pressed bool Icon fyne.Resource IconPosition buttonIconPosition OnTap func() @@ -418,6 +422,40 @@ type tabButton struct { Text string } +// FocusGained is called when the Entry has been given focus. +func (c *TabContainer) FocusGained() { + c.focused = true + c.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (c *TabContainer) FocusLost() { + c.focused = false + c.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (c *TabContainer) Focused() bool { + return c.focused +} + +// TypedRune is not used +func (c *TabContainer) TypedRune(rune) { +} + +// TypedKey receive keyboard events when the TabContainer is focused +func (c *TabContainer) TypedKey(key *fyne.KeyEvent) { + if key.Name == fyne.KeyLeft || key.Name == fyne.KeyUp { + if c.current > 0 { + c.SelectTabIndex(c.current - 1) + } + } else if key.Name == fyne.KeyRight || key.Name == fyne.KeyDown { + if c.current < len(c.Items)-1 { + c.SelectTabIndex(c.current + 1) + } + } +} + func (b *tabButton) CreateRenderer() fyne.WidgetRenderer { b.ExtendBaseWidget(b) var icon *canvas.Image @@ -481,10 +519,14 @@ type tabButtonRenderer struct { func (r *tabButtonRenderer) BackgroundColor() color.Color { switch { - case r.button.Style == PrimaryButton: - return theme.PrimaryColor() + case r.button.pressed: + return theme.Shade(theme.FocusColor(), theme.PressedShade) case r.button.hovered: return theme.HoverColor() + case r.button.focused: + return theme.FocusColor() + case r.button.Style == PrimaryButton: + return theme.PrimaryColor() default: return theme.BackgroundColor() } diff --git a/widget/testdata/entry/focus_focus_gained.png b/widget/testdata/entry/focus_focus_gained.png index 3d9043cb03..5350cc3847 100644 Binary files a/widget/testdata/entry/focus_focus_gained.png and b/widget/testdata/entry/focus_focus_gained.png differ diff --git a/widget/testdata/entry/focus_with_popup_dismissed.png b/widget/testdata/entry/focus_with_popup_dismissed.png index 3d9043cb03..5350cc3847 100644 Binary files a/widget/testdata/entry/focus_with_popup_dismissed.png and b/widget/testdata/entry/focus_with_popup_dismissed.png differ diff --git a/widget/testdata/entry/focus_with_popup_entry_selected.png b/widget/testdata/entry/focus_with_popup_entry_selected.png index 3d9043cb03..5350cc3847 100644 Binary files a/widget/testdata/entry/focus_with_popup_entry_selected.png and b/widget/testdata/entry/focus_with_popup_entry_selected.png differ diff --git a/widget/testdata/entry/focus_with_popup_initial.png b/widget/testdata/entry/focus_with_popup_initial.png index e6466ced77..c9098a5b88 100644 Binary files a/widget/testdata/entry/focus_with_popup_initial.png and b/widget/testdata/entry/focus_with_popup_initial.png differ diff --git a/widget/testdata/entry/on_key_down_newline_typed.png b/widget/testdata/entry/on_key_down_newline_typed.png index bbd19290f0..5fa16e5579 100644 Binary files a/widget/testdata/entry/on_key_down_newline_typed.png and b/widget/testdata/entry/on_key_down_newline_typed.png differ diff --git a/widget/testdata/entry/select_add_selection.png b/widget/testdata/entry/select_add_selection.png index 279bbba35b..6d28b15d08 100644 Binary files a/widget/testdata/entry/select_add_selection.png and b/widget/testdata/entry/select_add_selection.png differ diff --git a/widget/testdata/entry/select_all_initial.png b/widget/testdata/entry/select_all_initial.png index edcdb79625..8999abce40 100644 Binary files a/widget/testdata/entry/select_all_initial.png and b/widget/testdata/entry/select_all_initial.png differ diff --git a/widget/testdata/entry/select_all_selected.png b/widget/testdata/entry/select_all_selected.png index b00b595d8f..8175c9162a 100644 Binary files a/widget/testdata/entry/select_all_selected.png and b/widget/testdata/entry/select_all_selected.png differ diff --git a/widget/testdata/entry/select_initial.png b/widget/testdata/entry/select_initial.png index 9c985c0531..d85764bfae 100644 Binary files a/widget/testdata/entry/select_initial.png and b/widget/testdata/entry/select_initial.png differ diff --git a/widget/testdata/entry/select_move_wo_shift.png b/widget/testdata/entry/select_move_wo_shift.png index 7d7e525c03..4c57036ab6 100644 Binary files a/widget/testdata/entry/select_move_wo_shift.png and b/widget/testdata/entry/select_move_wo_shift.png differ diff --git a/widget/testdata/entry/select_multi_line_initial.png b/widget/testdata/entry/select_multi_line_initial.png index 14b5c6b99d..8037d48ba6 100644 Binary files a/widget/testdata/entry/select_multi_line_initial.png and b/widget/testdata/entry/select_multi_line_initial.png differ diff --git a/widget/testdata/entry/select_multi_line_pagedown.png b/widget/testdata/entry/select_multi_line_pagedown.png index fcf589cdd3..fc59584d2b 100644 Binary files a/widget/testdata/entry/select_multi_line_pagedown.png and b/widget/testdata/entry/select_multi_line_pagedown.png differ diff --git a/widget/testdata/entry/select_multi_line_shift_pagedown.png b/widget/testdata/entry/select_multi_line_shift_pagedown.png index 74d9a4dcec..fb46eca9a9 100644 Binary files a/widget/testdata/entry/select_multi_line_shift_pagedown.png and b/widget/testdata/entry/select_multi_line_shift_pagedown.png differ diff --git a/widget/testdata/entry/select_multi_line_shift_pageup.png b/widget/testdata/entry/select_multi_line_shift_pageup.png index 80d4935917..682bead6b8 100644 Binary files a/widget/testdata/entry/select_multi_line_shift_pageup.png and b/widget/testdata/entry/select_multi_line_shift_pageup.png differ diff --git a/widget/testdata/entry/select_select_left.png b/widget/testdata/entry/select_select_left.png index 11fec263ce..0e66ab664b 100644 Binary files a/widget/testdata/entry/select_select_left.png and b/widget/testdata/entry/select_select_left.png differ diff --git a/widget/testdata/entry/select_selected.png b/widget/testdata/entry/select_selected.png index da19cefa42..49ed8a85b8 100644 Binary files a/widget/testdata/entry/select_selected.png and b/widget/testdata/entry/select_selected.png differ diff --git a/widget/testdata/entry/select_single_line_pagedown.png b/widget/testdata/entry/select_single_line_pagedown.png index cf277d468e..82aca00955 100644 Binary files a/widget/testdata/entry/select_single_line_pagedown.png and b/widget/testdata/entry/select_single_line_pagedown.png differ diff --git a/widget/testdata/entry/select_single_line_shift_pagedown.png b/widget/testdata/entry/select_single_line_shift_pagedown.png index dd6401310d..6816f7ac50 100644 Binary files a/widget/testdata/entry/select_single_line_shift_pagedown.png and b/widget/testdata/entry/select_single_line_shift_pagedown.png differ diff --git a/widget/testdata/entry/select_single_line_shift_pageup.png b/widget/testdata/entry/select_single_line_shift_pageup.png index 02964d4047..dcdc1c6da4 100644 Binary files a/widget/testdata/entry/select_single_line_shift_pageup.png and b/widget/testdata/entry/select_single_line_shift_pageup.png differ diff --git a/widget/testdata/entry/selection_add_one_row_down.png b/widget/testdata/entry/selection_add_one_row_down.png index 2b341c7dfb..9b1de9fa99 100644 Binary files a/widget/testdata/entry/selection_add_one_row_down.png and b/widget/testdata/entry/selection_add_one_row_down.png differ diff --git a/widget/testdata/entry/selection_add_to_end.png b/widget/testdata/entry/selection_add_to_end.png index 0e1b8acefb..73b65fdf5e 100644 Binary files a/widget/testdata/entry/selection_add_to_end.png and b/widget/testdata/entry/selection_add_to_end.png differ diff --git a/widget/testdata/entry/selection_add_to_home.png b/widget/testdata/entry/selection_add_to_home.png index b097debe47..5976831eba 100644 Binary files a/widget/testdata/entry/selection_add_to_home.png and b/widget/testdata/entry/selection_add_to_home.png differ diff --git a/widget/testdata/entry/selection_delete_and_add_down.png b/widget/testdata/entry/selection_delete_and_add_down.png index 2f38a59397..2a1d3289eb 100644 Binary files a/widget/testdata/entry/selection_delete_and_add_down.png and b/widget/testdata/entry/selection_delete_and_add_down.png differ diff --git a/widget/testdata/entry/selection_delete_and_add_up.png b/widget/testdata/entry/selection_delete_and_add_up.png index 9fb3d18b38..c110462438 100644 Binary files a/widget/testdata/entry/selection_delete_and_add_up.png and b/widget/testdata/entry/selection_delete_and_add_up.png differ diff --git a/widget/testdata/entry/selection_delete_multi_line.png b/widget/testdata/entry/selection_delete_multi_line.png index facedc9521..3c38ff251c 100644 Binary files a/widget/testdata/entry/selection_delete_multi_line.png and b/widget/testdata/entry/selection_delete_multi_line.png differ diff --git a/widget/testdata/entry/selection_delete_reverse_multi_line.png b/widget/testdata/entry/selection_delete_reverse_multi_line.png index a80538f64f..e7b9a478d3 100644 Binary files a/widget/testdata/entry/selection_delete_reverse_multi_line.png and b/widget/testdata/entry/selection_delete_reverse_multi_line.png differ diff --git a/widget/testdata/entry/selection_delete_single_line.png b/widget/testdata/entry/selection_delete_single_line.png index 36eab8de2b..d14e34e7b9 100644 Binary files a/widget/testdata/entry/selection_delete_single_line.png and b/widget/testdata/entry/selection_delete_single_line.png differ diff --git a/widget/testdata/entry/selection_deselect_backspace.png b/widget/testdata/entry/selection_deselect_backspace.png index 5e0b8b754d..7b9b525cca 100644 Binary files a/widget/testdata/entry/selection_deselect_backspace.png and b/widget/testdata/entry/selection_deselect_backspace.png differ diff --git a/widget/testdata/entry/selection_deselect_delete.png b/widget/testdata/entry/selection_deselect_delete.png index 73765bba56..a373846e0b 100644 Binary files a/widget/testdata/entry/selection_deselect_delete.png and b/widget/testdata/entry/selection_deselect_delete.png differ diff --git a/widget/testdata/entry/selection_deselect_select_backspace.png b/widget/testdata/entry/selection_deselect_select_backspace.png index c7e6551843..43daa21a73 100644 Binary files a/widget/testdata/entry/selection_deselect_select_backspace.png and b/widget/testdata/entry/selection_deselect_select_backspace.png differ diff --git a/widget/testdata/entry/selection_end.png b/widget/testdata/entry/selection_end.png index 91ecc34e7f..9439c7e584 100644 Binary files a/widget/testdata/entry/selection_end.png and b/widget/testdata/entry/selection_end.png differ diff --git a/widget/testdata/entry/selection_enter.png b/widget/testdata/entry/selection_enter.png index d9c67308be..9274aa5382 100644 Binary files a/widget/testdata/entry/selection_enter.png and b/widget/testdata/entry/selection_enter.png differ diff --git a/widget/testdata/entry/selection_focus_gained.png b/widget/testdata/entry/selection_focus_gained.png index 6683489439..6f3dd25eb6 100644 Binary files a/widget/testdata/entry/selection_focus_gained.png and b/widget/testdata/entry/selection_focus_gained.png differ diff --git a/widget/testdata/entry/selection_home.png b/widget/testdata/entry/selection_home.png index 1a50261b1e..16653a60b7 100644 Binary files a/widget/testdata/entry/selection_home.png and b/widget/testdata/entry/selection_home.png differ diff --git a/widget/testdata/entry/selection_initial.png b/widget/testdata/entry/selection_initial.png index 6683489439..6f3dd25eb6 100644 Binary files a/widget/testdata/entry/selection_initial.png and b/widget/testdata/entry/selection_initial.png differ diff --git a/widget/testdata/entry/selection_remove_add_one_row_up.png b/widget/testdata/entry/selection_remove_add_one_row_up.png index 8de8b88063..fdbe060206 100644 Binary files a/widget/testdata/entry/selection_remove_add_one_row_up.png and b/widget/testdata/entry/selection_remove_add_one_row_up.png differ diff --git a/widget/testdata/entry/selection_remove_one_row_up.png b/widget/testdata/entry/selection_remove_one_row_up.png index 6683489439..6f3dd25eb6 100644 Binary files a/widget/testdata/entry/selection_remove_one_row_up.png and b/widget/testdata/entry/selection_remove_one_row_up.png differ diff --git a/widget/testdata/entry/selection_replace.png b/widget/testdata/entry/selection_replace.png index 4abf704cab..60044769d9 100644 Binary files a/widget/testdata/entry/selection_replace.png and b/widget/testdata/entry/selection_replace.png differ diff --git a/widget/testdata/entry/selection_reverse_initial.png b/widget/testdata/entry/selection_reverse_initial.png index 7eef257aa6..3ab3db7178 100644 Binary files a/widget/testdata/entry/selection_reverse_initial.png and b/widget/testdata/entry/selection_reverse_initial.png differ diff --git a/widget/testdata/entry/selection_snap_down.png b/widget/testdata/entry/selection_snap_down.png index 5e66b52aac..ebf7a9c202 100644 Binary files a/widget/testdata/entry/selection_snap_down.png and b/widget/testdata/entry/selection_snap_down.png differ diff --git a/widget/testdata/entry/selection_snap_left.png b/widget/testdata/entry/selection_snap_left.png index f8ad921d57..6c61f2a5d7 100644 Binary files a/widget/testdata/entry/selection_snap_left.png and b/widget/testdata/entry/selection_snap_left.png differ diff --git a/widget/testdata/entry/selection_snap_right.png b/widget/testdata/entry/selection_snap_right.png index b106dddd43..52601fc687 100644 Binary files a/widget/testdata/entry/selection_snap_right.png and b/widget/testdata/entry/selection_snap_right.png differ diff --git a/widget/testdata/entry/selection_snap_up.png b/widget/testdata/entry/selection_snap_up.png index 407ef313df..fef425f52e 100644 Binary files a/widget/testdata/entry/selection_snap_up.png and b/widget/testdata/entry/selection_snap_up.png differ diff --git a/widget/testdata/entry/set_readonly_on_focus_writable.png b/widget/testdata/entry/set_readonly_on_focus_writable.png index 3d9043cb03..5350cc3847 100644 Binary files a/widget/testdata/entry/set_readonly_on_focus_writable.png and b/widget/testdata/entry/set_readonly_on_focus_writable.png differ diff --git a/widget/testdata/entry/tapped_focused.png b/widget/testdata/entry/tapped_focused.png index f663820b33..b555e8a06a 100644 Binary files a/widget/testdata/entry/tapped_focused.png and b/widget/testdata/entry/tapped_focused.png differ diff --git a/widget/testdata/entry/tapped_secondary_full_menu.png b/widget/testdata/entry/tapped_secondary_full_menu.png index a22c73cc3b..00d27133a9 100644 Binary files a/widget/testdata/entry/tapped_secondary_full_menu.png and b/widget/testdata/entry/tapped_secondary_full_menu.png differ diff --git a/widget/testdata/entry/tapped_secondary_password_menu.png b/widget/testdata/entry/tapped_secondary_password_menu.png index 3aab387298..072d8553b2 100644 Binary files a/widget/testdata/entry/tapped_secondary_password_menu.png and b/widget/testdata/entry/tapped_secondary_password_menu.png differ diff --git a/widget/testdata/entry/tapped_tapped_2nd_m.png b/widget/testdata/entry/tapped_tapped_2nd_m.png index 93bfbf7f65..b7522cbcc1 100644 Binary files a/widget/testdata/entry/tapped_tapped_2nd_m.png and b/widget/testdata/entry/tapped_tapped_2nd_m.png differ diff --git a/widget/testdata/entry/tapped_tapped_3nd_m.png b/widget/testdata/entry/tapped_tapped_3nd_m.png index c36aabe0fd..680f222b23 100644 Binary files a/widget/testdata/entry/tapped_tapped_3nd_m.png and b/widget/testdata/entry/tapped_tapped_3nd_m.png differ diff --git a/widget/testdata/entry/tapped_tapped_after_last_col.png b/widget/testdata/entry/tapped_tapped_after_last_col.png index ee376a428c..831ec4ea5a 100644 Binary files a/widget/testdata/entry/tapped_tapped_after_last_col.png and b/widget/testdata/entry/tapped_tapped_after_last_col.png differ diff --git a/widget/testdata/entry/tapped_tapped_after_last_row.png b/widget/testdata/entry/tapped_tapped_after_last_row.png index 36ca525b5c..575946961e 100644 Binary files a/widget/testdata/entry/tapped_tapped_after_last_row.png and b/widget/testdata/entry/tapped_tapped_after_last_row.png differ diff --git a/widget/testdata/entry/wrap_multi_line_off.png b/widget/testdata/entry/wrap_multi_line_off.png index 5f20ba0f33..a41bd59679 100644 Binary files a/widget/testdata/entry/wrap_multi_line_off.png and b/widget/testdata/entry/wrap_multi_line_off.png differ diff --git a/widget/testdata/entry/wrap_multi_line_wrap_break.png b/widget/testdata/entry/wrap_multi_line_wrap_break.png index aca7576adc..5f351efc0e 100644 Binary files a/widget/testdata/entry/wrap_multi_line_wrap_break.png and b/widget/testdata/entry/wrap_multi_line_wrap_break.png differ diff --git a/widget/testdata/entry/wrap_multi_line_wrap_word.png b/widget/testdata/entry/wrap_multi_line_wrap_word.png index f5cf3257e8..8ef5fa7216 100644 Binary files a/widget/testdata/entry/wrap_multi_line_wrap_word.png and b/widget/testdata/entry/wrap_multi_line_wrap_word.png differ diff --git a/widget/testdata/entry/wrap_single_line_off.png b/widget/testdata/entry/wrap_single_line_off.png index 9e6121792a..52c37f59c2 100644 Binary files a/widget/testdata/entry/wrap_single_line_off.png and b/widget/testdata/entry/wrap_single_line_off.png differ diff --git a/widget/testdata/password_entry/concealed.png b/widget/testdata/password_entry/concealed.png index 95bbb329c2..35c51e3c06 100644 Binary files a/widget/testdata/password_entry/concealed.png and b/widget/testdata/password_entry/concealed.png differ diff --git a/widget/testdata/password_entry/obfuscation_typed.png b/widget/testdata/password_entry/obfuscation_typed.png index 95bbb329c2..35c51e3c06 100644 Binary files a/widget/testdata/password_entry/obfuscation_typed.png and b/widget/testdata/password_entry/obfuscation_typed.png differ diff --git a/widget/testdata/password_entry/placeholder_typed.png b/widget/testdata/password_entry/placeholder_typed.png index 95bbb329c2..35c51e3c06 100644 Binary files a/widget/testdata/password_entry/placeholder_typed.png and b/widget/testdata/password_entry/placeholder_typed.png differ diff --git a/widget/testdata/password_entry/revealed.png b/widget/testdata/password_entry/revealed.png index aa01af7dae..25d67649cd 100644 Binary files a/widget/testdata/password_entry/revealed.png and b/widget/testdata/password_entry/revealed.png differ diff --git a/widget/testdata/select_entry_dropdown_B_opened.png b/widget/testdata/select_entry_dropdown_B_opened.png index 1e8d9dcb08..6d09222b2f 100644 Binary files a/widget/testdata/select_entry_dropdown_B_opened.png and b/widget/testdata/select_entry_dropdown_B_opened.png differ diff --git a/widget/testdata/select_entry_dropdown_empty_opened.png b/widget/testdata/select_entry_dropdown_empty_opened.png index 2764b6b76d..11c7c3f2cf 100644 Binary files a/widget/testdata/select_entry_dropdown_empty_opened.png and b/widget/testdata/select_entry_dropdown_empty_opened.png differ diff --git a/widget/testdata/select_entry_dropdown_empty_opened_shrunk.png b/widget/testdata/select_entry_dropdown_empty_opened_shrunk.png index 45fb049146..393d43fe65 100644 Binary files a/widget/testdata/select_entry_dropdown_empty_opened_shrunk.png and b/widget/testdata/select_entry_dropdown_empty_opened_shrunk.png differ diff --git a/widget/testdata/select_entry_dropdown_tapped_B.png b/widget/testdata/select_entry_dropdown_tapped_B.png index f1ef87477a..82b9eab6eb 100644 Binary files a/widget/testdata/select_entry_dropdown_tapped_B.png and b/widget/testdata/select_entry_dropdown_tapped_B.png differ diff --git a/widget/testdata/select_entry_dropdown_tapped_C.png b/widget/testdata/select_entry_dropdown_tapped_C.png index 9e29058e59..c0d9525eb6 100644 Binary files a/widget/testdata/select_entry_dropdown_tapped_C.png and b/widget/testdata/select_entry_dropdown_tapped_C.png differ diff --git a/widget/testdata/tabcontainer/desktop/two_first_hovered.png b/widget/testdata/tabcontainer/desktop/two_first_hovered.png index de236c26ae..e7b6542647 100644 Binary files a/widget/testdata/tabcontainer/desktop/two_first_hovered.png and b/widget/testdata/tabcontainer/desktop/two_first_hovered.png differ diff --git a/widget/toolbar.go b/widget/toolbar.go index 33b32ff4b7..e43f5645e7 100644 --- a/widget/toolbar.go +++ b/widget/toolbar.go @@ -23,9 +23,7 @@ type ToolbarAction struct { // ToolbarObject gets a button to render this ToolbarAction func (t *ToolbarAction) ToolbarObject() fyne.CanvasObject { - button := NewButtonWithIcon("", t.Icon, t.OnActivated) - button.HideShadow = true - + button := newToolbarButton(t.Icon, t.OnActivated) return button } @@ -68,7 +66,79 @@ func NewToolbarSeparator() ToolbarItem { // Toolbar widget creates a horizontal list of tool buttons type Toolbar struct { BaseWidget - Items []ToolbarItem + Items []ToolbarItem + focused bool + current int + buttons []*ToolbarButton +} + +// FocusGained is called when the Entry has been given focus. +func (t *Toolbar) FocusGained() { + t.focused = true + if t.current < len(t.Items) { + t.buttons[t.current].focused = true + } + t.Refresh() +} + +// FocusLost is called when the Entry has had focus removed. +func (t *Toolbar) FocusLost() { + t.focused = false + if t.current < len(t.buttons) { + t.buttons[t.current].focused = false + } + t.Refresh() +} + +// Focused returns whether or not this Entry has focus. +func (t *Toolbar) Focused() bool { + return t.focused +} + +// TypedRune is not usedd +func (t *Toolbar) TypedRune(rune) { +} + +func (t *Toolbar) changeFocusedButton(delta int) { + t.current = t.current + delta + if t.current < 0 { + t.current = 0 + } + if t.current >= len(t.buttons) { + t.current = len(t.buttons) - 1 + } +} + +// TypedKey receives keyboard events when the toolbar is focused +func (t *Toolbar) TypedKey(key *fyne.KeyEvent) { + t.buttons[t.current].focused = false + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + t.buttons[t.current].OnTap() + } + if key.Name == fyne.KeyLeft || key.Name == fyne.KeyUp { + t.changeFocusedButton(-1) + } else if key.Name == fyne.KeyRight || key.Name == fyne.KeyDown { + t.changeFocusedButton(+1) + } + + t.buttons[t.current].focused = true + t.Refresh() +} + +// KeyUp is called when a key is released +func (t *Toolbar) KeyUp(key *fyne.KeyEvent) { + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + t.buttons[t.current].pressed = false + t.buttons[t.current].Refresh() + } +} + +// KeyDown is called when a key is pressed +func (t *Toolbar) KeyDown(key *fyne.KeyEvent) { + if key.Name == fyne.KeyReturn || key.Name == fyne.KeyEnter || key.Name == fyne.KeySpace { + t.buttons[t.current].pressed = true + t.buttons[t.current].Refresh() + } } // CreateRenderer is a private method to Fyne which links this widget to its renderer @@ -101,7 +171,6 @@ func (t *Toolbar) MinSize() fyne.Size { func NewToolbar(items ...ToolbarItem) *Toolbar { t := &Toolbar{Items: items} t.ExtendBaseWidget(t) - t.Refresh() return t } @@ -140,8 +209,18 @@ func (r *toolbarRenderer) Refresh() { func (r *toolbarRenderer) resetObjects() { if len(r.objs) != len(r.toolbar.Items) { r.objs = make([]fyne.CanvasObject, 0, len(r.toolbar.Items)) - for _, item := range r.toolbar.Items { - r.objs = append(r.objs, item.ToolbarObject()) + // Remove old buttons and recreate when renderer is recreated + r.toolbar.buttons = nil + for i, item := range r.toolbar.Items { + o := item.ToolbarObject() + if b, ok := o.(*ToolbarButton); ok { + b.toolbar = r.toolbar + r.toolbar.buttons = append(r.toolbar.buttons, b) + if r.toolbar.current == i { + b.focused = r.toolbar.focused + } + } + r.objs = append(r.objs, o) } } r.SetObjects(r.objs) diff --git a/widget/toolbar_button.go b/widget/toolbar_button.go new file mode 100644 index 0000000000..c610871e37 --- /dev/null +++ b/widget/toolbar_button.go @@ -0,0 +1,212 @@ +package widget + +import ( + "image/color" + + "fyne.io/fyne/driver/desktop" + "fyne.io/fyne/internal/widget" + + "fyne.io/fyne" + "fyne.io/fyne/canvas" + "fyne.io/fyne/theme" +) + +var _ fyne.Tappable = (*ToolbarButton)(nil) +var _ desktop.Hoverable = (*ToolbarButton)(nil) + +// ToolbarButton is mostly like an ordinary button, but it can not be focused, only tapped +// The focusing is done by the toolbar. This is needed to avoid tabbing through all toolbar +// buttons. Instead the arrow keys can be used to select the toolbar button when the toolbar +// has focus. +type ToolbarButton struct { + BaseWidget + toolbar *Toolbar + hovered bool + focused bool + pressed bool + Icon fyne.Resource + IconPosition buttonIconPosition + OnTap func() + Text string +} + +// MinSize returns the size that this widget should not shrink below +func (b *ToolbarButton) MinSize() fyne.Size { + b.ExtendBaseWidget(b) + return b.BaseWidget.MinSize() +} + +// CreateRenderer is a private method to Fyne which links this widget to its renderer +func (b *ToolbarButton) CreateRenderer() fyne.WidgetRenderer { + b.ExtendBaseWidget(b) + var icon *canvas.Image + if b.Icon != nil { + icon = canvas.NewImageFromResource(b.Icon) + } + + label := canvas.NewText(b.Text, theme.TextColor()) + label.Alignment = fyne.TextAlignCenter + + objects := []fyne.CanvasObject{label} + if icon != nil { + objects = append(objects, icon) + } + return &ToolbarButtonRenderer{ + BaseRenderer: widget.NewBaseRenderer(objects), + button: b, + icon: icon, + label: label, + } +} + +// Tapped is called when a pointer tapped event is captured and triggers any tap handler +func (b *ToolbarButton) Tapped(e *fyne.PointEvent) { + b.OnTap() +} + +// MouseIn is called when a desktop pointer enters the widget +func (b *ToolbarButton) MouseIn(e *desktop.MouseEvent) { + b.hovered = true + canvas.Refresh(b) +} + +// MouseOut is called when a desktop pointer exits the widget +func (b *ToolbarButton) MouseOut() { + b.hovered = false + canvas.Refresh(b) +} + +// MouseMoved is called when a desktop pointer hovers over the widget +func (b *ToolbarButton) MouseMoved(e *desktop.MouseEvent) { +} + +// MouseDown called on mouse click +func (b *ToolbarButton) MouseDown(m *desktop.MouseEvent) { + b.pressed = true + b.focused = true + for j, o := range b.toolbar.buttons { + if o == b { + o.focused = true + b.toolbar.current = j + } else { + o.focused = false + } + } + b.Refresh() +} + +// MouseUp called on mouse release +// If a mouse drag event has completed then check to see if it has resulted in an empty selection, +// if so, and if a text select key isn't held, then disable selecting +func (b *ToolbarButton) MouseUp(m *desktop.MouseEvent) { + b.pressed = false + b.focused = false + b.Refresh() +} + +// ToolbarButtonRenderer is the renderer for toolbar buttons +type ToolbarButtonRenderer struct { + widget.BaseRenderer + button *ToolbarButton + icon *canvas.Image + label *canvas.Text +} + +// Refresh updates the widget state when requested. +func (r *ToolbarButtonRenderer) Refresh() { + r.label.Color = theme.TextColor() + r.label.TextSize = theme.TextSize() + canvas.Refresh(r.button) +} + +func (r *ToolbarButtonRenderer) padding() fyne.Size { + return fyne.NewSize(theme.Padding()*2, theme.Padding()*2) +} + +// BackgroundColor returns the theme background color. +// Implements: fyne.WidgetRenderer +func (r *ToolbarButtonRenderer) BackgroundColor() color.Color { + switch { + case r.button.pressed: + return theme.PressedColor() + case r.button.hovered: + return theme.HoverColor() + case r.button.focused: + return theme.FocusColor() + default: + return theme.ButtonColor() + } +} + +// Layout the components of the button widget +func (r *ToolbarButtonRenderer) Layout(size fyne.Size) { + padding := r.padding() + innerSize := size.Subtract(padding) + innerOffset := fyne.NewPos(padding.Width/2, padding.Height/2) + labelShift := 0 + if r.icon != nil { + var iconOffset fyne.Position + if r.button.IconPosition == buttonIconTop { + iconOffset = fyne.NewPos((innerSize.Width-r.iconSize())/2, 0) + } else { + iconOffset = fyne.NewPos(0, (innerSize.Height-r.iconSize())/2) + } + r.icon.Resize(fyne.NewSize(r.iconSize(), r.iconSize())) + r.icon.Move(innerOffset.Add(iconOffset)) + labelShift = r.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)) + } +} + +// MinSize calculates the smallest size that will fit the listed +func (r *ToolbarButtonRenderer) MinSize() fyne.Size { + var contentWidth, contentHeight int + textSize := r.label.MinSize() + if r.button.IconPosition == buttonIconTop { + contentWidth = fyne.Max(textSize.Width, r.iconSize()) + if r.icon != nil { + contentHeight += r.iconSize() + } + if r.label.Text != "" { + if r.icon != nil { + contentHeight += theme.Padding() + } + contentHeight += textSize.Height + } + } else { + contentHeight = fyne.Max(textSize.Height, r.iconSize()) + if r.icon != nil { + contentWidth += r.iconSize() + } + if r.label.Text != "" { + if r.icon != nil { + contentWidth += theme.Padding() + } + contentWidth += textSize.Width + } + } + return fyne.NewSize(contentWidth, contentHeight).Add(r.padding()) +} + +func (r *ToolbarButtonRenderer) iconSize() int { + return theme.IconInlineSize() +} + +// newToolbarButton creates a new button widget with the specified label, themed icon and tap handler +func newToolbarButton(icon fyne.Resource, tapped func()) *ToolbarButton { + button := &ToolbarButton{OnTap: tapped, Icon: icon} + button.ExtendBaseWidget(button) + return button +} diff --git a/widget/toolbar_test.go b/widget/toolbar_test.go index 9e68e0e67e..85b3037850 100644 --- a/widget/toolbar_test.go +++ b/widget/toolbar_test.go @@ -46,7 +46,7 @@ func TestToolbar_ItemPositioning(t *testing.T) { toolbar.Refresh() var items []fyne.CanvasObject for _, o := range test.LaidOutObjects(toolbar) { - if b, ok := o.(*Button); ok { + if b, ok := o.(*ToolbarButton); ok { items = append(items, b) } }