From 0f350d41c76ecb58eb4be1dd078d3241fb5b084d Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 22 Feb 2025 14:41:39 +1100 Subject: [PATCH 01/19] Remove compatibility module and update Python version requirements to 3.12 to use eager_task_factory. --- .github/workflows/packaging.yml | 4 +-- ipylab/_compat/__init__.py | 0 ipylab/_compat/typing.py | 17 ------------ ipylab/code_editor.py | 3 +-- ipylab/commands.py | 3 +-- ipylab/common.py | 3 +-- ipylab/connection.py | 3 +-- ipylab/ipylab.py | 48 +++++++++++---------------------- ipylab/jupyterfrontend.py | 5 ++-- ipylab/log.py | 5 ++-- ipylab/menu.py | 7 +++-- ipylab/notification.py | 3 +-- pyproject.toml | 10 +++---- 13 files changed, 33 insertions(+), 78 deletions(-) delete mode 100644 ipylab/_compat/__init__.py delete mode 100644 ipylab/_compat/typing.py diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 5f7a5e8c..491fb9e1 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -50,9 +50,9 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python: ['3.11', '3.13'] + python: ['3.12', '3.13'] include: - - python: '3.11' + - python: '3.12' dist: 'ipylab*.tar.gz' - python: '3.13' dist: 'ipylab*.whl' diff --git a/ipylab/_compat/__init__.py b/ipylab/_compat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ipylab/_compat/typing.py b/ipylab/_compat/typing.py deleted file mode 100644 index 1196b434..00000000 --- a/ipylab/_compat/typing.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) ipylab contributors. -# Distributed under the terms of the Modified BSD License. - -from __future__ import annotations - -import sys - -if sys.version_info < (3, 12): - from typing_extensions import override -else: - from typing import override - -__all__ = ["override"] - - -def __dir__() -> list[str]: - return __all__ diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index b705c2f8..0aa8f509 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -7,7 +7,7 @@ import inspect import typing from asyncio import Task -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, override from IPython.core import completer as IPC # noqa: N812 from IPython.utils.tokenutil import token_at_cursor @@ -18,7 +18,6 @@ from traitlets import Callable, Container, Dict, Instance, Int, Unicode, default, observe import ipylab -from ipylab._compat.typing import override from ipylab.common import Fixed, LastUpdatedDict from ipylab.ipylab import Ipylab diff --git a/ipylab/commands.py b/ipylab/commands.py index 1a2f0bf3..93c5822d 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -6,14 +6,13 @@ import functools import inspect import uuid -from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack +from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack, override from ipywidgets import TypedTuple from traitlets import Callable as CallableTrait from traitlets import Container, Dict, Instance, Tuple, Unicode import ipylab -from ipylab._compat.typing import override from ipylab.common import IpylabKwgs, Obj, TaskHooks, TaskHookType, TransformType, pack from ipylab.connection import InfoConnection, ShellConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform, register diff --git a/ipylab/common.py b/ipylab/common.py index 5e40631d..ec89ea2d 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -9,14 +9,13 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from enum import StrEnum -from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, TypedDict, TypeVar, override import pluggy from ipywidgets import Widget, widget_serialization from traitlets import HasTraits import ipylab -from ipylab._compat.typing import override __all__ = [ "Area", diff --git a/ipylab/connection.py b/ipylab/connection.py index de1e33d7..13513c2e 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -5,12 +5,11 @@ import uuid import weakref -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, override from ipywidgets import Widget, register from traitlets import Bool, Dict, Instance, Unicode, observe -from ipylab._compat.typing import override from ipylab.ipylab import Ipylab if TYPE_CHECKING: diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 54c20644..7feb7bf0 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -51,23 +51,6 @@ def __init__(self, base: Obj, subpath: str): super().__init__((base, subpath)) -class Response(asyncio.Event): - def set(self, payload, error: Exception | None = None) -> None: - if getattr(self, "_value", False): - msg = "Already set!" - raise RuntimeError(msg) - self.payload = payload - self.error = error - super().set() - - async def wait(self) -> Any: - """Wait for a message and return the response.""" - await super().wait() - if self.error: - raise self.error - return self.payload - - class IpylabFrontendError(IOError): pass @@ -101,7 +84,7 @@ class Ipylab(WidgetBase): _ready_event: asyncio.Event | None = None _comm = None - _pending_operations: Dict[str, Response] = Dict() + _pending_operations: Dict[str, asyncio.Future] = Dict() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() ipylab_tasks: Container[set[asyncio.Task]] = Set() close_extras: Fixed[weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) @@ -200,16 +183,11 @@ def _check_closed(self): async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: await self.ready() try: - if not hooks: - return await aw result = await aw - try: + if hooks: self._task_result(result, hooks) - except Exception: - self.log.exception("TaskHook error", obj={"result": result, "hooks": hooks, "aw": aw}) - raise except Exception: - self.log.exception("Task error", obj=aw) + self.log.exception("Task error", obj={"result": result, "hooks": hooks, "aw": aw}) raise else: return result @@ -272,8 +250,11 @@ def _on_custom_msg(self, _, msg: dict, buffers: list): try: c = json.loads(content) if "ipylab_PY" in c: - error = self._to_frontend_error(c) if "error" in c else None - self._pending_operations.pop(c["ipylab_PY"]).set(c.get("payload"), error) + op = self._pending_operations.pop(c["ipylab_PY"]) + if "error" in c: + op.set_exception(self._to_frontend_error(c)) + else: + op.set_result(c.get("payload")) elif "ipylab_FE" in c: return self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers)) elif "closed" in c: @@ -383,7 +364,7 @@ def _ipylab_send(self, content, buffers: list | None = None): raise def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]: - """Run aw in a task. + """Run aw in an eager task. If the task is running when this object is closed the task will be cancel. Noting the corresponding promise in the frontend will run to completion. @@ -398,9 +379,10 @@ def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookT """ self._check_closed() - task = asyncio.create_task(self._wrap_awaitable(aw, hooks), name=name) - self.ipylab_tasks.add(task) - task.add_done_callback(self._task_done_callback) + task = asyncio.eager_task_factory(asyncio.get_running_loop(), self._wrap_awaitable(aw, hooks), name=name) + if not task.done(): + self.ipylab_tasks.add(task) + task.add_done_callback(self._task_done_callback) return task def operation( @@ -450,11 +432,11 @@ def operation( if toObject: content["toObject"] = toObject - self._pending_operations[ipylab_PY] = response = Response() + self._pending_operations[ipylab_PY] = op = asyncio.get_running_loop().create_future() async def _operation(content: dict): self._ipylab_send(content) - payload = await response.wait() + payload = await op return Transform.transform_payload(content["transform"], payload) return self.to_task(_operation(content), name=ipylab_PY, hooks=hooks) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 25bb8996..2adf2e88 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -6,14 +6,13 @@ import contextlib import functools import inspect -from typing import TYPE_CHECKING, Any, Unpack +from typing import TYPE_CHECKING, Any, Unpack, override from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe import ipylab from ipylab import Ipylab -from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, to_selector from ipylab.dialog import Dialog @@ -62,7 +61,7 @@ class App(Ipylab): @classmethod @override - def _single_key(cls, kwgs: dict): # noqa: ARG003 + def _single_key(cls, kwgs: dict): return "app" def close(self): diff --git a/ipylab/log.py b/ipylab/log.py index b9f732d7..f7f2a5ba 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -6,13 +6,12 @@ import logging import weakref from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, override from IPython.core.ultratb import FormattedTB from ipywidgets import CallbackDispatcher import ipylab -from ipylab._compat.typing import override if TYPE_CHECKING: from asyncio import Task @@ -101,7 +100,7 @@ def _add_logger(self, logger: logging.Logger): logger.addHandler(self) @override - def setLevel(self, level: LogLevel) -> None: # noqa: N802 + def setLevel(self, level: LogLevel) -> None: level = LogLevel(level) super().setLevel(level) for logger in self._loggers: diff --git a/ipylab/menu.py b/ipylab/menu.py index a08cfe33..fca45e56 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -3,13 +3,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from ipywidgets import TypedTuple from traitlets import Container, Instance, Union import ipylab -from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry from ipylab.common import Fixed, Obj from ipylab.connection import InfoConnection @@ -157,7 +156,7 @@ class MainMenu(Menu): @classmethod @override - def _single_key(cls, kwgs: dict): # noqa: ARG003 + def _single_key(cls, kwgs: dict): return cls def __init__(self): @@ -193,7 +192,7 @@ def add_item( selector="", submenu: MenuConnection | None = None, rank: float | None = None, - type: Literal["command", "submenu", "separator"] = "command", # noqa: A002 + type: Literal["command", "submenu", "separator"] = "command", args: dict | None = None, ) -> Task[MenuItemConnection]: """Add command, subitem or separator. diff --git a/ipylab/notification.py b/ipylab/notification.py index 576c39a8..cef27ca0 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -5,7 +5,7 @@ import inspect from enum import StrEnum -from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, override import traitlets from ipywidgets import TypedTuple, register @@ -13,7 +13,6 @@ import ipylab from ipylab import Transform, pack -from ipylab._compat.typing import override from ipylab.common import Obj, TaskHooks, TransformType from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase diff --git a/pyproject.toml b/pyproject.toml index e19467bc..f8c550ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" name = "ipylab" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.11" +requires-python = ">=3.12" classifiers = [ "Framework :: Jupyter", "Framework :: Jupyter :: JupyterLab", @@ -20,18 +20,16 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ - "jupyterlab>=4.1", + "jupyterlab>=4.3", "ipywidgets>=8.1.5", - "ipython>=8.2", + "ipython>=8.32", "jupyterlab_widgets>=3.0.11", - "pluggy~=1.1", - "typing_extensions; python_version < '3.12'", + "pluggy~=1.5", ] dynamic = ["version", "description", "authors", "urls", "keywords"] From a806cd95f503d9160ae88e0b7ee73b1108082e5f Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 26 Feb 2025 15:20:28 +1100 Subject: [PATCH 02/19] Fix for _wrap_awaitable where aw fails. --- ipylab/ipylab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 7feb7bf0..42a2af56 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -182,6 +182,7 @@ def _check_closed(self): async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: await self.ready() + result = None try: result = await aw if hooks: From 83358b445ee78687b915c11fe9c62efb65070820 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 26 Feb 2025 19:33:52 +1100 Subject: [PATCH 03/19] Change selector from a property to a coroutine. --- ipylab/commands.py | 31 ++++++++++++++++++------------- ipylab/jupyterfrontend.py | 6 +++--- ipylab/menu.py | 6 +++++- ipylab/shell.py | 4 ++-- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/ipylab/commands.py b/ipylab/commands.py index 93c5822d..415b4f3a 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -92,20 +92,25 @@ def add_key_binding( self, keys: list, selector="", args: dict | None = None, *, prevent_default=True ) -> Task[KeybindingConnection]: "Add a key binding for this command and selector." - if not self.comm: - msg = f"Closed: {self}" - raise RuntimeError(msg) args = args or {} - selector = selector or ipylab.app.selector - args |= {"keys": keys, "preventDefault": prevent_default, "selector": selector, "command": str(self)} - cid = KeybindingConnection.to_cid(self) - transform: TransformType = {"transform": Transform.connection, "cid": cid} - hooks: TaskHooks = { - "add_to_tuple_fwd": [(self, "key_bindings")], - "trait_add_fwd": [("info", args), ("command", self)], - "close_with_fwd": [self], - } - return self.commands.execute_method("addKeyBinding", args, transform=transform, hooks=hooks) + + async def add_key_binding(): + args_ = args | { + "keys": keys, + "preventDefault": prevent_default, + "selector": selector or await ipylab.app.selector(), + "command": str(self), + } + cid = KeybindingConnection.to_cid(self) + transform: TransformType = {"transform": Transform.connection, "cid": cid} + hooks: TaskHooks = { + "add_to_tuple_fwd": [(self, "key_bindings")], + "trait_add_fwd": [("info", args_), ("command", self)], + "close_with_fwd": [self], + } + return await self.commands.execute_method("addKeyBinding", args_, transform=transform, hooks=hooks) + + return self.to_task(add_key_binding()) class CommandPalletItemConnection(InfoConnection): diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 2adf2e88..947d8842 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -101,9 +101,9 @@ def repr_log(self): "A representation to use when logging" return self.__class__.__name__ - @property - def selector(self): - # Calling this before `_ready` is set will raise an attribute error. + async def selector(self): + "Returns the selector once the app is ready." + await self.ready() return self._selector @override diff --git a/ipylab/menu.py b/ipylab/menu.py index fca45e56..ae79de1f 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -200,7 +200,11 @@ def add_item( ref: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#context-menu """ - return self._add_item(command, submenu, rank, type, args, selector or ipylab.app.selector) + + async def add_item_(): + return await self._add_item(command, submenu, rank, type, args, selector or await ipylab.app.selector()) + + return self.to_task(add_item_()) @override def activate(self): diff --git a/ipylab/shell.py b/ipylab/shell.py index 96816073..88ff1d85 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -133,12 +133,12 @@ def add( if isinstance(obj, ipylab.Panel): hooks_["add_to_tuple_fwd"].append((obj, "connections")) args["ipy_model"] = obj.model_id - if isinstance(obj, DOMWidget): - obj.add_class(ipylab.app.selector.removeprefix(".")) else: args["evaluate"] = pack(obj) async def add_to_shell() -> ShellConnection: + if isinstance(obj, DOMWidget): + obj.add_class((await ipylab.app.selector()).removeprefix(".")) if "evaluate" in args: if isinstance(vpath, dict): result = ipylab.plugin_manager.hook.vpath_getter(app=ipylab.app, kwgs=vpath) From 120d004809735b03b3eaf27f71148084bf82e1b3 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 26 Feb 2025 19:56:49 +1100 Subject: [PATCH 04/19] Refactor vpath handling to use a coroutine and update references to _vpath across the codebase --- ipylab/commands.py | 4 ++-- ipylab/jupyterfrontend.py | 13 +++++++++---- ipylab/log_viewer.py | 4 ++-- ipylab/shell.py | 12 +++++++----- src/widgets/frontend.ts | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/ipylab/commands.py b/ipylab/commands.py index 415b4f3a..6418f262 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -295,7 +295,7 @@ def add_command( """ async def add_command(): - cid = CommandConnection.to_cid(self.name, ipylab.app.vpath, name) + cid = CommandConnection.to_cid(self.name, await ipylab.app.vpath(), name) if cmd := CommandConnection.get_existing_connection(cid, quiet=True): await cmd.ready() cmd.close() @@ -348,7 +348,7 @@ def execute(self, command_id: str | CommandConnection, args: dict | None = None, async def execute_command(): id_ = str(command_id) if id_ not in self.all_commands: - id_ = CommandConnection.to_cid(self.name, ipylab.app.vpath, id_) + id_ = CommandConnection.to_cid(self.name, await ipylab.app.vpath(), id_) if id_ not in self.all_commands: msg = f"Command '{command_id}' not registered!" raise ValueError(msg) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 947d8842..d75025d4 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -41,7 +41,7 @@ class App(Ipylab): _model_name = Unicode("JupyterFrontEndModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True) version = Unicode(read_only=True).tag(sync=True) - vpath = Unicode(read_only=True).tag(sync=True) + _vpath = Unicode(read_only=True).tag(sync=True) per_kernel_widget_manager_detected = Bool(read_only=True).tag(sync=True) shell = Fixed(Shell) @@ -77,8 +77,8 @@ def _default_logging_handler(self): @observe("_ready", "log_level") def _app_observe_ready(self, change): if change["name"] == "_ready" and self._ready: - assert self.vpath, "Vpath should always before '_ready'." # noqa: S101 - self._selector = to_selector(self.vpath) + assert self._vpath, "Vpath should always before '_ready'." # noqa: S101 + self._selector = to_selector(self._vpath) ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001 try: ipylab.plugin_manager.hook.autostart.call_historic( @@ -94,13 +94,18 @@ def _autostart_callback(self, result): @property def repr_info(self): - return {"vpath": self.vpath} + return {"vpath": self._vpath} @property def repr_log(self): "A representation to use when logging" return self.__class__.__name__ + async def vpath(self): + "Returns the vpath for this kernel once the app is ready." + await self.ready() + return self._vpath + async def selector(self): "Returns the selector once the app is ready." await self.ready() diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 45c5aa31..c587b2ea 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -106,8 +106,8 @@ def close(self): def _observe_connections(self, _): if self.connections and len(self.connections) == 1: self.output.push(*(rec.output for rec in self._records), clear=True) - self.info.value = f"Vpath: {ipylab.app.vpath}" - self.title.label = f"Log: {ipylab.app.vpath}" + self.info.value = f"Vpath: {ipylab.app._vpath}" # noqa: SLF001 + self.title.label = f"Log: {ipylab.app._vpath}" # noqa: SLF001 def _add_record(self, record: logging.LogRecord): self._records.append(record) diff --git a/ipylab/shell.py b/ipylab/shell.py index 88ff1d85..e0e0bbe1 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -137,6 +137,7 @@ def add( args["evaluate"] = pack(obj) async def add_to_shell() -> ShellConnection: + vpath_ = await ipylab.app.vpath() if isinstance(obj, DOMWidget): obj.add_class((await ipylab.app.selector()).removeprefix(".")) if "evaluate" in args: @@ -146,11 +147,11 @@ async def add_to_shell() -> ShellConnection: result = await result args["vpath"] = result else: - args["vpath"] = vpath or ipylab.app.vpath - if args["vpath"] != ipylab.app.vpath: + args["vpath"] = vpath or vpath_ + if args["vpath"] != vpath_: hooks_["trait_add_fwd"] = [("auto_dispose", False)] else: - args["vpath"] = ipylab.app.vpath + args["vpath"] = vpath_ return await self.operation("addToShell", {"args": args}, transform=Transform.connection, hooks=hooks_) @@ -191,14 +192,15 @@ async def open_console(): if not isinstance(ref_, ShellConnection): ref_ = await self.connect_to_widget(ref_) objects_ = {"ref": ref_} | (objects or {}) + vpath_ = await ipylab.app.vpath() args = { - "path": ipylab.app.vpath, + "path": vpath_, "insertMode": InsertMode(mode), "activate": activate, "ref": f"{pack(ref_)}.id", } kwgs = IpylabKwgs( - transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(ipylab.app.vpath)}, + transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath_)}, toObject=["args[ref]"], hooks={ "trait_add_rev": [(self, "console")], diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index d82e5cfc..a1d94657 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -26,7 +26,7 @@ export class JupyterFrontEndModel extends IpylabModel { async ipylabInit(base: any = null) { const vpath = await JFEM.getVpath(this.kernelId); - this.set('vpath', vpath); + this.set('_vpath', vpath); this.set('version', JFEM.app.version); this.set('per_kernel_widget_manager_detected', JFEM.PER_KERNEL_WM); await super.ipylabInit(base); From 5ae8c944acf652b7ae4739f4f1e3eac8ca19ab53 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 26 Feb 2025 23:04:11 +1100 Subject: [PATCH 05/19] Refactor _on_ready_callbacks to use a List and update related methods with improved documentation --- ipylab/ipylab.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 42a2af56..e08bdcbc 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -11,8 +11,22 @@ import weakref from typing import TYPE_CHECKING, Any, TypeVar +import traitlets from ipywidgets import Widget, register -from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe +from traitlets import ( + Bool, + Container, + Dict, + HasTraits, + Instance, + List, + Set, + TraitError, + TraitType, + Unicode, + default, + observe, +) import ipylab import ipylab._frontend as _fe @@ -76,7 +90,9 @@ class Ipylab(WidgetBase): ipylab_base = IpylabBase(Obj.this, "").tag(sync=True) _ready = Bool(read_only=True, help="Set to by frontend when ready").tag(sync=True) - _on_ready_callbacks: Container[set[Callable]] = Set() + _on_ready_callbacks: Container[list[Callable[[], None | Awaitable] | Callable[[Self], None | Awaitable]]] = List( + trait=traitlets.Callable() + ) _async_widget_base_init_complete = False _single_map: ClassVar[dict[Hashable, str]] = {} # single_key : model_id @@ -320,6 +336,11 @@ def ensure_run(self, aw: Callable | Awaitable | None) -> None: raise async def ready(self): + """Wait for the application to be ready. + + If this is not the main application instance, it waits for the + main application instance to be ready first. + """ if self is not ipylab.app and not ipylab.app._ready: # noqa: SLF001 await ipylab.app.ready() if not self._ready: # type: ignore @@ -336,10 +357,22 @@ async def ready(self): await self._ready_event.wait() def on_ready(self, callback, remove=False): # noqa: FBT002 + """Register a callback to execute when the application is ready. + + The callback will be executed only once. + + Parameters + ---------- + callback : callable + The callback to execute when the application is ready. + remove : bool, optional + If True, remove the callback from the list of callbacks. + By default, False. + """ if remove: - self._on_ready_callbacks.discard(callback) - else: - self._on_ready_callbacks.add(callback) + self._on_ready_callbacks.append(callback) + elif callback in self._on_ready_callbacks: + self._on_ready_callbacks.remove(callback) def add_to_tuple(self, owner: HasTraits, name: str): """Add self to the tuple of obj.""" From 69595dfecb0bb0ec058ef35218e183adb1fa709a Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 12:01:18 +1100 Subject: [PATCH 06/19] Refactor CSSStyleSheet usage in examples and remove import from __init__.py --- examples/css_stylesheet.ipynb | 7 ++++--- ipylab/__init__.py | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb index d4c07a80..ad5cd55c 100644 --- a/examples/css_stylesheet.ipynb +++ b/examples/css_stylesheet.ipynb @@ -36,6 +36,7 @@ "import ipywidgets as ipw\n", "\n", "import ipylab\n", + "from ipylab.css_stylesheet import CSSStyleSheet\n", "\n", "app = ipylab.app" ] @@ -47,7 +48,7 @@ "outputs": [], "source": [ "# Create a new CSSStyleSheet object.\n", - "ss = ipylab.CSSStyleSheet()\n", + "ss = CSSStyleSheet()\n", "\n", "# Set a css variable\n", "t = ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n", @@ -144,7 +145,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss = ipylab.CSSStyleSheet()" + "ss = CSSStyleSheet()" ] }, { @@ -181,7 +182,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss = ipylab.CSSStyleSheet()\n", + "ss = CSSStyleSheet()\n", "ss.replace(\"\"\"\n", "/* Modify Jupyter Styles */\n", "\n", diff --git a/ipylab/__init__.py b/ipylab/__init__.py index 50728617..bf4f7e24 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -8,7 +8,6 @@ from ipylab.code_editor import CodeEditor from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector from ipylab.connection import Connection, ShellConnection -from ipylab.css_stylesheet import CSSStyleSheet from ipylab.ipylab import Ipylab from ipylab.jupyterfrontend import App, JupyterFrontEnd from ipylab.notification import NotificationType, NotifyAction @@ -19,7 +18,6 @@ "__version__", "CodeEditor", "Connection", - "CSSStyleSheet", "ShellConnection", "SimpleOutput", "Panel", From 83093f12590ab7748f5d71158228e44da2bccb47 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 13:34:21 +1100 Subject: [PATCH 07/19] Refactor vpath and selector access to properties in App class and update related usages --- ipylab/commands.py | 6 +++--- ipylab/jupyterfrontend.py | 37 +++++++++++++++++++++++++++++++------ ipylab/menu.py | 2 +- ipylab/shell.py | 10 +++++----- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/ipylab/commands.py b/ipylab/commands.py index 6418f262..8ef1fe03 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -98,7 +98,7 @@ async def add_key_binding(): args_ = args | { "keys": keys, "preventDefault": prevent_default, - "selector": selector or await ipylab.app.selector(), + "selector": selector or ipylab.app.selector, "command": str(self), } cid = KeybindingConnection.to_cid(self) @@ -295,7 +295,7 @@ def add_command( """ async def add_command(): - cid = CommandConnection.to_cid(self.name, await ipylab.app.vpath(), name) + cid = CommandConnection.to_cid(self.name, ipylab.app.vpath, name) if cmd := CommandConnection.get_existing_connection(cid, quiet=True): await cmd.ready() cmd.close() @@ -348,7 +348,7 @@ def execute(self, command_id: str | CommandConnection, args: dict | None = None, async def execute_command(): id_ = str(command_id) if id_ not in self.all_commands: - id_ = CommandConnection.to_cid(self.name, await ipylab.app.vpath(), id_) + id_ = CommandConnection.to_cid(self.name, ipylab.app.vpath, id_) if id_ not in self.all_commands: msg = f"Command '{command_id}' not registered!" raise ValueError(msg) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index d75025d4..4a0f0826 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -101,14 +101,39 @@ def repr_log(self): "A representation to use when logging" return self.__class__.__name__ - async def vpath(self): - "Returns the vpath for this kernel once the app is ready." - await self.ready() + @property + def vpath(self): + """The virtual path for this kernel. + + `vpath` is equivalent to the session `path` in the frontend. + + Raises + ------ + RuntimeError + If App is not ready. + + Returns + ------- + str + Virtual path to the application. + """ + if not self._ready: + msg = "`vpath` cannot not be accessed until app is ready." + raise RuntimeError(msg) return self._vpath - async def selector(self): - "Returns the selector once the app is ready." - await self.ready() + @property + def selector(self): + """The default selector based on the `vpath` for this kernel. + + Raises + ------ + RuntimeError + If the application is not ready. + """ + if not self._ready: + msg = "`vpath` cannot not be accessed until app is ready." + raise RuntimeError(msg) return self._selector @override diff --git a/ipylab/menu.py b/ipylab/menu.py index ae79de1f..3fd055df 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -202,7 +202,7 @@ def add_item( """ async def add_item_(): - return await self._add_item(command, submenu, rank, type, args, selector or await ipylab.app.selector()) + return await self._add_item(command, submenu, rank, type, args, selector or ipylab.app.selector) return self.to_task(add_item_()) diff --git a/ipylab/shell.py b/ipylab/shell.py index e0e0bbe1..3c7da52c 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -137,9 +137,9 @@ def add( args["evaluate"] = pack(obj) async def add_to_shell() -> ShellConnection: - vpath_ = await ipylab.app.vpath() + vpath_ = ipylab.app.vpath if isinstance(obj, DOMWidget): - obj.add_class((await ipylab.app.selector()).removeprefix(".")) + obj.add_class(ipylab.app.selector.removeprefix(".")) if "evaluate" in args: if isinstance(vpath, dict): result = ipylab.plugin_manager.hook.vpath_getter(app=ipylab.app, kwgs=vpath) @@ -192,15 +192,15 @@ async def open_console(): if not isinstance(ref_, ShellConnection): ref_ = await self.connect_to_widget(ref_) objects_ = {"ref": ref_} | (objects or {}) - vpath_ = await ipylab.app.vpath() + vpath = ipylab.app.vpath args = { - "path": vpath_, + "path": vpath, "insertMode": InsertMode(mode), "activate": activate, "ref": f"{pack(ref_)}.id", } kwgs = IpylabKwgs( - transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath_)}, + transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath)}, toObject=["args[ref]"], hooks={ "trait_add_rev": [(self, "console")], From 78c5d91b32c8f2ac94ad0e8046d12c1af4d2c822 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 16:34:35 +1100 Subject: [PATCH 08/19] Fix CSSStyleSheet and on_ready registration. --- ipylab/css_stylesheet.py | 2 ++ ipylab/ipylab.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 6906d3cb..9d42a3ce 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -27,6 +27,8 @@ class CSSStyleSheet(Ipylab): ) def __init__(self, **kwgs): + if self._async_widget_base_init_complete: + return super().__init__(**kwgs) self.on_ready(self._restore) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index e08bdcbc..c8685405 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -369,7 +369,7 @@ def on_ready(self, callback, remove=False): # noqa: FBT002 If True, remove the callback from the list of callbacks. By default, False. """ - if remove: + if not remove: self._on_ready_callbacks.append(callback) elif callback in self._on_ready_callbacks: self._on_ready_callbacks.remove(callback) From 04b408eaa0ba466fe165aeca9d71ce0a629aea8f Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 17:25:37 +1100 Subject: [PATCH 09/19] Add tests for on_ready callback registration and removal in Ipylab --- tests/conftest.py | 9 ++++++++ tests/test_ipylab.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/test_ipylab.py diff --git a/tests/conftest.py b/tests/conftest.py index 21f26fb2..1b84d3e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +import ipylab + @pytest.fixture(scope="session") def anyio_backend(): @@ -9,3 +11,10 @@ def anyio_backend(): @pytest.fixture(autouse=True) async def anyio_backend_autouse(anyio_backend): return anyio_backend + + +@pytest.fixture +async def app(mocker): + app = ipylab.app + mocker.patch.object(app, "ready") + return app diff --git a/tests/test_ipylab.py b/tests/test_ipylab.py new file mode 100644 index 00000000..cd4a8728 --- /dev/null +++ b/tests/test_ipylab.py @@ -0,0 +1,50 @@ +from unittest.mock import AsyncMock, MagicMock + +from ipylab.ipylab import Ipylab +from ipylab.jupyterfrontend import App + + +async def test_on_ready_add_and_remove(): + obj = Ipylab() + callback = MagicMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks # noqa: SLF001 + + # Simulate the ready event + obj.set_trait("_ready", True) + callback.assert_called() + + callback.reset_mock() + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_called() + + # Reset the mock and remove the callback + callback.reset_mock() + obj.on_ready(callback, remove=True) + assert callback not in obj._on_ready_callbacks # noqa: SLF001 + + # Simulate the ready event again, callback should not be called + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_not_called() + + obj.close() + + +async def test_on_ready_async(app: App): # noqa: ARG001 + obj = Ipylab() + callback = AsyncMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks # noqa: SLF001 + + # Simulate the ready event + obj.set_trait("_ready", True) + callback.assert_called() + assert callback.await_count == 1 # With eager task factory this should already be called. + assert callback.call_args[0][0] is obj + obj.close() From bc8a52e9a7dc921cac81111984c6aa34c44858c6 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 17:54:45 +1100 Subject: [PATCH 10/19] Update test cases for on_ready callback in Ipylab and remove unused noqa comments --- pyproject.toml | 3 ++ tests/test_ipylab.py | 88 +++++++++++++++++------------------ tests/test_jupyterfrontend.py | 2 +- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8c550ec..4508aafb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,3 +111,6 @@ write = true ignore = ["BLE001", "N803"] [tool.ruff.format] docstring-code-format = true + +[tool.ruff.lint.per-file-ignores] +"tests*" = ['ARG002', 'SLF001', 'S101', 'PLR2004'] diff --git a/tests/test_ipylab.py b/tests/test_ipylab.py index cd4a8728..72202375 100644 --- a/tests/test_ipylab.py +++ b/tests/test_ipylab.py @@ -4,47 +4,47 @@ from ipylab.jupyterfrontend import App -async def test_on_ready_add_and_remove(): - obj = Ipylab() - callback = MagicMock() - - # Add the callback - obj.on_ready(callback) - assert callback in obj._on_ready_callbacks # noqa: SLF001 - - # Simulate the ready event - obj.set_trait("_ready", True) - callback.assert_called() - - callback.reset_mock() - obj.set_trait("_ready", False) - obj.set_trait("_ready", True) - callback.assert_called() - - # Reset the mock and remove the callback - callback.reset_mock() - obj.on_ready(callback, remove=True) - assert callback not in obj._on_ready_callbacks # noqa: SLF001 - - # Simulate the ready event again, callback should not be called - obj.set_trait("_ready", False) - obj.set_trait("_ready", True) - callback.assert_not_called() - - obj.close() - - -async def test_on_ready_async(app: App): # noqa: ARG001 - obj = Ipylab() - callback = AsyncMock() - - # Add the callback - obj.on_ready(callback) - assert callback in obj._on_ready_callbacks # noqa: SLF001 - - # Simulate the ready event - obj.set_trait("_ready", True) - callback.assert_called() - assert callback.await_count == 1 # With eager task factory this should already be called. - assert callback.call_args[0][0] is obj - obj.close() +class TestOnReady: + async def test_on_ready_add_and_remove(self): + obj = Ipylab() + callback = MagicMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks + + # Simulate the ready event + obj.set_trait("_ready", True) + callback.assert_called() + + callback.reset_mock() + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_called() + + # Reset the mock and remove the callback + callback.reset_mock() + obj.on_ready(callback, remove=True) + assert callback not in obj._on_ready_callbacks + + # Simulate the ready event again, callback should not be called + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_not_called() + + obj.close() + + async def test_on_ready_async(self, app: App): + obj = Ipylab() + callback = AsyncMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks + + # Simulate the ready event + obj.set_trait("_ready", True) + callback.assert_called() + assert callback.await_count == 1 # With eager task factory this should already be called. + assert callback.call_args[0][0] is obj + obj.close() diff --git a/tests/test_jupyterfrontend.py b/tests/test_jupyterfrontend.py index 1e7164cb..4555de73 100644 --- a/tests/test_jupyterfrontend.py +++ b/tests/test_jupyterfrontend.py @@ -101,7 +101,7 @@ async def test_app_evaluate(kw: dict[str, Any], result, mocker): fe_msg = {"ipylab": json.dumps(data)} # Simulate the message arriving in kernel and being processed - task2 = app._on_custom_msg(None, fe_msg, []) # noqa: SLF001 + task2 = app._on_custom_msg(None, fe_msg, []) assert isinstance(task2, asyncio.Task) async with asyncio.timeout(1): await task2 From c1c7298420360fc293fd61c1877c7a6edde79e56 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Feb 2025 21:47:00 +1100 Subject: [PATCH 11/19] Fix JupyteFrontEnd no closing properly. --- src/widgets/frontend.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index a1d94657..42b394fd 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -7,6 +7,7 @@ import { Kernel } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; import { IpylabModel } from './ipylab'; +const VPATH = '_vpath'; /** * JupyterFrontEndModel (JFEM) is a SINGLETON per kernel. */ @@ -26,7 +27,7 @@ export class JupyterFrontEndModel extends IpylabModel { async ipylabInit(base: any = null) { const vpath = await JFEM.getVpath(this.kernelId); - this.set('_vpath', vpath); + this.set(VPATH, vpath); this.set('version', JFEM.app.version); this.set('per_kernel_widget_manager_detected', JFEM.PER_KERNEL_WM); await super.ipylabInit(base); @@ -38,7 +39,7 @@ export class JupyterFrontEndModel extends IpylabModel { close(comm_closed?: boolean): Promise { Private.jfems.delete(this.kernelId); - Private.vpathTojfem.delete(this.get('vpath')); + Private.vpathTojfem.delete(this.get(VPATH)); return super.close(comm_closed); } @@ -133,8 +134,16 @@ export class JupyterFrontEndModel extends IpylabModel { reject(msg); }, 10000); Private.vpathTojfem.get(vpath).promise.then(jfem => { - clearTimeout(timeoutID); - resolve(jfem); + if (!jfem.commAvailable) { + jfem.close(); + JupyterFrontEndModel.getModelByVpath(vpath).then(jfem => { + clearTimeout(timeoutID); + resolve(jfem); + }); + } else { + clearTimeout(timeoutID); + resolve(jfem); + } }); }); } From fdbc1b60343b61df07cf75210077dc49296ce194 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 3 Mar 2025 21:18:55 +1100 Subject: [PATCH 12/19] Add ResizeBox widget with resize capabilities and update related files --- .vscode/settings.json | 4 +-- README.md | 9 +++--- examples/widgets.ipynb | 60 +++++++++++++++++++++++++++++++++++- ipylab/widgets.py | 17 +++++++++- src/widget.ts | 3 ++ src/widgets/resize_box.ts | 65 +++++++++++++++++++++++++++++++++++++++ style/widget.css | 7 +++++ 7 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 src/widgets/resize_box.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ef88f0f7..5e55950f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,9 +14,7 @@ "python.terminal.activateEnvInCurrentTerminal": true, "python.createEnvironment.trigger": "prompt", "python.analysis.typeCheckingMode": "basic", - "python.testing.pytestArgs": [ - "tests" - ], + "python.testing.pytestArgs": ["tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true } diff --git a/README.md b/README.md index 802762de..92d6274a 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,11 @@ combining [Per-kernel-widget-manager](https://github.com/jupyter-widgets/ipywidg and [weakref](https://github.com/fleming79/ipywidgets/tree/weakref). These versions enable: -* Widget restoration when the page is reloaded. -* Starting new kernels and opening widgets from those kernels. -* autostart plugins - Run code when Jupyterlab is started. -* Viewing widgets from kernels inside from other kernels. + +- Widget restoration when the page is reloaded. +- Starting new kernels and opening widgets from those kernels. +- autostart plugins - Run code when Jupyterlab is started. +- Viewing widgets from kernels inside from other kernels. ```bash # For per-kernel-widget-manager support (Install modified version of ipywidgets, jupyterlab_widgets & widgetsnbextension) diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index c9f7678f..eed31133 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -484,6 +484,64 @@ "source": [ "split_panel.connections" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ResizeBox\n", + "\n", + "A resize box is a plain box with ['resize' property](https://developer.mozilla.org/en-US/docs/Web/CSS/resize) set to `both`. A size observer reports back the dimensions of the first view as it changes.\n", + "\n", + "Should the first view close, the next available view will be made resizeable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as ipw\n", + "\n", + "import ipylab.widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "button = ipw.Button()\n", + "label = ipw.HTML(\"Test\")\n", + "resize_box = ipylab.widgets.ResizeBox([label], layout={\"border\": \"solid 1px black\"})\n", + "\n", + "\n", + "def observe(_):\n", + " label.value = f\"Size: {resize_box.width}px, {resize_box.height}px\"\n", + "\n", + "\n", + "resize_box.observe(observe, names=(\"width\", \"height\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] } ], "metadata": { @@ -502,7 +560,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 09919c35..28e185fa 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -8,7 +8,7 @@ from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict -from traitlets import Container, Dict, Instance, Unicode, observe +from traitlets import Container, Dict, Instance, Int, Unicode, observe import ipylab import ipylab._frontend as _fe @@ -111,3 +111,18 @@ async def force_refresh(children): return ipylab.app.to_task(force_refresh(self.children)) # ============== End temp fix ============= + + +@register +class ResizeBox(Box): + "A box that is aware of its size. Only the first view is resizeable." + + _model_name = Unicode("ResizeBoxModel").tag(sync=True) + _view_name = Unicode("ResizeBoxView").tag(sync=True) + _model_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) + _model_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) + _view_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) + _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) + + width = Int(readonly=True, help="clientWidth in pixels").tag(sync=True) + height = Int(readonly=True, help="clientHeight in pixels").tag(sync=True) diff --git a/src/widget.ts b/src/widget.ts index 40329b9e..d3884790 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -12,6 +12,7 @@ import { IconModel, IconView } from './widgets/icon'; import { IpylabModel } from './widgets/ipylab'; import { NotificationManagerModel } from './widgets/notification'; import { PanelModel, PanelView } from './widgets/panel'; +import { ResizeBoxModel, ResizeBoxView } from './widgets/resize_box'; import { SessionManagerModel } from './widgets/sessions'; import { ShellModel } from './widgets/shell'; import { SimpleOutputModel, SimpleOutputView } from './widgets/simple_output'; @@ -33,6 +34,8 @@ export { NotificationManagerModel, PanelModel, PanelView, + ResizeBoxModel, + ResizeBoxView, SessionManagerModel, ShellConnectionModel, ShellModel, diff --git a/src/widgets/resize_box.ts b/src/widgets/resize_box.ts new file mode 100644 index 00000000..f3663f98 --- /dev/null +++ b/src/widgets/resize_box.ts @@ -0,0 +1,65 @@ +// Copyright (c) ipylab contributors +// Distributed under the terms of the Modified BSD License. + +import { BoxModel, BoxView } from '@jupyter-widgets/controls'; +import { MODULE_NAME, MODULE_VERSION } from '../version'; + +/** + * The model for a Resizeable box. + */ +export class ResizeBoxModel extends BoxModel { + /** + * The default attributes. + */ + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _model_name: 'ResizeBoxModel', + _model_module: MODULE_NAME, + _model_module_version: MODULE_VERSION, + _view_name: 'ResizeBoxView', + _view_module: MODULE_NAME, + _view_module_version: MODULE_VERSION + }; + } +} + +/** + * The view for a Resizeable box. + */ +export class ResizeBoxView extends BoxView { + initialize(parameters: any): void { + super.initialize(parameters); + if (Object.keys(this.model.views).length === 1) { + this.makeObserver(); + } + } + + makeObserver() { + if (this.sizeObserver) { + return; + } + this.sizeObserver = new ResizeObserver(() => { + this.model.set('width', this.el.clientWidth); + this.model.set('height', this.el.clientHeight); + this.model.save_changes(); + }); + this.sizeObserver.observe(this.el); + this.luminoWidget.removeClass('widget-box'); + this.luminoWidget.removeClass('jupyter-widgets'); + this.luminoWidget.addClass('ipylab-ResizeBox'); + } + + + remove(): any { + this.sizeObserver.disconnect(); + super.remove(); + for (const k in this.model.views) { + this.model.views[k].then(view => (view as ResizeBoxView).makeObserver()); + break; + } + } + + sizeObserver: ResizeObserver; + model: ResizeBoxModel; +} diff --git a/style/widget.css b/style/widget.css index 00832162..a927957e 100644 --- a/style/widget.css +++ b/style/widget.css @@ -36,3 +36,10 @@ /* overflow-x: auto; overflow-y: auto; */ } + +.ipylab-ResizeBox { + box-sizing: border-box; + display: flex block; + resize: both; + overflow: hidden; +} From ca7b13344eeb538024e5305db393cdfeb7a70b03 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 4 Mar 2025 10:16:34 +1100 Subject: [PATCH 13/19] Add ResizeBox example notebook and update related files --- examples/css_stylesheet.ipynb | 9 +- examples/resize_box.ipynb | 202 ++++++++++++++++++++++++++++++++++ examples/widgets.ipynb | 58 ---------- ipylab/widgets.py | 5 +- src/widgets/resize_box.ts | 33 ++++-- style/widget.css | 4 +- 6 files changed, 239 insertions(+), 72 deletions(-) create mode 100644 examples/resize_box.ipynb diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb index ad5cd55c..df732f29 100644 --- a/examples/css_stylesheet.ipynb +++ b/examples/css_stylesheet.ipynb @@ -60,6 +60,13 @@ "\"\"\") # Define the stylesheet" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Resize example" + ] + }, { "cell_type": "code", "execution_count": null, @@ -254,7 +261,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb new file mode 100644 index 00000000..c742e9e1 --- /dev/null +++ b/examples/resize_box.ipynb @@ -0,0 +1,202 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ResizeBox\n", + "\n", + "A resize box is a Box with the css style [`resize: 'both'`](https://developer.mozilla.org/en-US/docs/Web/CSS/resize) on the first view. A size observer in the frontend synchronises the `size` trait of the box (clientHeight, clientWidth) to the size of the box. Additional views do not have the resize handle, but will synchronise the size to the first view. \n", + "\n", + "Should the first view close, the next available view will be made resizeable.\n", + "\n", + "A resize box can be used to wrap an element that is otherwise not dynamically resizable, for example: the [Matplotlib ipympl widget](https://github.com/matplotlib/ipympl).\n", + "\n", + "Tip: If you simply just want to make a widget resizeable see the [resize css example](css_stylesheet.ipynb#Resize-example)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as ipw\n", + "\n", + "import ipylab.widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "button = ipw.Button()\n", + "label = ipw.HTML(\"Test\")\n", + "resize_box = ipylab.widgets.ResizeBox([label], layout={\"border\": \"solid 1px black\"})\n", + "\n", + "\n", + "def observe(_):\n", + " label.value = f\"Size: {resize_box.size}px\"\n", + "\n", + "\n", + "resize_box.observe(observe, names=(\"size\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ipw.VBox([resize_box, resize_box], layout={\"height\": \"50px\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Matplotlib example\n", + "\n", + "`ipympl` provides a resizeable figure, but it isn't dynamically resizeable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q ipympl numpy" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Restart jupyterlab if you installed ipympl**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib as mpl\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "mpl.use(\"module://ipympl.backend_nbagg\")\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", + "y = np.sin(x)\n", + "\n", + "fig, ax = plt.subplots()\n", + "ax.plot(x, y)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Make the plot resize dynamically\n", + "\n", + "We can dynamically update the size of the plot by using `ResizeBox`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipylab\n", + "from ipylab.widgets import ResizeBox\n", + "\n", + "box = ResizeBox([fig.canvas])\n", + "fig.canvas.resizable = False\n", + "\n", + "\n", + "def _observe_resizebox_dimensions(change):\n", + " box: ResizeBox = change[\"owner\"] # type: ignore\n", + " canvas = box.children[0] # type: ignore\n", + " width, height = box.size\n", + " dpi = canvas.figure.dpi\n", + " fig.set_size_inches(max((width) // dpi, 1), max((height) // dpi, 1))\n", + " fig.canvas.draw_idle()\n", + "\n", + "\n", + "box.observe(_observe_resizebox_dimensions, names=(\"size\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets add the box to the shell.\n", + "\n", + "Try the following:\n", + "- Resize the browser and watch the figure update.\n", + "- Use the resize handle to resize the plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ipylab.app.shell.add(box)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index eed31133..1161c99a 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -484,64 +484,6 @@ "source": [ "split_panel.connections" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ResizeBox\n", - "\n", - "A resize box is a plain box with ['resize' property](https://developer.mozilla.org/en-US/docs/Web/CSS/resize) set to `both`. A size observer reports back the dimensions of the first view as it changes.\n", - "\n", - "Should the first view close, the next available view will be made resizeable." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import ipywidgets as ipw\n", - "\n", - "import ipylab.widgets" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "button = ipw.Button()\n", - "label = ipw.HTML(\"Test\")\n", - "resize_box = ipylab.widgets.ResizeBox([label], layout={\"border\": \"solid 1px black\"})\n", - "\n", - "\n", - "def observe(_):\n", - " label.value = f\"Size: {resize_box.width}px, {resize_box.height}px\"\n", - "\n", - "\n", - "resize_box.observe(observe, names=(\"width\", \"height\"))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "resize_box" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "resize_box" - ] } ], "metadata": { diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 28e185fa..4dc0f470 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -8,7 +8,7 @@ from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict -from traitlets import Container, Dict, Instance, Int, Unicode, observe +from traitlets import Container, Dict, Instance, Tuple, Unicode, observe import ipylab import ipylab._frontend as _fe @@ -124,5 +124,4 @@ class ResizeBox(Box): _view_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) - width = Int(readonly=True, help="clientWidth in pixels").tag(sync=True) - height = Int(readonly=True, help="clientHeight in pixels").tag(sync=True) + size: Container[tuple[int, int]] = Tuple(readonly=True, help="(clientWidth,clientHeight) in pixels").tag(sync=True) diff --git a/src/widgets/resize_box.ts b/src/widgets/resize_box.ts index f3663f98..37451bba 100644 --- a/src/widgets/resize_box.ts +++ b/src/widgets/resize_box.ts @@ -22,6 +22,7 @@ export class ResizeBoxModel extends BoxModel { _view_module_version: MODULE_VERSION }; } + primaryView: ResizeBoxView | null; } /** @@ -30,8 +31,11 @@ export class ResizeBoxModel extends BoxModel { export class ResizeBoxView extends BoxView { initialize(parameters: any): void { super.initialize(parameters); - if (Object.keys(this.model.views).length === 1) { + if (!this.model.primaryView) { this.makeObserver(); + } else { + this.listenTo(this.model, 'change:size', this.updateSize); + requestAnimationFrame(() => this.updateSize()); } } @@ -39,24 +43,39 @@ export class ResizeBoxView extends BoxView { if (this.sizeObserver) { return; } + this.model.primaryView = this; this.sizeObserver = new ResizeObserver(() => { - this.model.set('width', this.el.clientWidth); - this.model.set('height', this.el.clientHeight); + const size = [this.el.clientWidth, this.el.clientHeight]; + this.model.set('size', size); this.model.save_changes(); }); this.sizeObserver.observe(this.el); this.luminoWidget.removeClass('widget-box'); this.luminoWidget.removeClass('jupyter-widgets'); this.luminoWidget.addClass('ipylab-ResizeBox'); + this.stopListening(this.model, 'change:size', this.updateSize); } + updateSize() { + const view = this.model.primaryView; + if (view && view !== this) { + this.el.style.width = view.el.style.width; + this.el.style.height = view.el.style.height; + } + } remove(): any { - this.sizeObserver.disconnect(); + this?.sizeObserver?.disconnect(); + this.stopListening(this.model, 'change:size', this.updateSize); super.remove(); - for (const k in this.model.views) { - this.model.views[k].then(view => (view as ResizeBoxView).makeObserver()); - break; + if (this.model.primaryView === this) { + for (const k in this.model.views) { + this.model.views[k].then(view => { + const view_ = view as ResizeBoxView; + view_.makeObserver(); + }); + break; + } } } diff --git a/style/widget.css b/style/widget.css index a927957e..0190e99d 100644 --- a/style/widget.css +++ b/style/widget.css @@ -32,12 +32,10 @@ width: 100%; height: 100%; overflow: auto; - - /* overflow-x: auto; - overflow-y: auto; */ } .ipylab-ResizeBox { + flex: 0 0 auto; box-sizing: border-box; display: flex block; resize: both; From a14d2720b2b79f1619a9476ce6534119c48e96c4 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 4 Mar 2025 13:03:15 +1100 Subject: [PATCH 14/19] Refine ResizeBox documentation and improve resizing behavior in widget --- examples/resize_box.ipynb | 8 ++-- src/widgets/resize_box.ts | 79 +++++++++++++++++++++------------------ style/widget.css | 3 +- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index c742e9e1..afb80186 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -13,13 +13,11 @@ "source": [ "# ResizeBox\n", "\n", - "A resize box is a Box with the css style [`resize: 'both'`](https://developer.mozilla.org/en-US/docs/Web/CSS/resize) on the first view. A size observer in the frontend synchronises the `size` trait of the box (clientHeight, clientWidth) to the size of the box. Additional views do not have the resize handle, but will synchronise the size to the first view. \n", - "\n", - "Should the first view close, the next available view will be made resizeable.\n", + "A resize box is a Box with the css style [`resize: 'both'`](https://developer.mozilla.org/en-US/docs/Web/CSS/resize). A size observer in the frontend synchronises the `size` trait of the box (clientHeight, clientWidth) to the size of the box. All views are resizeable and will have the same same size.\n", "\n", "A resize box can be used to wrap an element that is otherwise not dynamically resizable, for example: the [Matplotlib ipympl widget](https://github.com/matplotlib/ipympl).\n", "\n", - "Tip: If you simply just want to make a widget resizeable see the [resize css example](css_stylesheet.ipynb#Resize-example)." + "Tip: If you want to make a widget resizeable without synchronised views add a class with resize set instead [resize css example](css_stylesheet.ipynb#Resize-example)." ] }, { @@ -75,7 +73,7 @@ "metadata": {}, "outputs": [], "source": [ - "ipw.VBox([resize_box, resize_box], layout={\"height\": \"50px\"})" + "ipw.VBox([resize_box, resize_box])" ] }, { diff --git a/src/widgets/resize_box.ts b/src/widgets/resize_box.ts index 37451bba..6a6a6e21 100644 --- a/src/widgets/resize_box.ts +++ b/src/widgets/resize_box.ts @@ -22,7 +22,7 @@ export class ResizeBoxModel extends BoxModel { _view_module_version: MODULE_VERSION }; } - primaryView: ResizeBoxView | null; + _resizing = false; } /** @@ -31,54 +31,61 @@ export class ResizeBoxModel extends BoxModel { export class ResizeBoxView extends BoxView { initialize(parameters: any): void { super.initialize(parameters); - if (!this.model.primaryView) { - this.makeObserver(); - } else { - this.listenTo(this.model, 'change:size', this.updateSize); - requestAnimationFrame(() => this.updateSize()); - } - } - - makeObserver() { - if (this.sizeObserver) { - return; - } - this.model.primaryView = this; - this.sizeObserver = new ResizeObserver(() => { - const size = [this.el.clientWidth, this.el.clientHeight]; - this.model.set('size', size); - this.model.save_changes(); - }); - this.sizeObserver.observe(this.el); this.luminoWidget.removeClass('widget-box'); this.luminoWidget.removeClass('jupyter-widgets'); this.luminoWidget.addClass('ipylab-ResizeBox'); - this.stopListening(this.model, 'change:size', this.updateSize); + this.resize(); + this.sizeObserver = new ResizeObserver(() => { + if (!this.model._resizing && !this._resizing) { + const clientWidth = this.el.clientWidth; + const clientHeight = this.el.clientHeight; + const width = this.el.style.width; + const height = this.el.style.height; + try { + this.model._resizing = true; + this._resizing = true; + if (clientWidth && clientHeight) { + this.model.set('size', [clientWidth, clientHeight]); + } + if (width && height) { + this.model.set('width_height', [width, height]); + } + if ((width && height) || (clientWidth && clientHeight)) { + this.model.save_changes(); + } + } finally { + this._resizing = false; + this.model._resizing = false; + } + if (!width && !height) { + this.resize(); + } + } + }); + this.sizeObserver.observe(this.el); + this.listenTo(this.model, 'change:width_height', this.resize); } - updateSize() { - const view = this.model.primaryView; - if (view && view !== this) { - this.el.style.width = view.el.style.width; - this.el.style.height = view.el.style.height; + resize() { + if (this._resizing) { + return; + } + const [width, height] = this.model.get('width_height') ?? [null, null]; + if (width && height) { + this._resizing = true; + this.el.style.width = width; + this.el.style.height = height; + this._resizing = false; } } remove(): any { this?.sizeObserver?.disconnect(); - this.stopListening(this.model, 'change:size', this.updateSize); + this.stopListening(this.model, 'change:size', this.resize); super.remove(); - if (this.model.primaryView === this) { - for (const k in this.model.views) { - this.model.views[k].then(view => { - const view_ = view as ResizeBoxView; - view_.makeObserver(); - }); - break; - } - } } sizeObserver: ResizeObserver; model: ResizeBoxModel; + _resizing = false; } diff --git a/style/widget.css b/style/widget.css index 0190e99d..478668ef 100644 --- a/style/widget.css +++ b/style/widget.css @@ -35,9 +35,8 @@ } .ipylab-ResizeBox { - flex: 0 0 auto; box-sizing: border-box; - display: flex block; + display: inline block; resize: both; overflow: hidden; } From 8b6cb360cf33f3a30ba46a31db6578490b4feeb4 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 5 Mar 2025 08:34:19 +1100 Subject: [PATCH 15/19] Fix SimpleOutput not rendering widgets. --- examples/simple_output.ipynb | 70 +++++++++++++++++++++++------------- ipylab/simple_output.py | 4 +-- ipylab/widgets.py | 16 +++++++-- src/widgets/simple_output.ts | 5 +++ style/widget.css | 5 +++ 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb index ff7bf354..db4d1964 100644 --- a/examples/simple_output.ipynb +++ b/examples/simple_output.ipynb @@ -111,7 +111,9 @@ "id": "10", "metadata": {}, "source": [ - "### Other formats are also supported" + "### Other formats are also supported\n", + "\n", + "#### Ipython" ] }, { @@ -130,6 +132,26 @@ "cell_type": "markdown", "id": "12", "metadata": {}, + "source": [ + "#### Ipywidgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as ipw\n", + "\n", + "SimpleOutput().push(ipw.Button(description=\"ipywidgets button\"))" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, "source": [ "### set\n", "\n", @@ -139,7 +161,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -150,7 +172,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -160,7 +182,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -170,7 +192,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "18", "metadata": {}, "source": [ "## max_continuous_streams and max_outputs\n", @@ -185,7 +207,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -196,7 +218,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -206,7 +228,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -216,7 +238,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "22", "metadata": {}, "source": [ "`max_outputs` limits the total number of outputs." @@ -225,7 +247,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -236,7 +258,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -246,7 +268,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "25", "metadata": {}, "source": [ "# AutoScroll\n", @@ -260,7 +282,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "26", "metadata": {}, "source": [ "## Ipylab log viewer\n", @@ -271,7 +293,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -283,7 +305,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -299,7 +321,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -308,7 +330,7 @@ }, { "cell_type": "markdown", - "id": "28", + "id": "30", "metadata": {}, "source": [ "## Example usage" @@ -317,7 +339,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -332,7 +354,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -392,7 +414,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "33", "metadata": {}, "source": [ "# Basic console example\n", @@ -415,7 +437,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -480,7 +502,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -491,7 +513,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -516,7 +538,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py index ff6e2bd2..49a69da0 100644 --- a/ipylab/simple_output.py +++ b/ipylab/simple_output.py @@ -57,11 +57,11 @@ def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str | TextDispl yield output elif isinstance(output, str): yield {"output_type": "stream", "name": "stdout", "text": output} - elif hasattr(output, "_repr_mimebundle_") and callable(output._repr_mimebundle_): # type: ignore - yield output._repr_mimebundle_() # type: ignore elif fmt: data, metadata = fmt(output) yield {"output_type": "display_data", "data": data, "metadata": metadata} + elif hasattr(output, "_repr_mimebundle_") and callable(output._repr_mimebundle_): # type: ignore + yield {"output_type": "display_data", "data": output._repr_mimebundle_()} # type: ignore else: yield {"output_type": "display_data", "data": repr(output)} diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 4dc0f470..e61b87db 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -115,7 +115,19 @@ async def force_refresh(children): @register class ResizeBox(Box): - "A box that is aware of its size. Only the first view is resizeable." + """A box that can be resized. + + All views of the box are resizeable via the handle on the bottom right corner. + When a view is resized the other views are also resized to the same width and height. + The `size` trait of this object provides the size in pixels as (client width, client height). + + Reference + --------- + * [width](https://developer.mozilla.org/en-US/docs/Web/CSS/width) + * [height](https://developer.mozilla.org/en-US/docs/Web/CSS/height) + * [client width](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth) + * [client height](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight) + """ _model_name = Unicode("ResizeBoxModel").tag(sync=True) _view_name = Unicode("ResizeBoxView").tag(sync=True) @@ -124,4 +136,4 @@ class ResizeBox(Box): _view_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) - size: Container[tuple[int, int]] = Tuple(readonly=True, help="(clientWidth,clientHeight) in pixels").tag(sync=True) + size: Container[tuple[int, int]] = Tuple(readonly=True, help="(clientWidth, clientHeight) in pixels").tag(sync=True) diff --git a/src/widgets/simple_output.ts b/src/widgets/simple_output.ts index aca75a38..a2c246ac 100644 --- a/src/widgets/simple_output.ts +++ b/src/widgets/simple_output.ts @@ -130,6 +130,11 @@ export class SimpleOutputModel extends IpylabModel { } export class SimpleOutputView extends DOMWidgetView { + initialize(parameters: any): void { + super.initialize(parameters); + this.luminoWidget.removeClass('jupyter-widgets'); + this.luminoWidget.addClass('ipylab-SimpleOutput'); + } _createElement(tagName: string): HTMLElement { this.luminoWidget = new IpylabSimplifiedOutputArea({ view: this, diff --git a/style/widget.css b/style/widget.css index 478668ef..02b976fc 100644 --- a/style/widget.css +++ b/style/widget.css @@ -34,6 +34,11 @@ overflow: auto; } +.ipylab-SimpleOutput { + box-sizing: border-box; + display: inline block; + overflow: auto; +} .ipylab-ResizeBox { box-sizing: border-box; display: inline block; From 5664a64d615f89a761d833fa60b88d3d26e391d6 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Fri, 7 Mar 2025 14:27:40 +1100 Subject: [PATCH 16/19] Tweak show_dialog help info --- ipylab/dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ipylab/dialog.py b/ipylab/dialog.py index b05ef98b..40df763b 100644 --- a/ipylab/dialog.py +++ b/ipylab/dialog.py @@ -98,9 +98,9 @@ def show_dialog( "iconLabel": "", "caption": "Accept the result", "className": "", - "accept": False, + "accept": True, "actions": [], - "displayType": "warn", + "displayType": "default", }, { "ariaLabel": "Cancel", @@ -111,7 +111,7 @@ def show_dialog( "className": "", "accept": False, "actions": [], - "displayType": "default", + "displayType": "warn", }, ], ], From c32f91328ecd9a5af47b8465a10b626477c108f9 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 8 Mar 2025 09:54:29 +1100 Subject: [PATCH 17/19] Remove unused variable in _wrap_awaitable method --- ipylab/ipylab.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index c8685405..bf14325d 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -198,7 +198,6 @@ def _check_closed(self): async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: await self.ready() - result = None try: result = await aw if hooks: From a889d56f413121ac3f0ea46c7a1a12466d80c5fc Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 8 Mar 2025 09:57:07 +1100 Subject: [PATCH 18/19] Improve ResizeBox documentation --- examples/resize_box.ipynb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index afb80186..a06bce1a 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -13,11 +13,13 @@ "source": [ "# ResizeBox\n", "\n", - "A resize box is a Box with the css style [`resize: 'both'`](https://developer.mozilla.org/en-US/docs/Web/CSS/resize). A size observer in the frontend synchronises the `size` trait of the box (clientHeight, clientWidth) to the size of the box. All views are resizeable and will have the same same size.\n", + "The `ResizeBox` is a Box which is resizeable and reports its client size to the `size` trait. \n", "\n", - "A resize box can be used to wrap an element that is otherwise not dynamically resizable, for example: the [Matplotlib ipympl widget](https://github.com/matplotlib/ipympl).\n", + "A resize box is useful for wrapping a widget which is not dynamically resizable, for example: the [Matplotlib ipympl widget](https://github.com/matplotlib/ipympl).\n", "\n", - "Tip: If you want to make a widget resizeable without synchronised views add a class with resize set instead [resize css example](css_stylesheet.ipynb#Resize-example)." + "All views of the resize box are resizeable and have the same size.\n", + "\n", + "Tip: Only use a `ResizeBox` if enabling the resize style ([resize css example](css_stylesheet.ipynb#Resize-example))) doesn't work, or if you want all views to be the same size." ] }, { From 3eca1648ef498b1edb725955fc4d043d29d84d43 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 8 Mar 2025 09:58:16 +1100 Subject: [PATCH 19/19] Update pre-commit dependencies to latest versions --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13816634..ed4fc5d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: hooks: - id: check-json5 - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.31.1 + rev: 0.31.3 hooks: - id: check-github-workflows - repo: https://github.com/ComPWA/taplo-pre-commit @@ -38,7 +38,7 @@ repos: hooks: - id: taplo-format - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.10 hooks: - id: ruff types_or: [python, jupyter]