fix: address code review findings (cancel focus, log privacy, timer races, pool cleanup, shortcut reload)
- commit
- 4f21c98d952d09ea58f2bb970af596983f6746c4
- author
- brice · · 2026-03-13 18:47 UTC
- parent
- ea0fc29
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +115 -7
M internal/application/usecase/insert_accent.go +13 -6
| @@ -137,16 +137,23 @@ func (uc *InsertAccentUseCase) Cancel(ctx context.Context) { | |||
| 137 | 137 | log := logging.FromContext(ctx) | |
| 138 | 138 | ||
| 139 | 139 | uc.mu.Lock() | |
| 140 | - | defer uc.mu.Unlock() | |
| 140 | + | target := uc.targetInput | |
| 141 | + | wasVisible := uc.pickerVisible | |
| 142 | + | uc.cancelTimerLocked() | |
| 143 | + | if wasVisible { | |
| 144 | + | uc.accentPicker.Hide() | |
| 145 | + | uc.pickerVisible = false | |
| 146 | + | } | |
| 147 | + | uc.mu.Unlock() | |
| 141 | 148 | ||
| 142 | - | if uc.timer != nil || uc.pickerVisible { | |
| 149 | + | if wasVisible { | |
| 143 | 150 | log.Debug().Msg("canceling accent picker") | |
| 144 | 151 | } | |
| 145 | 152 | ||
| 146 | - | uc.cancelTimerLocked() | |
| 147 | - | if uc.pickerVisible { | |
| 148 | - | uc.accentPicker.Hide() | |
| 149 | - | uc.pickerVisible = false | |
| 153 | + | if wasVisible { | |
| 154 | + | if focusable, ok := target.(port.Focusable); ok { | |
| 155 | + | focusable.Focus(ctx) | |
| 156 | + | } | |
| 150 | 157 | } | |
| 151 | 158 | } | |
| 152 | 159 | ||
| @@ -137,16 +137,23 @@ func (uc *InsertAccentUseCase) Cancel(ctx context.Context) { | |||||
| 137 | log := logging.FromContext(ctx) | 137 | log := logging.FromContext(ctx) | ||
| 138 | 138 | ||||
| 139 | uc.mu.Lock() | 139 | uc.mu.Lock() | ||
| 140 | - | defer uc.mu.Unlock() | 140 | + | target := uc.targetInput |
| 141 | + | wasVisible := uc.pickerVisible | |||
| 142 | + | uc.cancelTimerLocked() | |||
| 143 | + | if wasVisible { | |||
| 144 | + | uc.accentPicker.Hide() | |||
| 145 | + | uc.pickerVisible = false | |||
| 146 | + | } | |||
| 147 | + | uc.mu.Unlock() | |||
| 141 | 148 | ||||
| 142 | - | if uc.timer != nil || uc.pickerVisible { | 149 | + | if wasVisible { |
| 143 | log.Debug().Msg("canceling accent picker") | 150 | log.Debug().Msg("canceling accent picker") | ||
| 144 | } | 151 | } | ||
| 145 | 152 | ||||
| 146 | - | uc.cancelTimerLocked() | 153 | + | if wasVisible { |
| 147 | - | if uc.pickerVisible { | 154 | + | if focusable, ok := target.(port.Focusable); ok { |
| 148 | - | uc.accentPicker.Hide() | 155 | + | focusable.Focus(ctx) |
| 149 | - | uc.pickerVisible = false | 156 | + | } |
| 150 | } | 157 | } | ||
| 151 | } | 158 | } | ||
| 152 | 159 | ||||
M internal/infrastructure/textinput/webview_target.go +1 -1
| @@ -99,7 +99,7 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||
| 99 | 99 | t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0) | |
| 100 | 100 | ||
| 101 | 101 | log.Debug(). | |
| 102 | - | Str("text", text). | |
| 102 | + | Int("len", len(text)). | |
| 103 | 103 | Msg("inserted text into WebView via JS") | |
| 104 | 104 | ||
| 105 | 105 | return nil | |
| @@ -99,7 +99,7 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||||
| 99 | t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0) | 99 | t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0) | ||
| 100 | 100 | ||||
| 101 | log.Debug(). | 101 | log.Debug(). | ||
| 102 | - | Str("text", text). | 102 | + | Int("len", len(text)). |
| 103 | Msg("inserted text into WebView via JS") | 103 | Msg("inserted text into WebView via JS") | ||
| 104 | 104 | ||||
| 105 | return nil | 105 | return nil | ||
M internal/infrastructure/webkit/webview.go +1 -0
| @@ -1738,6 +1738,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||
| 1738 | 1738 | wv.OnAudioStateChanged = nil | |
| 1739 | 1739 | wv.OnLinkHover = nil | |
| 1740 | 1740 | wv.OnWebProcessTerminated = nil | |
| 1741 | + | wv.OnPermissionRequest = nil | |
| 1741 | 1742 | ||
| 1742 | 1743 | wv.mu.Lock() | |
| 1743 | 1744 | wv.uri = "" | |
| @@ -1738,6 +1738,7 @@ func (wv *WebView) ResetForPoolReuse() { | |||||
| 1738 | wv.OnAudioStateChanged = nil | 1738 | wv.OnAudioStateChanged = nil | ||
| 1739 | wv.OnLinkHover = nil | 1739 | wv.OnLinkHover = nil | ||
| 1740 | wv.OnWebProcessTerminated = nil | 1740 | wv.OnWebProcessTerminated = nil | ||
| 1741 | + | wv.OnPermissionRequest = nil | |||
| 1741 | 1742 | ||||
| 1742 | wv.mu.Lock() | 1743 | wv.mu.Lock() | ||
| 1743 | wv.uri = "" | 1744 | wv.uri = "" | ||
M internal/ui/app.go +4 -0
| @@ -3053,6 +3053,10 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 3053 | 3053 | if a.keyboardHandler != nil { | |
| 3054 | 3054 | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | |
| 3055 | 3055 | } | |
| 3056 | + | // Reload global shortcuts (removes stale, re-registers from config) | |
| 3057 | + | if a.globalShortcutHandler != nil { | |
| 3058 | + | a.globalShortcutHandler.ReloadShortcuts(ctx, cfgCopy) | |
| 3059 | + | } | |
| 3056 | 3060 | return false | |
| 3057 | 3061 | }) | |
| 3058 | 3062 | glib.IdleAdd(&cb, 0) | |
| @@ -3053,6 +3053,10 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 3053 | if a.keyboardHandler != nil { | 3053 | if a.keyboardHandler != nil { | ||
| 3054 | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | 3054 | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | ||
| 3055 | } | 3055 | } | ||
| 3056 | + | // Reload global shortcuts (removes stale, re-registers from config) | |||
| 3057 | + | if a.globalShortcutHandler != nil { | |||
| 3058 | + | a.globalShortcutHandler.ReloadShortcuts(ctx, cfgCopy) | |||
| 3059 | + | } | |||
| 3056 | return false | 3060 | return false | ||
| 3057 | }) | 3061 | }) | ||
| 3058 | glib.IdleAdd(&cb, 0) | 3062 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/input/global_shortcuts.go +92 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | // to intercept shortcuts before they reach the WebView. | |
| 19 | 19 | type GlobalShortcutHandler struct { | |
| 20 | 20 | controller *gtk.ShortcutController | |
| 21 | + | window *gtk.ApplicationWindow | |
| 21 | 22 | kbHandler *KeyboardHandler | |
| 22 | 23 | onAction ActionHandler | |
| 23 | 24 | ctx context.Context | |
| @@ -41,6 +42,7 @@ func NewGlobalShortcutHandler( | |||
| 41 | 42 | ||
| 42 | 43 | h := &GlobalShortcutHandler{ | |
| 43 | 44 | controller: gtk.NewShortcutController(), | |
| 45 | + | window: window, | |
| 44 | 46 | kbHandler: kbHandler, | |
| 45 | 47 | onAction: onAction, | |
| 46 | 48 | ctx: ctx, | |
| @@ -244,6 +246,96 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 244 | 246 | return true | |
| 245 | 247 | } | |
| 246 | 248 | ||
| 249 | + | // ReloadShortcuts rebuilds global shortcuts from a new config. | |
| 250 | + | // It replaces the GTK shortcut controller to ensure stale shortcuts | |
| 251 | + | // are removed. | |
| 252 | + | func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) { | |
| 253 | + | log := logging.FromContext(ctx) | |
| 254 | + | ||
| 255 | + | if h.window == nil || h.controller == nil { | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Remove old controller from window | |
| 260 | + | h.window.RemoveController(&h.controller.EventController) | |
| 261 | + | ||
| 262 | + | // Create fresh controller | |
| 263 | + | h.controller = gtk.NewShortcutController() | |
| 264 | + | if h.controller == nil { | |
| 265 | + | log.Error().Msg("failed to create shortcut controller during reload") | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | |
| 269 | + | h.callbacks = make([]gtk.ShortcutFunc, 0) | |
| 270 | + | h.registered = make(map[KeyBinding]Action) | |
| 271 | + | ||
| 272 | + | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | |
| 273 | + | tabActions := []Action{ | |
| 274 | + | ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3, | |
| 275 | + | ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6, | |
| 276 | + | ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9, | |
| 277 | + | } | |
| 278 | + | for i, action := range tabActions { | |
| 279 | + | h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action) | |
| 280 | + | } | |
| 281 | + | h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10) | |
| 282 | + | h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab) | |
| 283 | + | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |
| 284 | + | ||
| 285 | + | // Re-register config-driven shortcuts | |
| 286 | + | if cfg != nil { | |
| 287 | + | actionMap := map[string]Action{ | |
| 288 | + | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 289 | + | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 290 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 291 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 292 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 293 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |
| 297 | + | action, ok := actionMap[actionName] | |
| 298 | + | if !ok { | |
| 299 | + | continue | |
| 300 | + | } | |
| 301 | + | for _, keyStr := range actionBinding.Keys { | |
| 302 | + | binding, ok := ParseKeyString(keyStr) | |
| 303 | + | if !ok { | |
| 304 | + | continue | |
| 305 | + | } | |
| 306 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | occupied := make(map[KeyBinding]Action, len(h.registered)) | |
| 311 | + | for binding, action := range h.registered { | |
| 312 | + | occupied[binding] = action | |
| 313 | + | } | |
| 314 | + | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 315 | + | if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |
| 316 | + | if url, ok := ParseFloatingProfileAction(shortcut.Action); ok { | |
| 317 | + | log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut") | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | shortcuts := NewShortcutSet(ctx, cfg) | |
| 323 | + | for binding, action := range shortcuts.Global { | |
| 324 | + | if _, exists := h.registered[binding]; exists { | |
| 325 | + | continue | |
| 326 | + | } | |
| 327 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Attach new controller to window | |
| 332 | + | h.window.AddController(&h.controller.EventController) | |
| 333 | + | ||
| 334 | + | log.Debug(). | |
| 335 | + | Int("shortcuts", len(h.callbacks)). | |
| 336 | + | Msg("global shortcuts reloaded") | |
| 337 | + | } | |
| 338 | + | ||
| 247 | 339 | // Detach removes the global shortcut handler from the window. | |
| 248 | 340 | // Note: GTK handles cleanup when the widget is destroyed, | |
| 249 | 341 | // but we clear our references here. | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | // to intercept shortcuts before they reach the WebView. | 18 | // to intercept shortcuts before they reach the WebView. | ||
| 19 | type GlobalShortcutHandler struct { | 19 | type GlobalShortcutHandler struct { | ||
| 20 | controller *gtk.ShortcutController | 20 | controller *gtk.ShortcutController | ||
| 21 | + | window *gtk.ApplicationWindow | |||
| 21 | kbHandler *KeyboardHandler | 22 | kbHandler *KeyboardHandler | ||
| 22 | onAction ActionHandler | 23 | onAction ActionHandler | ||
| 23 | ctx context.Context | 24 | ctx context.Context | ||
| @@ -41,6 +42,7 @@ func NewGlobalShortcutHandler( | |||||
| 41 | 42 | ||||
| 42 | h := &GlobalShortcutHandler{ | 43 | h := &GlobalShortcutHandler{ | ||
| 43 | controller: gtk.NewShortcutController(), | 44 | controller: gtk.NewShortcutController(), | ||
| 45 | + | window: window, | |||
| 44 | kbHandler: kbHandler, | 46 | kbHandler: kbHandler, | ||
| 45 | onAction: onAction, | 47 | onAction: onAction, | ||
| 46 | ctx: ctx, | 48 | ctx: ctx, | ||
| @@ -244,6 +246,96 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 244 | return true | 246 | return true | ||
| 245 | } | 247 | } | ||
| 246 | 248 | ||||
| 249 | + | // ReloadShortcuts rebuilds global shortcuts from a new config. | |||
| 250 | + | // It replaces the GTK shortcut controller to ensure stale shortcuts | |||
| 251 | + | // are removed. | |||
| 252 | + | func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) { | |||
| 253 | + | log := logging.FromContext(ctx) | |||
| 254 | + | ||||
| 255 | + | if h.window == nil || h.controller == nil { | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Remove old controller from window | |||
| 260 | + | h.window.RemoveController(&h.controller.EventController) | |||
| 261 | + | ||||
| 262 | + | // Create fresh controller | |||
| 263 | + | h.controller = gtk.NewShortcutController() | |||
| 264 | + | if h.controller == nil { | |||
| 265 | + | log.Error().Msg("failed to create shortcut controller during reload") | |||
| 266 | + | return | |||
| 267 | + | } | |||
| 268 | + | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | |||
| 269 | + | h.callbacks = make([]gtk.ShortcutFunc, 0) | |||
| 270 | + | h.registered = make(map[KeyBinding]Action) | |||
| 271 | + | ||||
| 272 | + | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | |||
| 273 | + | tabActions := []Action{ | |||
| 274 | + | ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3, | |||
| 275 | + | ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6, | |||
| 276 | + | ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9, | |||
| 277 | + | } | |||
| 278 | + | for i, action := range tabActions { | |||
| 279 | + | h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action) | |||
| 280 | + | } | |||
| 281 | + | h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10) | |||
| 282 | + | h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab) | |||
| 283 | + | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |||
| 284 | + | ||||
| 285 | + | // Re-register config-driven shortcuts | |||
| 286 | + | if cfg != nil { | |||
| 287 | + | actionMap := map[string]Action{ | |||
| 288 | + | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 289 | + | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 290 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 291 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 292 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 293 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |||
| 297 | + | action, ok := actionMap[actionName] | |||
| 298 | + | if !ok { | |||
| 299 | + | continue | |||
| 300 | + | } | |||
| 301 | + | for _, keyStr := range actionBinding.Keys { | |||
| 302 | + | binding, ok := ParseKeyString(keyStr) | |||
| 303 | + | if !ok { | |||
| 304 | + | continue | |||
| 305 | + | } | |||
| 306 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | occupied := make(map[KeyBinding]Action, len(h.registered)) | |||
| 311 | + | for binding, action := range h.registered { | |||
| 312 | + | occupied[binding] = action | |||
| 313 | + | } | |||
| 314 | + | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |||
| 315 | + | if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |||
| 316 | + | if url, ok := ParseFloatingProfileAction(shortcut.Action); ok { | |||
| 317 | + | log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut") | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | shortcuts := NewShortcutSet(ctx, cfg) | |||
| 323 | + | for binding, action := range shortcuts.Global { | |||
| 324 | + | if _, exists := h.registered[binding]; exists { | |||
| 325 | + | continue | |||
| 326 | + | } | |||
| 327 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // Attach new controller to window | |||
| 332 | + | h.window.AddController(&h.controller.EventController) | |||
| 333 | + | ||||
| 334 | + | log.Debug(). | |||
| 335 | + | Int("shortcuts", len(h.callbacks)). | |||
| 336 | + | Msg("global shortcuts reloaded") | |||
| 337 | + | } | |||
| 338 | + | ||||
| 247 | // Detach removes the global shortcut handler from the window. | 339 | // Detach removes the global shortcut handler from the window. | ||
| 248 | // Note: GTK handles cleanup when the widget is destroyed, | 340 | // Note: GTK handles cleanup when the widget is destroyed, | ||
| 249 | // but we clear our references here. | 341 | // but we clear our references here. | ||
M internal/ui/input/modal.go +4 -0
| @@ -94,6 +94,9 @@ func NewModalState(ctx context.Context) *ModalState { | |||
| 94 | 94 | func (m *ModalState) SetMainThreadScheduler(fn func(func())) { | |
| 95 | 95 | m.mu.Lock() | |
| 96 | 96 | defer m.mu.Unlock() | |
| 97 | + | if fn == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 97 | 100 | m.scheduleOnMainThread = fn | |
| 98 | 101 | } | |
| 99 | 102 | ||
| @@ -208,6 +211,7 @@ func (m *ModalState) cancelTimerLocked() { | |||
| 208 | 211 | if m.timer != nil { | |
| 209 | 212 | m.timer.Stop() | |
| 210 | 213 | m.timer = nil | |
| 214 | + | m.timerGen++ | |
| 211 | 215 | } | |
| 212 | 216 | } | |
| 213 | 217 | ||
| @@ -94,6 +94,9 @@ func NewModalState(ctx context.Context) *ModalState { | |||||
| 94 | func (m *ModalState) SetMainThreadScheduler(fn func(func())) { | 94 | func (m *ModalState) SetMainThreadScheduler(fn func(func())) { | ||
| 95 | m.mu.Lock() | 95 | m.mu.Lock() | ||
| 96 | defer m.mu.Unlock() | 96 | defer m.mu.Unlock() | ||
| 97 | + | if fn == nil { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 97 | m.scheduleOnMainThread = fn | 100 | m.scheduleOnMainThread = fn | ||
| 98 | } | 101 | } | ||
| 99 | 102 | ||||
| @@ -208,6 +211,7 @@ func (m *ModalState) cancelTimerLocked() { | |||||
| 208 | if m.timer != nil { | 211 | if m.timer != nil { | ||
| 209 | m.timer.Stop() | 212 | m.timer.Stop() | ||
| 210 | m.timer = nil | 213 | m.timer = nil | ||
| 214 | + | m.timerGen++ | |||
| 211 | } | 215 | } | ||
| 212 | } | 216 | } | ||
| 213 | 217 | ||||