feat(accent): implement core accent picker domain and UI component
- commit
- 1e8b52f01f8d588a9bb64957656408dc8e5e2e81
- author
- brice · · 2025-12-29 17:38 UTC
- committer
- brice · · 2025-12-30 13:48 UTC
- parent
- 6c70122
- signature
- unsigned
6 files · +520 -0
A internal/application/port/accent_picker.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | // AccentPickerUI defines the interface for displaying an accent selection overlay. | |
| 4 | + | // The picker shows a list of accented characters and allows the user to select one | |
| 5 | + | // via arrow keys, number keys (1-9), or Enter. | |
| 6 | + | type AccentPickerUI interface { | |
| 7 | + | // Show displays the accent picker with the given accent variants. | |
| 8 | + | // selectedCallback is called when the user selects an accent. | |
| 9 | + | // cancelCallback is called when the user cancels (Escape key). | |
| 10 | + | Show(accents []rune, selectedCallback func(accent rune), cancelCallback func()) | |
| 11 | + | ||
| 12 | + | // Hide hides the accent picker. | |
| 13 | + | Hide() | |
| 14 | + | ||
| 15 | + | // IsVisible returns true if the accent picker is currently visible. | |
| 16 | + | IsVisible() bool | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | // AccentPickerUI defines the interface for displaying an accent selection overlay. | |||
| 4 | + | // The picker shows a list of accented characters and allows the user to select one | |||
| 5 | + | // via arrow keys, number keys (1-9), or Enter. | |||
| 6 | + | type AccentPickerUI interface { | |||
| 7 | + | // Show displays the accent picker with the given accent variants. | |||
| 8 | + | // selectedCallback is called when the user selects an accent. | |||
| 9 | + | // cancelCallback is called when the user cancels (Escape key). | |||
| 10 | + | Show(accents []rune, selectedCallback func(accent rune), cancelCallback func()) | |||
| 11 | + | ||||
| 12 | + | // Hide hides the accent picker. | |||
| 13 | + | Hide() | |||
| 14 | + | ||||
| 15 | + | // IsVisible returns true if the accent picker is currently visible. | |||
| 16 | + | IsVisible() bool | |||
| 17 | + | } | |||
A internal/domain/entity/accent.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "unicode" | |
| 4 | + | ||
| 5 | + | // AccentMap maps base characters to their accented variants. | |
| 6 | + | // The order determines display order in the accent picker. | |
| 7 | + | var AccentMap = map[rune][]rune{ | |
| 8 | + | 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'}, | |
| 9 | + | 'c': {'ç', 'ć', 'č'}, | |
| 10 | + | 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'}, | |
| 11 | + | 'i': {'ì', 'í', 'î', 'ï', 'į'}, | |
| 12 | + | 'n': {'ñ', 'ń'}, | |
| 13 | + | 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'}, | |
| 14 | + | 's': {'ß', 'ś', 'š'}, | |
| 15 | + | 'u': {'ù', 'ú', 'û', 'ü', 'ū'}, | |
| 16 | + | 'y': {'ÿ', 'ý'}, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // HasAccents returns true if the given character has accent variants. | |
| 20 | + | func HasAccents(char rune) bool { | |
| 21 | + | lower := unicode.ToLower(char) | |
| 22 | + | _, ok := AccentMap[lower] | |
| 23 | + | return ok | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // GetAccents returns the accent variants for a character. | |
| 27 | + | // If uppercase is true, returns uppercase variants. | |
| 28 | + | // Returns nil if the character has no accent variants. | |
| 29 | + | func GetAccents(char rune, uppercase bool) []rune { | |
| 30 | + | lower := unicode.ToLower(char) | |
| 31 | + | accents, ok := AccentMap[lower] | |
| 32 | + | if !ok { | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | if !uppercase { | |
| 37 | + | return accents | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Return uppercase versions | |
| 41 | + | result := make([]rune, len(accents)) | |
| 42 | + | for i, a := range accents { | |
| 43 | + | result[i] = unicode.ToUpper(a) | |
| 44 | + | } | |
| 45 | + | return result | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import "unicode" | |||
| 4 | + | ||||
| 5 | + | // AccentMap maps base characters to their accented variants. | |||
| 6 | + | // The order determines display order in the accent picker. | |||
| 7 | + | var AccentMap = map[rune][]rune{ | |||
| 8 | + | 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'}, | |||
| 9 | + | 'c': {'ç', 'ć', 'č'}, | |||
| 10 | + | 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'}, | |||
| 11 | + | 'i': {'ì', 'í', 'î', 'ï', 'į'}, | |||
| 12 | + | 'n': {'ñ', 'ń'}, | |||
| 13 | + | 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'}, | |||
| 14 | + | 's': {'ß', 'ś', 'š'}, | |||
| 15 | + | 'u': {'ù', 'ú', 'û', 'ü', 'ū'}, | |||
| 16 | + | 'y': {'ÿ', 'ý'}, | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // HasAccents returns true if the given character has accent variants. | |||
| 20 | + | func HasAccents(char rune) bool { | |||
| 21 | + | lower := unicode.ToLower(char) | |||
| 22 | + | _, ok := AccentMap[lower] | |||
| 23 | + | return ok | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // GetAccents returns the accent variants for a character. | |||
| 27 | + | // If uppercase is true, returns uppercase variants. | |||
| 28 | + | // Returns nil if the character has no accent variants. | |||
| 29 | + | func GetAccents(char rune, uppercase bool) []rune { | |||
| 30 | + | lower := unicode.ToLower(char) | |||
| 31 | + | accents, ok := AccentMap[lower] | |||
| 32 | + | if !ok { | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | if !uppercase { | |||
| 37 | + | return accents | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Return uppercase versions | |||
| 41 | + | result := make([]rune, len(accents)) | |||
| 42 | + | for i, a := range accents { | |||
| 43 | + | result[i] = unicode.ToUpper(a) | |||
| 44 | + | } | |||
| 45 | + | return result | |||
| 46 | + | } | |||
A internal/domain/entity/accent_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | func TestHasAccents(t *testing.T) { | |
| 8 | + | tests := []struct { | |
| 9 | + | name string | |
| 10 | + | char rune | |
| 11 | + | want bool | |
| 12 | + | }{ | |
| 13 | + | {"lowercase e", 'e', true}, | |
| 14 | + | {"uppercase E", 'E', true}, | |
| 15 | + | {"lowercase a", 'a', true}, | |
| 16 | + | {"uppercase A", 'A', true}, | |
| 17 | + | {"lowercase c", 'c', true}, | |
| 18 | + | {"lowercase n", 'n', true}, | |
| 19 | + | {"lowercase x (no accents)", 'x', false}, | |
| 20 | + | {"uppercase X (no accents)", 'X', false}, | |
| 21 | + | {"digit 1 (no accents)", '1', false}, | |
| 22 | + | {"space (no accents)", ' ', false}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | got := HasAccents(tt.char) | |
| 28 | + | if got != tt.want { | |
| 29 | + | t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want) | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestGetAccents(t *testing.T) { | |
| 36 | + | tests := []struct { | |
| 37 | + | name string | |
| 38 | + | char rune | |
| 39 | + | uppercase bool | |
| 40 | + | wantLen int | |
| 41 | + | wantFirst rune | |
| 42 | + | }{ | |
| 43 | + | {"lowercase e", 'e', false, 6, 'è'}, | |
| 44 | + | {"uppercase E", 'E', true, 6, 'È'}, | |
| 45 | + | {"lowercase a", 'a', false, 7, 'à'}, | |
| 46 | + | {"uppercase A", 'A', true, 7, 'À'}, | |
| 47 | + | {"lowercase c", 'c', false, 3, 'ç'}, | |
| 48 | + | {"uppercase C with shift", 'c', true, 3, 'Ç'}, | |
| 49 | + | {"no accents x", 'x', false, 0, 0}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | for _, tt := range tests { | |
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |
| 54 | + | got := GetAccents(tt.char, tt.uppercase) | |
| 55 | + | ||
| 56 | + | if len(got) != tt.wantLen { | |
| 57 | + | t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | if tt.wantLen > 0 && got[0] != tt.wantFirst { | |
| 61 | + | t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst) | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestGetAccents_UppercaseFromLowercase(t *testing.T) { | |
| 68 | + | // When passing lowercase char with uppercase=true, should return uppercase accents | |
| 69 | + | got := GetAccents('e', true) | |
| 70 | + | if len(got) == 0 { | |
| 71 | + | t.Fatal("expected accents for 'e' with uppercase=true") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Verify all returned accents are uppercase | |
| 75 | + | for _, accent := range got { | |
| 76 | + | lower := GetAccents('e', false) | |
| 77 | + | found := false | |
| 78 | + | for _, l := range lower { | |
| 79 | + | if accent != l { | |
| 80 | + | found = true | |
| 81 | + | break | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if !found && accent == got[0] { | |
| 85 | + | // First accent should be uppercase version of first lowercase accent | |
| 86 | + | if accent != 'È' { | |
| 87 | + | t.Errorf("expected uppercase accent È, got %q", accent) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestAccentMapCoverage(t *testing.T) { | |
| 94 | + | // Verify all expected characters have accents | |
| 95 | + | expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'} | |
| 96 | + | ||
| 97 | + | for _, char := range expectedChars { | |
| 98 | + | if !HasAccents(char) { | |
| 99 | + | t.Errorf("expected %q to have accents", char) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | accents := GetAccents(char, false) | |
| 103 | + | if len(accents) == 0 { | |
| 104 | + | t.Errorf("expected %q to have at least one accent variant", char) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | func TestHasAccents(t *testing.T) { | |||
| 8 | + | tests := []struct { | |||
| 9 | + | name string | |||
| 10 | + | char rune | |||
| 11 | + | want bool | |||
| 12 | + | }{ | |||
| 13 | + | {"lowercase e", 'e', true}, | |||
| 14 | + | {"uppercase E", 'E', true}, | |||
| 15 | + | {"lowercase a", 'a', true}, | |||
| 16 | + | {"uppercase A", 'A', true}, | |||
| 17 | + | {"lowercase c", 'c', true}, | |||
| 18 | + | {"lowercase n", 'n', true}, | |||
| 19 | + | {"lowercase x (no accents)", 'x', false}, | |||
| 20 | + | {"uppercase X (no accents)", 'X', false}, | |||
| 21 | + | {"digit 1 (no accents)", '1', false}, | |||
| 22 | + | {"space (no accents)", ' ', false}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | got := HasAccents(tt.char) | |||
| 28 | + | if got != tt.want { | |||
| 29 | + | t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want) | |||
| 30 | + | } | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestGetAccents(t *testing.T) { | |||
| 36 | + | tests := []struct { | |||
| 37 | + | name string | |||
| 38 | + | char rune | |||
| 39 | + | uppercase bool | |||
| 40 | + | wantLen int | |||
| 41 | + | wantFirst rune | |||
| 42 | + | }{ | |||
| 43 | + | {"lowercase e", 'e', false, 6, 'è'}, | |||
| 44 | + | {"uppercase E", 'E', true, 6, 'È'}, | |||
| 45 | + | {"lowercase a", 'a', false, 7, 'à'}, | |||
| 46 | + | {"uppercase A", 'A', true, 7, 'À'}, | |||
| 47 | + | {"lowercase c", 'c', false, 3, 'ç'}, | |||
| 48 | + | {"uppercase C with shift", 'c', true, 3, 'Ç'}, | |||
| 49 | + | {"no accents x", 'x', false, 0, 0}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | for _, tt := range tests { | |||
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | + | got := GetAccents(tt.char, tt.uppercase) | |||
| 55 | + | ||||
| 56 | + | if len(got) != tt.wantLen { | |||
| 57 | + | t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | if tt.wantLen > 0 && got[0] != tt.wantFirst { | |||
| 61 | + | t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst) | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestGetAccents_UppercaseFromLowercase(t *testing.T) { | |||
| 68 | + | // When passing lowercase char with uppercase=true, should return uppercase accents | |||
| 69 | + | got := GetAccents('e', true) | |||
| 70 | + | if len(got) == 0 { | |||
| 71 | + | t.Fatal("expected accents for 'e' with uppercase=true") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Verify all returned accents are uppercase | |||
| 75 | + | for _, accent := range got { | |||
| 76 | + | lower := GetAccents('e', false) | |||
| 77 | + | found := false | |||
| 78 | + | for _, l := range lower { | |||
| 79 | + | if accent != l { | |||
| 80 | + | found = true | |||
| 81 | + | break | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if !found && accent == got[0] { | |||
| 85 | + | // First accent should be uppercase version of first lowercase accent | |||
| 86 | + | if accent != 'È' { | |||
| 87 | + | t.Errorf("expected uppercase accent È, got %q", accent) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestAccentMapCoverage(t *testing.T) { | |||
| 94 | + | // Verify all expected characters have accents | |||
| 95 | + | expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'} | |||
| 96 | + | ||||
| 97 | + | for _, char := range expectedChars { | |||
| 98 | + | if !HasAccents(char) { | |||
| 99 | + | t.Errorf("expected %q to have accents", char) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | accents := GetAccents(char, false) | |||
| 103 | + | if len(accents) == 0 { | |||
| 104 | + | t.Errorf("expected %q to have at least one accent variant", char) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
A internal/ui/component/accent_picker.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Accent picker layout constants. | |
| 13 | + | const ( | |
| 14 | + | accentPickerSpacing = 8 // Spacing between accent items in pixels. | |
| 15 | + | maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9). | |
| 16 | + | accentIndex6 = 6 // Index for accent at position 7 (0-indexed). | |
| 17 | + | accentIndex7 = 7 // Index for accent at position 8 (0-indexed). | |
| 18 | + | accentIndex8 = 8 // Index for accent at position 9 (0-indexed). | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // AccentPicker displays an overlay with accent character options. | |
| 22 | + | // The user can select an accent using: | |
| 23 | + | // - Left/Right arrow keys to navigate | |
| 24 | + | // - Number keys 1-9 to select directly | |
| 25 | + | // - Enter to confirm selection | |
| 26 | + | // - Escape to cancel | |
| 27 | + | type AccentPicker struct { | |
| 28 | + | factory layout.WidgetFactory | |
| 29 | + | ||
| 30 | + | // Widgets | |
| 31 | + | container layout.BoxWidget // Outer container for positioning | |
| 32 | + | accentLabels []layout.LabelWidget // Individual accent character labels | |
| 33 | + | ||
| 34 | + | // State | |
| 35 | + | accents []rune | |
| 36 | + | selectedIndex int | |
| 37 | + | selectedCb func(rune) | |
| 38 | + | cancelCb func() | |
| 39 | + | visible bool | |
| 40 | + | keyController *gtk.EventControllerKey | |
| 41 | + | retainedClicked []interface{} // Keep click callbacks alive | |
| 42 | + | ||
| 43 | + | mu sync.Mutex | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Compile-time interface check. | |
| 47 | + | var _ port.AccentPickerUI = (*AccentPicker)(nil) | |
| 48 | + | ||
| 49 | + | // NewAccentPicker creates a new accent picker component. | |
| 50 | + | func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker { | |
| 51 | + | // Create container box | |
| 52 | + | container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing) | |
| 53 | + | container.AddCssClass("accent-picker") | |
| 54 | + | ||
| 55 | + | // Position at bottom-center | |
| 56 | + | container.SetHalign(gtk.AlignCenterValue) | |
| 57 | + | container.SetValign(gtk.AlignEndValue) | |
| 58 | + | ||
| 59 | + | // Don't expand | |
| 60 | + | container.SetHexpand(false) | |
| 61 | + | container.SetVexpand(false) | |
| 62 | + | ||
| 63 | + | // Make focusable so it can receive keyboard events | |
| 64 | + | container.SetFocusable(true) | |
| 65 | + | container.SetCanFocus(true) | |
| 66 | + | ||
| 67 | + | // Hidden by default | |
| 68 | + | container.SetVisible(false) | |
| 69 | + | ||
| 70 | + | ap := &AccentPicker{ | |
| 71 | + | factory: factory, | |
| 72 | + | container: container, | |
| 73 | + | selectedIndex: 0, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Set up key event handling | |
| 77 | + | ap.setupKeyboardHandling() | |
| 78 | + | ||
| 79 | + | return ap | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Widget returns the accent picker's container widget. | |
| 83 | + | func (ap *AccentPicker) Widget() layout.Widget { | |
| 84 | + | return ap.container | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Show displays the accent picker with the given accent options. | |
| 88 | + | func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) { | |
| 89 | + | ap.mu.Lock() | |
| 90 | + | defer ap.mu.Unlock() | |
| 91 | + | ||
| 92 | + | ap.accents = accents | |
| 93 | + | ap.selectedCb = selectedCb | |
| 94 | + | ap.cancelCb = cancelCb | |
| 95 | + | ap.selectedIndex = 0 | |
| 96 | + | ap.visible = true | |
| 97 | + | ||
| 98 | + | // Clear existing labels | |
| 99 | + | ap.clearLabels() | |
| 100 | + | ||
| 101 | + | // Create new labels for each accent | |
| 102 | + | ap.accentLabels = make([]layout.LabelWidget, len(accents)) | |
| 103 | + | ap.retainedClicked = make([]interface{}, len(accents)) | |
| 104 | + | ||
| 105 | + | for i, accent := range accents { | |
| 106 | + | label := ap.factory.NewLabel(string(accent)) | |
| 107 | + | label.AddCssClass("accent-picker-item") | |
| 108 | + | ||
| 109 | + | // Add number hint for first maxNumberedAccentItems items | |
| 110 | + | if i < maxNumberedAccentItems { | |
| 111 | + | label.AddCssClass("accent-picker-numbered") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Set up click handling | |
| 115 | + | clickCtrl := gtk.NewGestureClick() | |
| 116 | + | idx := i // Capture for closure | |
| 117 | + | clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) { | |
| 118 | + | ap.selectAccent(idx) | |
| 119 | + | } | |
| 120 | + | clickCtrl.ConnectPressed(&clickCb) | |
| 121 | + | ap.retainedClicked[i] = clickCb | |
| 122 | + | label.AddController(&clickCtrl.EventController) | |
| 123 | + | ||
| 124 | + | ap.accentLabels[i] = label | |
| 125 | + | ap.container.Append(label) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Highlight first item | |
| 129 | + | ap.updateSelection() | |
| 130 | + | ||
| 131 | + | ap.container.SetVisible(true) | |
| 132 | + | ap.container.GrabFocus() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Hide hides the accent picker. | |
| 136 | + | func (ap *AccentPicker) Hide() { | |
| 137 | + | ap.mu.Lock() | |
| 138 | + | defer ap.mu.Unlock() | |
| 139 | + | ||
| 140 | + | ap.visible = false | |
| 141 | + | ap.container.SetVisible(false) | |
| 142 | + | ap.clearLabels() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // IsVisible returns true if the accent picker is currently visible. | |
| 146 | + | func (ap *AccentPicker) IsVisible() bool { | |
| 147 | + | ap.mu.Lock() | |
| 148 | + | defer ap.mu.Unlock() | |
| 149 | + | return ap.visible | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // clearLabels removes all accent labels from the container. | |
| 153 | + | func (ap *AccentPicker) clearLabels() { | |
| 154 | + | for _, label := range ap.accentLabels { | |
| 155 | + | label.Unparent() | |
| 156 | + | } | |
| 157 | + | ap.accentLabels = nil | |
| 158 | + | ap.retainedClicked = nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // updateSelection updates the visual selection highlight. | |
| 162 | + | func (ap *AccentPicker) updateSelection() { | |
| 163 | + | for i, label := range ap.accentLabels { | |
| 164 | + | if i == ap.selectedIndex { | |
| 165 | + | label.AddCssClass("accent-picker-selected") | |
| 166 | + | } else { | |
| 167 | + | label.RemoveCssClass("accent-picker-selected") | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // selectAccent selects the accent at the given index. | |
| 173 | + | func (ap *AccentPicker) selectAccent(index int) { | |
| 174 | + | ap.mu.Lock() | |
| 175 | + | if index < 0 || index >= len(ap.accents) { | |
| 176 | + | ap.mu.Unlock() | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | accent := ap.accents[index] | |
| 180 | + | cb := ap.selectedCb | |
| 181 | + | ap.mu.Unlock() | |
| 182 | + | ||
| 183 | + | if cb != nil { | |
| 184 | + | cb(accent) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // cancel cancels the accent picker. | |
| 189 | + | func (ap *AccentPicker) cancel() { | |
| 190 | + | ap.mu.Lock() | |
| 191 | + | cb := ap.cancelCb | |
| 192 | + | ap.mu.Unlock() | |
| 193 | + | ||
| 194 | + | if cb != nil { | |
| 195 | + | cb() | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // setupKeyboardHandling sets up key event handling for navigation. | |
| 200 | + | func (ap *AccentPicker) setupKeyboardHandling() { | |
| 201 | + | keyCtrl := gtk.NewEventControllerKey() | |
| 202 | + | ||
| 203 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |
| 204 | + | return ap.handleKeyPress(keyval) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | keyCtrl.ConnectKeyPressed(&keyPressedCb) | |
| 208 | + | ap.keyController = keyCtrl | |
| 209 | + | ap.container.AddController(&keyCtrl.EventController) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // handleKeyPress processes a key press event. | |
| 213 | + | // Returns true if the key was handled. | |
| 214 | + | func (ap *AccentPicker) handleKeyPress(keyval uint) bool { | |
| 215 | + | ap.mu.Lock() | |
| 216 | + | if !ap.visible { | |
| 217 | + | ap.mu.Unlock() | |
| 218 | + | return false | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Handle navigation keys | |
| 222 | + | if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn { | |
| 223 | + | return handled | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Handle number keys for direct selection | |
| 227 | + | if idx, ok := ap.numberKeyToIndex(keyval); ok { | |
| 228 | + | ap.mu.Unlock() | |
| 229 | + | ap.selectAccent(idx) | |
| 230 | + | return true | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | ap.mu.Unlock() | |
| 234 | + | return false | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // handleNavigationKey handles arrow keys, Enter, and Escape. | |
| 238 | + | // Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled. | |
| 239 | + | func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) { | |
| 240 | + | switch keyval { | |
| 241 | + | case uint(gdk.KEY_Left): | |
| 242 | + | if ap.selectedIndex > 0 { | |
| 243 | + | ap.selectedIndex-- | |
| 244 | + | ap.updateSelection() | |
| 245 | + | } | |
| 246 | + | ap.mu.Unlock() | |
| 247 | + | return true, true | |
| 248 | + | ||
| 249 | + | case uint(gdk.KEY_Right): | |
| 250 | + | if ap.selectedIndex < len(ap.accents)-1 { | |
| 251 | + | ap.selectedIndex++ | |
| 252 | + | ap.updateSelection() | |
| 253 | + | } | |
| 254 | + | ap.mu.Unlock() | |
| 255 | + | return true, true | |
| 256 | + | ||
| 257 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 258 | + | idx := ap.selectedIndex | |
| 259 | + | ap.mu.Unlock() | |
| 260 | + | ap.selectAccent(idx) | |
| 261 | + | return true, true | |
| 262 | + | ||
| 263 | + | case uint(gdk.KEY_Escape): | |
| 264 | + | ap.mu.Unlock() | |
| 265 | + | ap.cancel() | |
| 266 | + | return true, true | |
| 267 | + | ||
| 268 | + | default: | |
| 269 | + | return false, false | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // numberKeyToIndex converts a number key (1-9) to an accent index. | |
| 274 | + | // Returns the index and true if the key is a valid number key. | |
| 275 | + | func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) { | |
| 276 | + | keyMap := map[uint]int{ | |
| 277 | + | uint(gdk.KEY_1): 0, | |
| 278 | + | uint(gdk.KEY_KP_1): 0, | |
| 279 | + | uint(gdk.KEY_2): 1, | |
| 280 | + | uint(gdk.KEY_KP_2): 1, | |
| 281 | + | uint(gdk.KEY_3): 2, | |
| 282 | + | uint(gdk.KEY_KP_3): 2, | |
| 283 | + | uint(gdk.KEY_4): 3, | |
| 284 | + | uint(gdk.KEY_KP_4): 3, | |
| 285 | + | uint(gdk.KEY_5): 4, | |
| 286 | + | uint(gdk.KEY_KP_5): 4, | |
| 287 | + | uint(gdk.KEY_6): 5, | |
| 288 | + | uint(gdk.KEY_KP_6): 5, | |
| 289 | + | uint(gdk.KEY_7): accentIndex6, | |
| 290 | + | uint(gdk.KEY_KP_7): accentIndex6, | |
| 291 | + | uint(gdk.KEY_8): accentIndex7, | |
| 292 | + | uint(gdk.KEY_KP_8): accentIndex7, | |
| 293 | + | uint(gdk.KEY_9): accentIndex8, | |
| 294 | + | uint(gdk.KEY_KP_9): accentIndex8, | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | if idx, ok := keyMap[keyval]; ok { | |
| 298 | + | return idx, true | |
| 299 | + | } | |
| 300 | + | return 0, false | |
| 301 | + | } | |
| @@ -0,0 +1,301 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Accent picker layout constants. | |||
| 13 | + | const ( | |||
| 14 | + | accentPickerSpacing = 8 // Spacing between accent items in pixels. | |||
| 15 | + | maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9). | |||
| 16 | + | accentIndex6 = 6 // Index for accent at position 7 (0-indexed). | |||
| 17 | + | accentIndex7 = 7 // Index for accent at position 8 (0-indexed). | |||
| 18 | + | accentIndex8 = 8 // Index for accent at position 9 (0-indexed). | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // AccentPicker displays an overlay with accent character options. | |||
| 22 | + | // The user can select an accent using: | |||
| 23 | + | // - Left/Right arrow keys to navigate | |||
| 24 | + | // - Number keys 1-9 to select directly | |||
| 25 | + | // - Enter to confirm selection | |||
| 26 | + | // - Escape to cancel | |||
| 27 | + | type AccentPicker struct { | |||
| 28 | + | factory layout.WidgetFactory | |||
| 29 | + | ||||
| 30 | + | // Widgets | |||
| 31 | + | container layout.BoxWidget // Outer container for positioning | |||
| 32 | + | accentLabels []layout.LabelWidget // Individual accent character labels | |||
| 33 | + | ||||
| 34 | + | // State | |||
| 35 | + | accents []rune | |||
| 36 | + | selectedIndex int | |||
| 37 | + | selectedCb func(rune) | |||
| 38 | + | cancelCb func() | |||
| 39 | + | visible bool | |||
| 40 | + | keyController *gtk.EventControllerKey | |||
| 41 | + | retainedClicked []interface{} // Keep click callbacks alive | |||
| 42 | + | ||||
| 43 | + | mu sync.Mutex | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Compile-time interface check. | |||
| 47 | + | var _ port.AccentPickerUI = (*AccentPicker)(nil) | |||
| 48 | + | ||||
| 49 | + | // NewAccentPicker creates a new accent picker component. | |||
| 50 | + | func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker { | |||
| 51 | + | // Create container box | |||
| 52 | + | container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing) | |||
| 53 | + | container.AddCssClass("accent-picker") | |||
| 54 | + | ||||
| 55 | + | // Position at bottom-center | |||
| 56 | + | container.SetHalign(gtk.AlignCenterValue) | |||
| 57 | + | container.SetValign(gtk.AlignEndValue) | |||
| 58 | + | ||||
| 59 | + | // Don't expand | |||
| 60 | + | container.SetHexpand(false) | |||
| 61 | + | container.SetVexpand(false) | |||
| 62 | + | ||||
| 63 | + | // Make focusable so it can receive keyboard events | |||
| 64 | + | container.SetFocusable(true) | |||
| 65 | + | container.SetCanFocus(true) | |||
| 66 | + | ||||
| 67 | + | // Hidden by default | |||
| 68 | + | container.SetVisible(false) | |||
| 69 | + | ||||
| 70 | + | ap := &AccentPicker{ | |||
| 71 | + | factory: factory, | |||
| 72 | + | container: container, | |||
| 73 | + | selectedIndex: 0, | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Set up key event handling | |||
| 77 | + | ap.setupKeyboardHandling() | |||
| 78 | + | ||||
| 79 | + | return ap | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Widget returns the accent picker's container widget. | |||
| 83 | + | func (ap *AccentPicker) Widget() layout.Widget { | |||
| 84 | + | return ap.container | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Show displays the accent picker with the given accent options. | |||
| 88 | + | func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) { | |||
| 89 | + | ap.mu.Lock() | |||
| 90 | + | defer ap.mu.Unlock() | |||
| 91 | + | ||||
| 92 | + | ap.accents = accents | |||
| 93 | + | ap.selectedCb = selectedCb | |||
| 94 | + | ap.cancelCb = cancelCb | |||
| 95 | + | ap.selectedIndex = 0 | |||
| 96 | + | ap.visible = true | |||
| 97 | + | ||||
| 98 | + | // Clear existing labels | |||
| 99 | + | ap.clearLabels() | |||
| 100 | + | ||||
| 101 | + | // Create new labels for each accent | |||
| 102 | + | ap.accentLabels = make([]layout.LabelWidget, len(accents)) | |||
| 103 | + | ap.retainedClicked = make([]interface{}, len(accents)) | |||
| 104 | + | ||||
| 105 | + | for i, accent := range accents { | |||
| 106 | + | label := ap.factory.NewLabel(string(accent)) | |||
| 107 | + | label.AddCssClass("accent-picker-item") | |||
| 108 | + | ||||
| 109 | + | // Add number hint for first maxNumberedAccentItems items | |||
| 110 | + | if i < maxNumberedAccentItems { | |||
| 111 | + | label.AddCssClass("accent-picker-numbered") | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Set up click handling | |||
| 115 | + | clickCtrl := gtk.NewGestureClick() | |||
| 116 | + | idx := i // Capture for closure | |||
| 117 | + | clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) { | |||
| 118 | + | ap.selectAccent(idx) | |||
| 119 | + | } | |||
| 120 | + | clickCtrl.ConnectPressed(&clickCb) | |||
| 121 | + | ap.retainedClicked[i] = clickCb | |||
| 122 | + | label.AddController(&clickCtrl.EventController) | |||
| 123 | + | ||||
| 124 | + | ap.accentLabels[i] = label | |||
| 125 | + | ap.container.Append(label) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Highlight first item | |||
| 129 | + | ap.updateSelection() | |||
| 130 | + | ||||
| 131 | + | ap.container.SetVisible(true) | |||
| 132 | + | ap.container.GrabFocus() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Hide hides the accent picker. | |||
| 136 | + | func (ap *AccentPicker) Hide() { | |||
| 137 | + | ap.mu.Lock() | |||
| 138 | + | defer ap.mu.Unlock() | |||
| 139 | + | ||||
| 140 | + | ap.visible = false | |||
| 141 | + | ap.container.SetVisible(false) | |||
| 142 | + | ap.clearLabels() | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // IsVisible returns true if the accent picker is currently visible. | |||
| 146 | + | func (ap *AccentPicker) IsVisible() bool { | |||
| 147 | + | ap.mu.Lock() | |||
| 148 | + | defer ap.mu.Unlock() | |||
| 149 | + | return ap.visible | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // clearLabels removes all accent labels from the container. | |||
| 153 | + | func (ap *AccentPicker) clearLabels() { | |||
| 154 | + | for _, label := range ap.accentLabels { | |||
| 155 | + | label.Unparent() | |||
| 156 | + | } | |||
| 157 | + | ap.accentLabels = nil | |||
| 158 | + | ap.retainedClicked = nil | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // updateSelection updates the visual selection highlight. | |||
| 162 | + | func (ap *AccentPicker) updateSelection() { | |||
| 163 | + | for i, label := range ap.accentLabels { | |||
| 164 | + | if i == ap.selectedIndex { | |||
| 165 | + | label.AddCssClass("accent-picker-selected") | |||
| 166 | + | } else { | |||
| 167 | + | label.RemoveCssClass("accent-picker-selected") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // selectAccent selects the accent at the given index. | |||
| 173 | + | func (ap *AccentPicker) selectAccent(index int) { | |||
| 174 | + | ap.mu.Lock() | |||
| 175 | + | if index < 0 || index >= len(ap.accents) { | |||
| 176 | + | ap.mu.Unlock() | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | accent := ap.accents[index] | |||
| 180 | + | cb := ap.selectedCb | |||
| 181 | + | ap.mu.Unlock() | |||
| 182 | + | ||||
| 183 | + | if cb != nil { | |||
| 184 | + | cb(accent) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // cancel cancels the accent picker. | |||
| 189 | + | func (ap *AccentPicker) cancel() { | |||
| 190 | + | ap.mu.Lock() | |||
| 191 | + | cb := ap.cancelCb | |||
| 192 | + | ap.mu.Unlock() | |||
| 193 | + | ||||
| 194 | + | if cb != nil { | |||
| 195 | + | cb() | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // setupKeyboardHandling sets up key event handling for navigation. | |||
| 200 | + | func (ap *AccentPicker) setupKeyboardHandling() { | |||
| 201 | + | keyCtrl := gtk.NewEventControllerKey() | |||
| 202 | + | ||||
| 203 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool { | |||
| 204 | + | return ap.handleKeyPress(keyval) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | keyCtrl.ConnectKeyPressed(&keyPressedCb) | |||
| 208 | + | ap.keyController = keyCtrl | |||
| 209 | + | ap.container.AddController(&keyCtrl.EventController) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // handleKeyPress processes a key press event. | |||
| 213 | + | // Returns true if the key was handled. | |||
| 214 | + | func (ap *AccentPicker) handleKeyPress(keyval uint) bool { | |||
| 215 | + | ap.mu.Lock() | |||
| 216 | + | if !ap.visible { | |||
| 217 | + | ap.mu.Unlock() | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Handle navigation keys | |||
| 222 | + | if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn { | |||
| 223 | + | return handled | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Handle number keys for direct selection | |||
| 227 | + | if idx, ok := ap.numberKeyToIndex(keyval); ok { | |||
| 228 | + | ap.mu.Unlock() | |||
| 229 | + | ap.selectAccent(idx) | |||
| 230 | + | return true | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | ap.mu.Unlock() | |||
| 234 | + | return false | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // handleNavigationKey handles arrow keys, Enter, and Escape. | |||
| 238 | + | // Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled. | |||
| 239 | + | func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) { | |||
| 240 | + | switch keyval { | |||
| 241 | + | case uint(gdk.KEY_Left): | |||
| 242 | + | if ap.selectedIndex > 0 { | |||
| 243 | + | ap.selectedIndex-- | |||
| 244 | + | ap.updateSelection() | |||
| 245 | + | } | |||
| 246 | + | ap.mu.Unlock() | |||
| 247 | + | return true, true | |||
| 248 | + | ||||
| 249 | + | case uint(gdk.KEY_Right): | |||
| 250 | + | if ap.selectedIndex < len(ap.accents)-1 { | |||
| 251 | + | ap.selectedIndex++ | |||
| 252 | + | ap.updateSelection() | |||
| 253 | + | } | |||
| 254 | + | ap.mu.Unlock() | |||
| 255 | + | return true, true | |||
| 256 | + | ||||
| 257 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 258 | + | idx := ap.selectedIndex | |||
| 259 | + | ap.mu.Unlock() | |||
| 260 | + | ap.selectAccent(idx) | |||
| 261 | + | return true, true | |||
| 262 | + | ||||
| 263 | + | case uint(gdk.KEY_Escape): | |||
| 264 | + | ap.mu.Unlock() | |||
| 265 | + | ap.cancel() | |||
| 266 | + | return true, true | |||
| 267 | + | ||||
| 268 | + | default: | |||
| 269 | + | return false, false | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // numberKeyToIndex converts a number key (1-9) to an accent index. | |||
| 274 | + | // Returns the index and true if the key is a valid number key. | |||
| 275 | + | func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) { | |||
| 276 | + | keyMap := map[uint]int{ | |||
| 277 | + | uint(gdk.KEY_1): 0, | |||
| 278 | + | uint(gdk.KEY_KP_1): 0, | |||
| 279 | + | uint(gdk.KEY_2): 1, | |||
| 280 | + | uint(gdk.KEY_KP_2): 1, | |||
| 281 | + | uint(gdk.KEY_3): 2, | |||
| 282 | + | uint(gdk.KEY_KP_3): 2, | |||
| 283 | + | uint(gdk.KEY_4): 3, | |||
| 284 | + | uint(gdk.KEY_KP_4): 3, | |||
| 285 | + | uint(gdk.KEY_5): 4, | |||
| 286 | + | uint(gdk.KEY_KP_5): 4, | |||
| 287 | + | uint(gdk.KEY_6): 5, | |||
| 288 | + | uint(gdk.KEY_KP_6): 5, | |||
| 289 | + | uint(gdk.KEY_7): accentIndex6, | |||
| 290 | + | uint(gdk.KEY_KP_7): accentIndex6, | |||
| 291 | + | uint(gdk.KEY_8): accentIndex7, | |||
| 292 | + | uint(gdk.KEY_KP_8): accentIndex7, | |||
| 293 | + | uint(gdk.KEY_9): accentIndex8, | |||
| 294 | + | uint(gdk.KEY_KP_9): accentIndex8, | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | if idx, ok := keyMap[keyval]; ok { | |||
| 298 | + | return idx, true | |||
| 299 | + | } | |||
| 300 | + | return 0, false | |||
| 301 | + | } | |||
A internal/ui/theme/accent_picker_css.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | // generateAccentPickerCSS creates accent picker overlay styles. | |
| 4 | + | // The picker displays a row of accented characters that the user can select. | |
| 5 | + | func generateAccentPickerCSS(p Palette) string { | |
| 6 | + | _ = p | |
| 7 | + | return `/* ===== Accent Picker Styling ===== */ | |
| 8 | + | ||
| 9 | + | /* Main container - horizontal row of accent characters */ | |
| 10 | + | .accent-picker { | |
| 11 | + | background-color: alpha(var(--surface-variant), 0.95); | |
| 12 | + | border: 0.0625em solid var(--border); | |
| 13 | + | border-radius: 0.25em; | |
| 14 | + | padding: 0.375em 0.5em; | |
| 15 | + | margin-bottom: 1em; | |
| 16 | + | box-shadow: 0 2px 8px alpha(black, 0.2); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /* Individual accent character label */ | |
| 20 | + | .accent-picker-item { | |
| 21 | + | font-size: 1.5em; | |
| 22 | + | font-weight: 500; | |
| 23 | + | color: var(--text); | |
| 24 | + | padding: 0.25em 0.5em; | |
| 25 | + | border-radius: 0.125em; | |
| 26 | + | min-width: 1.5em; | |
| 27 | + | transition: background-color 100ms ease-in-out; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | .accent-picker-item:hover { | |
| 31 | + | background-color: alpha(var(--accent), 0.15); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /* Selected accent character */ | |
| 35 | + | .accent-picker-selected { | |
| 36 | + | background-color: alpha(var(--accent), 0.25); | |
| 37 | + | color: var(--accent); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /* Numbered items (1-9) could show subtle number hints */ | |
| 41 | + | .accent-picker-numbered { | |
| 42 | + | /* Could add ::after pseudo-element for number hint if needed */ | |
| 43 | + | } | |
| 44 | + | ` | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generateAccentPickerCSS creates accent picker overlay styles. | |||
| 4 | + | // The picker displays a row of accented characters that the user can select. | |||
| 5 | + | func generateAccentPickerCSS(p Palette) string { | |||
| 6 | + | _ = p | |||
| 7 | + | return `/* ===== Accent Picker Styling ===== */ | |||
| 8 | + | ||||
| 9 | + | /* Main container - horizontal row of accent characters */ | |||
| 10 | + | .accent-picker { | |||
| 11 | + | background-color: alpha(var(--surface-variant), 0.95); | |||
| 12 | + | border: 0.0625em solid var(--border); | |||
| 13 | + | border-radius: 0.25em; | |||
| 14 | + | padding: 0.375em 0.5em; | |||
| 15 | + | margin-bottom: 1em; | |||
| 16 | + | box-shadow: 0 2px 8px alpha(black, 0.2); | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | /* Individual accent character label */ | |||
| 20 | + | .accent-picker-item { | |||
| 21 | + | font-size: 1.5em; | |||
| 22 | + | font-weight: 500; | |||
| 23 | + | color: var(--text); | |||
| 24 | + | padding: 0.25em 0.5em; | |||
| 25 | + | border-radius: 0.125em; | |||
| 26 | + | min-width: 1.5em; | |||
| 27 | + | transition: background-color 100ms ease-in-out; | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | .accent-picker-item:hover { | |||
| 31 | + | background-color: alpha(var(--accent), 0.15); | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | /* Selected accent character */ | |||
| 35 | + | .accent-picker-selected { | |||
| 36 | + | background-color: alpha(var(--accent), 0.25); | |||
| 37 | + | color: var(--accent); | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | /* Numbered items (1-9) could show subtle number hints */ | |||
| 41 | + | .accent-picker-numbered { | |||
| 42 | + | /* Could add ::after pseudo-element for number hint if needed */ | |||
| 43 | + | } | |||
| 44 | + | ` | |||
| 45 | + | } | |||
M internal/ui/theme/css.go +4 -0
| @@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||
| 126 | 126 | ||
| 127 | 127 | // Tab picker styling | |
| 128 | 128 | sb.WriteString(generateTabPickerCSS(p)) | |
| 129 | + | sb.WriteString("\n") | |
| 130 | + | ||
| 131 | + | // Accent picker styling | |
| 132 | + | sb.WriteString(generateAccentPickerCSS(p)) | |
| 129 | 133 | ||
| 130 | 134 | return sb.String() | |
| 131 | 135 | } | |
| @@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||||
| 126 | 126 | ||||
| 127 | // Tab picker styling | 127 | // Tab picker styling | ||
| 128 | sb.WriteString(generateTabPickerCSS(p)) | 128 | sb.WriteString(generateTabPickerCSS(p)) | ||
| 129 | + | sb.WriteString("\n") | |||
| 130 | + | ||||
| 131 | + | // Accent picker styling | |||
| 132 | + | sb.WriteString(generateAccentPickerCSS(p)) | |||
| 129 | 133 | ||||
| 130 | return sb.String() | 134 | return sb.String() | ||
| 131 | } | 135 | } | ||