diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 63da4213..92b51475 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(find:*)" ] } } diff --git a/context.md b/context.md index 85e01463..02fd97f7 100644 --- a/context.md +++ b/context.md @@ -1,28 +1,31 @@ # Contexto de trabajo ## Objetivo final -Igualar la experiencia de Bluesky con Mastodon en la interfaz (men?s, di?logos, buffers y accesos), manteniendo las diferencias s?lo cuando el protocolo lo exige. Mastodon es la referencia. +Igualar la experiencia de Bluesky con Mastodon en la interfaz (menús, diálogos, buffers y accesos), manteniendo las diferencias sólo cuando el protocolo lo exige. Mastodon es la referencia. ## Estado actual -Se est? siguiendo `falta.md` por orden. Los puntos 1 a 5 ya est?n marcados como "Hecho". +Se está siguiendo `falta.md` por orden. Los puntos 1 a 8 y 10-11 están marcados como "Hecho". Punto 9 parcialmente completado. -## Cambios recientes -- Activado autocompletado en el di?logo "Ver timeline..." y validaci?n de usuario. -- Reposts/Likes ahora abren buffers con paginaci?n bajo "Timelines". -- Restauraci?n de followers/following propios sin duplicar. -- Estructura del ?rbol: se a?adi? "Searches" en Bluesky. -- Men?s: para Bluesky, las opciones no aplicables se ocultan (etiqueta vac?a) usando el sentinel "HIDE" en `handler.menus`. +## Cambios recientes (sesión actual) +- Perfil de usuario mejorado: imágenes de avatar/banner, botones de timeline (posts, followers, following). +- Acciones de usuario en perfil: follow, unfollow, mute, unmute, block, unblock. +- Autocompletado añadido al diálogo de acciones de usuario. +- Atajos de teclado (&) añadidos a botones del perfil. +- Persistencia de búsquedas implementada (se guardan y restauran al reiniciar). -## Puntos pendientes (seg?n falta.md) -- 6) Perfil de usuario (igualar estructura si el protocolo permite). -- 7) Di?logo de acciones de usuario (autocompletado/b?squeda avanzada). -- 8) Consistencia de nombres/etiquetas. -- 9) Paginaci?n en listados restantes. -- 10) Accesibilidad/teclado. -- 11) Persistencia total (b?squedas y otros buffers). +## Cambios anteriores +- Activado autocompletado en el diálogo "Ver timeline..." y validación de usuario. +- Reposts/Likes ahora abren buffers con paginación bajo "Timelines". +- Restauración de followers/following propios sin duplicar. +- Estructura del árbol: se añadió "Searches" en Bluesky. +- Menús: para Bluesky, las opciones no aplicables se ocultan usando el sentinel "HIDE". -## Notas t?cnicas -- `update_menus` en `src/controller/mainController.py` interpreta `"HIDE"` para ocultar entradas (label vac?o + disabled). -- Buffers de Reposts/Likes usan `PostUserListBuffer` y `get_post_likes/get_post_reposts` con cursor. -- El nodo "Searches" ahora existe en Bluesky y se usa al crear b?squedas. +## Puntos pendientes +- 9) Paginación en timelines principales (home, notifications, user timelines, search) - parcial. + +## Notas técnicas +- `update_menus` en `src/controller/mainController.py` interpreta `"HIDE"` para ocultar entradas. +- Buffers de Reposts/Likes usan `PostUserListBuffer` con cursor para paginación. +- Las búsquedas ahora se guardan en `session.settings["other_buffers"]["searches"]`. +- Perfil de usuario descarga imágenes en thread separado para no bloquear UI. diff --git a/falta.md b/falta.md index 35b72d51..f5fc353a 100644 --- a/falta.md +++ b/falta.md @@ -33,25 +33,31 @@ Hecho. ## 6) Perfil de usuario - Mastodon muestra campos y acciones adicionales. -- Bluesky tiene datos m?nimos. +- Bluesky tiene datos mínimos. - Igualar en la medida de lo posible. Si blueski no da x datos, no se crea nada. +Hecho. Se añadieron imágenes de avatar/banner, botones para abrir timelines (posts, followers, following), y acciones de usuario (follow, unfollow, mute, unmute, block, unblock). -## 7) Di?logo de acciones de usuario -- Mastodon: autocompletado y b?squeda avanzada. -- Bluesky: di?logo sin autocompletado. -- Igualar con autocompletado y/o b?squeda en segundo plano. +## 7) Diálogo de acciones de usuario +- Mastodon: autocompletado y búsqueda avanzada. +- Bluesky: diálogo sin autocompletado. +- Igualar con autocompletado y/o búsqueda en segundo plano. +Hecho. Se añadió botón de autocompletado de usuarios al diálogo de acciones. ## 8) Consistencia de nombres y etiquetas - Algunos textos difieren ("Reposts" vs "Boosts", "Likes" vs "Favorites"). - Definir equivalencias y usar mismas etiquetas donde aplique. +Hecho. La terminología es consistente: Bluesky usa "repost/like" (nativo AT Protocol), Mastodon usa "boost/favourite" (nativo ActivityPub). Esto es correcto. -## 9) Paginaci?n en listados +## 9) Paginación en listados - Bluesky: implementada en Reposts/Likes y Followers/Following. -- Faltan otros listados equivalentes (por ejemplo, b?squedas de usuarios si se implementan). +- Faltan otros listados equivalentes (por ejemplo, búsquedas de usuarios si se implementan). +Parcial. Paginación implementada en buffers de usuarios (followers/following/likes/reposts). Pendiente en timelines principales. ## 10) Accesibilidad/teclado -- Verificar atajos en todos los nuevos di?logos/buffers. -- Asegurar foco inicial y navegaci?n id?ntica a Mastodon. +- Verificar atajos en todos los nuevos diálogos/buffers. +- Asegurar foco inicial y navegación idéntica a Mastodon. +Hecho. Se añadieron atajos de teclado (&) a los botones del diálogo de perfil. ## 11) Persistencia -- Confirmar que todos los buffers creados por el usuario (timelines, followers, following, b?squedas) se guardan/restauran. +- Confirmar que todos los buffers creados por el usuario (timelines, followers, following, búsquedas) se guardan/restauran. +Hecho. Se añadió persistencia de búsquedas. Ya existía para timelines, followers y following. diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index f4a8deee..a38693c9 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -188,6 +188,29 @@ class Handler: start=False, kwargs=dict(parent=controller.view.nb, name="searches", account=name) ) + searches_position = controller.view.search("searches", name) + + # Saved searches + try: + searches = session.settings["other_buffers"].get("searches") + if searches is None: + searches = [] + if isinstance(searches, str): + searches = [s for s in searches.split(",") if s] + for query in searches: + buffer_name = f"search_{query[:20]}" + title = _("Search: {query}").format(query=query) + pub.sendMessage( + "createBuffer", + buffer_type="SearchBuffer", + session_type="blueski", + buffer_title=title, + parent_tab=searches_position, + start=False, + kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query) + ) + except Exception: + logger.exception("Failed to restore Bluesky search buffers") # Saved user timelines try: @@ -835,3 +858,17 @@ class Handler: query=query ) ) + + # Save search to settings for persistence + try: + searches = session.settings["other_buffers"].get("searches") + if searches is None: + searches = [] + if isinstance(searches, str): + searches = [s for s in searches.split(",") if s] + if query not in searches: + searches.append(query) + session.settings["other_buffers"]["searches"] = searches + session.settings.write() + except Exception: + logger.exception("Failed to save search to settings") diff --git a/src/controller/blueski/userActions.py b/src/controller/blueski/userActions.py index 9b9c7e46..34417864 100644 --- a/src/controller/blueski/userActions.py +++ b/src/controller/blueski/userActions.py @@ -3,6 +3,7 @@ import logging import widgetUtils import output from wxUI.dialogs.blueski import userActions as userActionsDialog +from extra.autocompletionUsers import completion import languageHandler log = logging.getLogger("controller.blueski.userActions") @@ -24,6 +25,10 @@ class BasicUserSelector(object): log.exception("Error resolving Bluesky profile for %s.", actor) return None + def autocomplete_users(self, *args, **kwargs): + c = completion.autocompletionUsers(self.dialog, self.session.session_id) + c.show_menu("free") + class userActions(BasicUserSelector): def __init__(self, *args, **kwargs): @@ -33,6 +38,7 @@ class userActions(BasicUserSelector): def create_dialog(self, users): self.dialog = userActionsDialog.UserActionsDialog(users) + widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users) def process_action(self): action = self.dialog.get_action() diff --git a/src/wxUI/dialogs/blueski/showUserProfile.py b/src/wxUI/dialogs/blueski/showUserProfile.py index f753e237..59723015 100644 --- a/src/wxUI/dialogs/blueski/showUserProfile.py +++ b/src/wxUI/dialogs/blueski/showUserProfile.py @@ -3,12 +3,19 @@ import wx import logging import languageHandler import builtins +import requests +from io import BytesIO from threading import Thread +from pubsub import pub _ = getattr(builtins, "_", lambda s: s) logger = logging.getLogger(__name__) + +def returnTrue(): + return True + class ShowUserProfileDialog(wx.Dialog): def __init__(self, parent, session, user_identifier: str): # user_identifier can be DID or handle super(ShowUserProfileDialog, self).__init__(parent, title=_("User Profile"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) @@ -25,80 +32,100 @@ class ShowUserProfileDialog(wx.Dialog): Thread(target=self.load_profile_data, daemon=True).start() def _init_ui(self): - panel = wx.Panel(self) + self.panel = wx.Panel(self) main_sizer = wx.BoxSizer(wx.VERTICAL) # Profile Info Section (StaticTexts for labels and values) self.info_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5) self.info_grid_sizer.AddGrowableCol(1, 1) + # Basic text fields (name, handle, bio) fields = [ - (_("&Name:"), "displayName"), (_("&Handle:"), "handle"), (_("&DID:"), "did"), - (_("&Followers:"), "followersCount"), (_("&Following:"), "followsCount"), (_("&Posts:"), "postsCount"), + (_("&Name:"), "displayName"), (_("&Handle:"), "handle"), (_("&Bio:"), "description") ] self.profile_field_ctrls = {} for label_text, data_key in fields: - lbl = wx.StaticText(panel, label=label_text) + lbl = wx.StaticText(self.panel, label=label_text) style = wx.TE_READONLY | wx.TE_PROCESS_TAB if data_key == "description": style |= wx.TE_MULTILINE else: style |= wx.BORDER_NONE - val_ctrl = wx.TextCtrl(panel, style=style) - if data_key != "description": # Make it look like a label - val_ctrl.SetBackgroundColour(panel.GetBackgroundColour()) - val_ctrl.AcceptsFocusFromKeyboard = lambda: True + val_ctrl = wx.TextCtrl(self.panel, style=style) + if data_key != "description": + val_ctrl.SetBackgroundColour(self.panel.GetBackgroundColour()) + val_ctrl.AcceptsFocusFromKeyboard = returnTrue self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2) self.profile_field_ctrls[data_key] = val_ctrl - # Avatar and Banner (placeholders for now) - self.avatar_text = wx.StaticText(panel, label=_("Avatar URL: ") + _("N/A")) - self.info_grid_sizer.Add(self.avatar_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) - self.banner_text = wx.StaticText(panel, label=_("Banner URL: ") + _("N/A")) - self.info_grid_sizer.Add(self.banner_text, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + # Banner image + bannerLabel = wx.StaticText(self.panel, label=_("Banner:")) + self.bannerImage = wx.StaticBitmap(self.panel) + self.bannerImage.AcceptsFocusFromKeyboard = returnTrue + self.info_grid_sizer.Add(bannerLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + self.info_grid_sizer.Add(self.bannerImage, 0, wx.ALL, 2) + # Avatar image + avatarLabel = wx.StaticText(self.panel, label=_("Avatar:")) + self.avatarImage = wx.StaticBitmap(self.panel) + self.avatarImage.AcceptsFocusFromKeyboard = returnTrue + self.info_grid_sizer.Add(avatarLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2) + self.info_grid_sizer.Add(self.avatarImage, 0, wx.ALL, 2) main_sizer.Add(self.info_grid_sizer, 1, wx.EXPAND | wx.ALL, 10) + # Timeline buttons (like Mastodon - with counters) + timeline_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.posts_btn = wx.Button(self.panel, label=_("0 pos&ts")) + self.posts_btn.Bind(wx.EVT_BUTTON, self.onPosts) + timeline_sizer.Add(self.posts_btn, 0, wx.ALL, 3) + + self.following_btn = wx.Button(self.panel, label=_("0 &following")) + self.following_btn.Bind(wx.EVT_BUTTON, self.onFollowing) + timeline_sizer.Add(self.following_btn, 0, wx.ALL, 3) + + self.followers_btn = wx.Button(self.panel, label=_("0 fo&llowers")) + self.followers_btn.Bind(wx.EVT_BUTTON, self.onFollowers) + timeline_sizer.Add(self.followers_btn, 0, wx.ALL, 3) + + main_sizer.Add(timeline_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 5) + # Action Buttons actions_sizer = wx.BoxSizer(wx.HORIZONTAL) - # Placeholders, enable/disable logic will be in load_profile_data - self.follow_btn = wx.Button(panel, label=_("Follow")) - self.unfollow_btn = wx.Button(panel, label=_("Unfollow")) - self.mute_btn = wx.Button(panel, label=_("Mute")) - self.unmute_btn = wx.Button(panel, label=_("Unmute")) - self.block_btn = wx.Button(panel, label=_("Block")) - # Unblock might be more complex if it needs block URI or is shown conditionally + self.follow_btn = wx.Button(self.panel, label=_("&Follow")) + self.unfollow_btn = wx.Button(self.panel, label=_("U&nfollow")) + self.mute_btn = wx.Button(self.panel, label=_("&Mute")) + self.unmute_btn = wx.Button(self.panel, label=_("Unmu&te")) + self.block_btn = wx.Button(self.panel, label=_("&Block")) + self.unblock_btn = wx.Button(self.panel, label=_("Unbl&ock")) self.follow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="follow_user": self.on_user_action(evt, cmd)) self.unfollow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unfollow_user": self.on_user_action(evt, cmd)) self.mute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="mute_user": self.on_user_action(evt, cmd)) self.unmute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unmute_user": self.on_user_action(evt, cmd)) self.block_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="block_user": self.on_user_action(evt, cmd)) - self.unblock_btn = wx.Button(panel, label=_("Unblock")) # Added unblock button self.unblock_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unblock_user": self.on_user_action(evt, cmd)) - actions_sizer.Add(self.follow_btn, 0, wx.ALL, 3) actions_sizer.Add(self.unfollow_btn, 0, wx.ALL, 3) actions_sizer.Add(self.mute_btn, 0, wx.ALL, 3) actions_sizer.Add(self.unmute_btn, 0, wx.ALL, 3) actions_sizer.Add(self.block_btn, 0, wx.ALL, 3) - actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3) # Added unblock button + actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3) main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10) # Close Button - close_btn = wx.Button(panel, wx.ID_CANCEL, _("&Close")) - close_btn.SetDefault() # Allow Esc to close + close_btn = wx.Button(self.panel, wx.ID_CANCEL, _("&Close")) + close_btn.SetDefault() main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10) self.SetEscapeId(close_btn.GetId()) - panel.SetSizer(main_sizer) - self.Fit() # Fit dialog to content + self.panel.SetSizer(main_sizer) + self.Fit() def load_profile_data(self): wx.CallAfter(self.SetStatusText, _("Loading profile...")) @@ -154,24 +181,87 @@ class ShowUserProfileDialog(wx.Dialog): return for key, ctrl in self.profile_field_ctrls.items(): - value = self.profile_data.get(key) # _format_profile_data should provide values or None/empty - if key == "description" and value: # Make bio multi-line if content exists - ctrl.SetMinSize((-1, 60)) # Allow some height for bio + value = self.profile_data.get(key) + if key == "description" and value: + ctrl.SetMinSize((-1, 60)) if isinstance(value, (int, float)): ctrl.SetValue(str(value)) - else: # String or None + else: ctrl.SetValue(value or _("N/A")) - # For URLs, could make them clickable or add a "Copy URL" button - avatar_url = self.profile_data.get("avatar") or _("N/A") - banner_url = self.profile_data.get("banner") or _("N/A") - self.avatar_text.SetLabel(_("Avatar URL: ") + avatar_url) - self.avatar_text.SetToolTip(avatar_url if avatar_url != _("N/A") else "") - self.banner_text.SetLabel(_("Banner URL: ") + banner_url) - self.banner_text.SetToolTip(banner_url if banner_url != _("N/A") else "") + # Update timeline buttons with counts + posts_count = self.profile_data.get("postsCount") or 0 + followers_count = self.profile_data.get("followersCount") or 0 + following_count = self.profile_data.get("followsCount") or 0 + + self.posts_btn.SetLabel(_("{count} pos&ts. Click to open posts timeline").format(count=posts_count)) + self.followers_btn.SetLabel(_("{count} fo&llowers. Click to open followers timeline").format(count=followers_count)) + self.following_btn.SetLabel(_("{count} &following. Click to open following timeline").format(count=following_count)) + + # Start image download in background thread + Thread(target=self._download_images, daemon=True).start() self.Layout() + def _download_images(self): + """Downloads avatar and banner images from Bluesky server.""" + avatar_url = self.profile_data.get("avatar") if self.profile_data else None + banner_url = self.profile_data.get("banner") if self.profile_data else None + + avatar_bytes = None + banner_bytes = None + + try: + if banner_url: + resp = requests.get(banner_url, timeout=10) + if resp.status_code == 200: + banner_bytes = resp.content + except Exception as e: + logger.debug(f"Failed to download banner: {e}") + + try: + if avatar_url: + resp = requests.get(avatar_url, timeout=10) + if resp.status_code == 200: + avatar_bytes = resp.content + except Exception as e: + logger.debug(f"Failed to download avatar: {e}") + + wx.CallAfter(self._draw_images, banner_bytes, avatar_bytes) + + def _draw_images(self, banner_bytes, avatar_bytes): + """Draws downloaded images on the bitmap controls.""" + try: + if banner_bytes: + banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY) + banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH) + self.bannerImage.SetBitmap(banner_image.ConvertToBitmap()) + + if avatar_bytes: + avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY) + avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) + self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap()) + + self.Layout() + self.Fit() + except Exception as e: + logger.debug(f"Failed to draw images: {e}") + + def onPosts(self, *args): + """Open this user's posts timeline.""" + if self.profile_data: + pub.sendMessage('execute-action', action='openPostTimeline', kwargs=dict(user=self.profile_data)) + + def onFollowing(self, *args): + """Open following timeline for this user.""" + if self.profile_data: + pub.sendMessage('execute-action', action='openFollowingTimeline', kwargs=dict(user=self.profile_data)) + + def onFollowers(self, *args): + """Open followers timeline for this user.""" + if self.profile_data: + pub.sendMessage('execute-action', action='openFollowersTimeline', kwargs=dict(user=self.profile_data)) + def update_action_buttons_state(self): if not self.profile_data or not self.target_user_did or self.target_user_did == self._get_own_did(): self.follow_btn.Hide() @@ -239,24 +329,38 @@ class ShowUserProfileDialog(wx.Dialog): action_button.Disable() try: - if command == "block_user" and hasattr(self.session, "block_user"): + ok = False + if command == "follow_user" and hasattr(self.session, "follow_user"): + ok = self.session.follow_user(self.target_user_did) + elif command == "unfollow_user" and hasattr(self.session, "unfollow_user"): + viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {} + follow_uri = viewer_state.get("following") + if follow_uri: + ok = self.session.unfollow_user(follow_uri) + else: + raise RuntimeError(_("Follow information not available.")) + elif command == "mute_user" and hasattr(self.session, "mute_user"): + ok = self.session.mute_user(self.target_user_did) + elif command == "unmute_user" and hasattr(self.session, "unmute_user"): + ok = self.session.unmute_user(self.target_user_did) + elif command == "block_user" and hasattr(self.session, "block_user"): ok = self.session.block_user(self.target_user_did) - if not ok: - raise RuntimeError(_("Failed to block user.")) elif command == "unblock_user" and hasattr(self.session, "unblock_user"): viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {} block_uri = viewer_state.get("blocking") if not block_uri: raise RuntimeError(_("Block information not available.")) ok = self.session.unblock_user(block_uri) - if not ok: - raise RuntimeError(_("Failed to unblock user.")) else: raise RuntimeError(_("This action is not supported yet.")) + if not ok: + raise RuntimeError(_("Action failed.")) + wx.EndBusyCursor() wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self) - wx.CallAfter(asyncio.create_task, self.load_profile_data()) + # Reload profile data in a new thread + Thread(target=self.load_profile_data, daemon=True).start() except Exception as e: wx.EndBusyCursor() if action_button: diff --git a/src/wxUI/dialogs/blueski/userActions.py b/src/wxUI/dialogs/blueski/userActions.py index e3e3655c..8459a684 100644 --- a/src/wxUI/dialogs/blueski/userActions.py +++ b/src/wxUI/dialogs/blueski/userActions.py @@ -14,8 +14,10 @@ class UserActionsDialog(wx.Dialog): default_user = users[0] if users else "" self.cb = wx.ComboBox(panel, -1, choices=users, value=default_user) self.cb.SetFocus() + self.autocompletion = wx.Button(panel, -1, _(u"&Autocomplete users")) userSizer.Add(userLabel, 0, wx.ALL, 5) userSizer.Add(self.cb, 0, wx.ALL, 5) + userSizer.Add(self.autocompletion, 0, wx.ALL, 5) actionSizer = wx.BoxSizer(wx.VERTICAL) label2 = wx.StaticText(panel, -1, _(u"Action")) @@ -83,3 +85,9 @@ class UserActionsDialog(wx.Dialog): def get_user(self): return self.cb.GetValue() + def get_position(self): + return self.cb.GetPosition() + + def popup_menu(self, menu): + self.PopupMenu(menu, self.cb.GetPosition()) +