From 312b3380fe630c30a17e675c04bf1ee3f79e3087 Mon Sep 17 00:00:00 2001
From: Alan Fleming <>
Date: Fri, 22 Nov 2024 15:46:25 +1100
Subject: [PATCH] Switch back from DocumentWidget to MainAreaWidget. Added
AutoScroll, SimpleOutput and logging.
---
examples/simple_output.ipynb | 328 +++++++++++++++++++++++++++++++++
ipylab/common.py | 10 +-
ipylab/hookspecs.py | 7 +
ipylab/ipylab.py | 70 +++----
ipylab/jupyterfrontend.py | 46 +++--
ipylab/lib.py | 11 +-
ipylab/log.py | 343 +++++++++--------------------------
ipylab/log_viewer.py | 173 ++++++++++++++++++
ipylab/shell.py | 14 +-
ipylab/simple_output.py | 117 ++++++++++++
package.json | 1 -
src/plugin.ts | 10 +-
src/widget.ts | 8 +-
src/widgets/autoscroll.ts | 177 ++++++++++++++++++
src/widgets/frontend.ts | 124 ++++---------
src/widgets/ipylab.ts | 19 +-
src/widgets/shell.ts | 14 +-
src/widgets/simple_output.ts | 195 ++++++++++++++++++++
style/widget.css | 2 +-
yarn.lock | 3 +-
20 files changed, 1200 insertions(+), 472 deletions(-)
create mode 100644 examples/simple_output.ipynb
create mode 100644 ipylab/log_viewer.py
create mode 100644 ipylab/simple_output.py
create mode 100644 src/widgets/autoscroll.ts
create mode 100644 src/widgets/simple_output.ts
diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb
new file mode 100644
index 0000000..8164844
--- /dev/null
+++ b/examples/simple_output.ipynb
@@ -0,0 +1,328 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "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",
+ "id": "1",
+ "metadata": {
+ "jp-MarkdownHeadingCollapsed": true
+ },
+ "source": [
+ "## Simple Output\n",
+ "\n",
+ "SimpleOutput is a widget that provides an output area to display all types of output. \n",
+ "\n",
+ "It is designed to minimise the size of messages and/or number of messages sent to the frontend. It is not supposed to be a drop in replacement for the Ipywidget `Output' widget, rather it provides an alternate type of interface.\n",
+ "\n",
+ "Compared to the Ipywidgets `Output` maintains a synchronised model of all loaded outputs. Each item added to `SimpleOutput` is serialized and sent to the frontend. There is no representation of the data left on the Python side meaning that `SimpleOutput` is more suitable for logging applications. "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## Methods\n",
+ "\n",
+ "There are two methods to add outputs \n",
+ "1. `push`\n",
+ "2. `set`\n",
+ "\n",
+ "and one '`clear`' to clear the outputs.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "### `push`\n",
+ "\n",
+ "`push` serializes and sends data as a custom message which is appended to the existing output."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import ipylab\n",
+ "from ipylab.simple_output import SimpleOutput"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "so = SimpleOutput(layout={\"max_height\": \"200px\"})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for i in range(50):\n",
+ " so.push(f\"test {i}\\n\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "so"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# or we could do it with one message\n",
+ "so.clear()\n",
+ "so.push(*(f\"test {i}\\n\" for i in range(100)))\n",
+ "# Not that we've just sent 100 'outputs' in one message.\n",
+ "# All of the 100 lines are squashed into the 200px high simple output."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "### set\n",
+ "\n",
+ "`Set` is similar to push, but is run as task and clears the output prior at adding the new outputs. The task returns the number of outputs in use."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "t = so.set(\"Line one\\n\", \"Line two\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "11",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "so"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "12",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert so.length == t.result() # noqa: S101\n",
+ "so.length"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "## max_continuous_streams\n",
+ "\n",
+ "Notice that above the length is 1 even though we sent two values? \n",
+ "\n",
+ "This is because both items are streams, and by default they get put into the same output in the frontend. \n",
+ "\n",
+ "The maximum number of consecutive streams is configurable with `max_continuous_streams`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "14",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Make each stream go into a new output.\n",
+ "so.max_continuous_streams = 0"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "t = so.set(\"Line one\\n\", \"Line two\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "16",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert so.length == t.result() # noqa: S101\n",
+ "so.length"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17",
+ "metadata": {},
+ "source": [
+ "# AutoScroll\n",
+ "\n",
+ "AutoScroll is a widget that provides automatic scrolling around a content widget. It is intended to be used in panels placed in the shell, and doesn't work correctly when used in notebooks.\n",
+ "\n",
+ "**Note**\n",
+ "\n",
+ "Autoscroll uses a relatively new feature `onscrollend` ([detail](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event)) and **may not work well on Safari** for fast update rates."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "## Builtin log viewer\n",
+ "\n",
+ "The built in log viewer uses the AutoScroll widget to scroll its output."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "app = ipylab.app\n",
+ "\n",
+ "app.log_viewer.add_to_shell()\n",
+ "app.log_level = \"INFO\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "20",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for _ in range(100):\n",
+ " app.log.info(\"Demo\")\n",
+ " app.log.error(\"Demo\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "21",
+ "metadata": {},
+ "source": [
+ "## Example usage"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "22",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from datetime import datetime\n",
+ "\n",
+ "import ipywidgets as ipw\n",
+ "\n",
+ "import ipylab\n",
+ "from ipylab.simple_output import AutoScroll"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "23",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "vb = ipw.VBox()\n",
+ "sw = AutoScroll(content=vb)\n",
+ "sw_holder = ipw.VBox([sw], layout={\"height\": \"200px\", \"border\": \"solid\"})\n",
+ "\n",
+ "enabled = ipw.Checkbox(description=\"Auto scroll\", indent=False)\n",
+ "ipw.link((sw, \"enabled\"), (enabled, \"value\"))\n",
+ "sleep = ipw.FloatSlider(description=\"Sleep time (s)\", value=0.3, min=0.05, max=1, step=0.01)\n",
+ "\n",
+ "b = ipw.Button(description=\"Start\")\n",
+ "\n",
+ "\n",
+ "def on_click(b):\n",
+ " if b.description == \"Start\":\n",
+ " import asyncio\n",
+ "\n",
+ " async def generate_output():\n",
+ " while True:\n",
+ " vb.children = (*vb.children, ipw.HTML(f\"It is now {datetime.now().isoformat()}\")) # noqa: DTZ005\n",
+ " await asyncio.sleep(sleep.value)\n",
+ "\n",
+ " b.task = ipylab.app.to_task(generate_output())\n",
+ " b.description = \"Stop\"\n",
+ " else:\n",
+ " b.task.cancel()\n",
+ " b.description = \"Start\"\n",
+ "\n",
+ "\n",
+ "b.on_click(on_click)\n",
+ "\n",
+ "p = ipylab.Panel([ipw.HBox([enabled, sleep, b], layout={\"justify_content\": \"center\"}), sw_holder])\n",
+ "p.add_to_shell(mode=ipylab.InsertMode.split_right)"
+ ]
+ }
+ ],
+ "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.11.10"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/ipylab/common.py b/ipylab/common.py
index 284cb5f..32b8e5b 100644
--- a/ipylab/common.py
+++ b/ipylab/common.py
@@ -19,6 +19,8 @@
hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins
+SVGSTR_TEST_TUBE = ''
+
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from typing import TypeVar, overload
@@ -240,11 +242,3 @@ def trait_tuple_add(owner: HasTraits, name: str, value: Any):
items = getattr(owner, name)
if value not in items:
owner.set_trait(name, (*items, value))
-
-
-def truncated_repr(obj: Any, maxlen=40, tail="…") -> str:
- "Do truncated string representation of obj."
- rep = repr(obj)
- if len(rep) > maxlen:
- return rep[0 : maxlen - len(tail)] + tail
- return rep
diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py
index e96da2d..d0effb4 100644
--- a/ipylab/hookspecs.py
+++ b/ipylab/hookspecs.py
@@ -14,6 +14,8 @@
import ipylab
from ipylab.common import IpylabKwgs
+ from ipylab.log import IpylabLogHandler
+ from ipylab.log_viewer import LogViewer
@hookspec(firstresult=True)
@@ -89,3 +91,8 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type:
vpath is the 'virtual path' for a session.
"""
+
+
+@hookspec(firstresult=True)
+def get_log_viewer(app: ipylab.App, handler: IpylabLogHandler) -> LogViewer: # type: ignore
+ """Create a new instance of a logViewer."""
diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py
index 22dbfd7..98a57ef 100644
--- a/ipylab/ipylab.py
+++ b/ipylab/ipylab.py
@@ -11,30 +11,18 @@
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from ipywidgets import Widget, register
-from traitlets import Bool, Container, Dict, HasTraits, Set, TraitError, TraitType, Unicode, default, observe
+from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe
import ipylab
import ipylab._frontend as _fe
-from ipylab.common import (
- IpylabKwgs,
- Obj,
- TaskHooks,
- TaskHookType,
- Transform,
- TransformType,
- pack,
- trait_tuple_add,
- truncated_repr,
-)
-from ipylab.log import LogLevel
+from ipylab.common import IpylabKwgs, Obj, TaskHooks, TaskHookType, Transform, TransformType, pack, trait_tuple_add
+from ipylab.log import IpylabLoggerAdapter
if TYPE_CHECKING:
from asyncio import Task
from collections.abc import Awaitable, Callable, Hashable
from typing import ClassVar, Self, Unpack
- from ipylab.log import LogPayloadType
-
__all__ = ["Ipylab", "WidgetBase", "Readonly"]
@@ -147,6 +135,7 @@ class Ipylab(WidgetBase):
_tasks: Container[set[asyncio.Task]] = Set()
_has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set()
close_extras: Readonly[weakref.WeakSet[Widget]] = Readonly(weakref.WeakSet)
+ log = Instance(IpylabLoggerAdapter, read_only=True)
@classmethod
def _single_key(cls, kwgs: dict) -> Hashable: # noqa: ARG003
@@ -160,7 +149,7 @@ def repr_info(self) -> dict[str, Any] | str:
@default("log")
def _default_log(self):
- return ipylab.app.log
+ return IpylabLoggerAdapter("ipylab", owner=self)
def __new__(cls, **kwgs) -> Self:
model_id = kwgs.get("model_id") or cls._single_map.get(cls._single_key(kwgs)) if cls.SINGLE else None
@@ -246,15 +235,12 @@ async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T:
result = await aw
try:
self._task_result(result, hooks)
- except Exception as e:
- obj = {"result": result, "hooks": hooks, "aw": aw}
- self.log_object(LogLevel.error, "TaskHook error", error=e, obj=obj)
- raise e from None
- except Exception as e:
- try:
- self.log_object(LogLevel.error, "Task error", error=e, obj=aw)
- finally:
- raise e
+ except Exception:
+ self.log.exception("TaskHook error", obj={"result": result, "hooks": hooks, "aw": aw})
+ raise
+ except Exception:
+ self.log.exception("Task error", obj=aw)
+ raise
else:
return result
@@ -323,14 +309,14 @@ def _on_custom_msg(self, _, msg: str, buffers: list):
self.close()
else:
raise NotImplementedError(msg) # noqa: TRY301
- except Exception as e:
- self.log_object(LogLevel.error, "Message processing error {obj}", error=e, obj=msg)
+ except Exception:
+ self.log.exception("Message processing error", obj=msg)
def _to_frontend_error(self, content):
error = content["error"]
operation = content.get("operation")
if operation:
- msg = f'{truncated_repr(self, 40)} operation "{operation}" failed with the message "{error}"'
+ msg = f'Operation "{operation}" failed with the message "{error}"'
return IpylabFrontendError(msg)
return IpylabFrontendError(error)
@@ -346,9 +332,8 @@ async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: di
content["payload"] = result
except asyncio.CancelledError:
content["error"] = "Cancelled"
- except Exception as e:
- content["error"] = str(e)
- self.log_object(LogLevel.error, "Operation for frontend error", error=e)
+ except Exception:
+ self.log.exception("Operation for frontend error", obj={"operation": operation, "payload": payload})
finally:
self.send(content, buffers)
@@ -378,8 +363,8 @@ def ensure_run(self, aw: Callable | Awaitable | None) -> None:
aw = aw()
if inspect.iscoroutine(aw):
self.to_task(aw, f"Ensure run {aw}")
- except Exception as e:
- self.log_object(LogLevel.error, "Ensure run", error=e)
+ except Exception:
+ self.log.exception("Ensure run", obj=aw)
raise
async def ready(self):
@@ -394,16 +379,6 @@ def on_ready(self, callback, remove=False): # noqa: FBT002
else:
self._on_ready_callbacks.add(callback)
- def log_object(self, level: LogLevel, msg: str = "", *, error: BaseException | None = None, obj: Any = None):
- "Pass a message to have it logged mapped to obj."
- match LogLevel(level):
- case LogLevel.error:
- self.log.exception(msg, extra={"owner": self, "obj": obj}, exc_info=error)
- case LogLevel.critical:
- self.log.exception(msg, extra={"owner": self, "obj": obj}, exc_info=error)
- case _:
- getattr(self.log, level)(msg, extra={"owner": self, "obj": obj})
-
def add_to_tuple(self, owner: HasTraits, name: str):
"""Add self to the tuple of obj."""
@@ -423,12 +398,9 @@ def add_as_trait(self, obj: HasTraits, name: str):
def send(self, content, buffers=None):
try:
super().send(json.dumps(content, default=pack), buffers)
- except Exception as e:
- self.log_object(LogLevel.error, "Send error", error=e)
- raise e from None
-
- def send_log_message(self, log: LogPayloadType):
- self.send({"log": log})
+ except Exception:
+ self.log.exception("Send error", obj=content)
+ raise
def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]:
"""Run aw in a task.
diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py
index 9c6b7d0..113d1b1 100644
--- a/ipylab/jupyterfrontend.py
+++ b/ipylab/jupyterfrontend.py
@@ -5,7 +5,6 @@
import functools
import inspect
-import logging
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Unpack
@@ -16,7 +15,7 @@
import ipylab
import ipylab.hookspecs
-from ipylab import Ipylab, ShellConnection, Transform
+from ipylab import Ipylab, ShellConnection, Transform, log
from ipylab._compat.typing import override
from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry
from ipylab.common import InsertMode, IpylabKwgs, Obj, to_selector
@@ -28,11 +27,14 @@
from ipylab.notification import NotificationManager
from ipylab.sessions import SessionManager
from ipylab.shell import Shell
+from ipylab.widgets import Panel
if TYPE_CHECKING:
from asyncio import Task
from typing import ClassVar
+ from ipylab.log_viewer import LogViewer
+
class LastUpdatedDict(OrderedDict):
"Store items in the order the keys were last added"
@@ -53,7 +55,6 @@ 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)
- logger_level = UseEnum(LogLevel, read_only=True, default_value=LogLevel.warning).tag(sync=True)
vpath = Unicode(read_only=True).tag(sync=True)
per_kernel_widget_manager_detected = Bool(read_only=True).tag(sync=True)
@@ -69,9 +70,10 @@ class App(Ipylab):
console = Instance(ShellConnection, allow_none=True, read_only=True)
logging_handler = Instance(IpylabLogHandler, read_only=True)
+ log_viewer: Instance[LogViewer] = Instance(Panel, Readonly=True) # type: ignore
+ log_level = UseEnum(LogLevel, LogLevel.ERROR)
active_namespace = Unicode("", read_only=True, help="name of the current namespace")
- selector = Unicode("", read_only=True, help="Selector class for context menus (css)")
namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore
@@ -86,27 +88,27 @@ def _single_key(cls, kwgs: dict): # noqa: ARG003
def close(self):
"Cannot close"
- @default("log")
- def _default_log(self):
- log = logging.getLogger("ipylab")
- self.logging_handler.set_as_handler(log)
- return log
-
@default("logging_handler")
def _default_logging_handler(self):
- handler = IpylabLogHandler()
- fmt = "{owner} {obj} {message}"
- handler.setFormatter(IpylabLogFormatter(fmt, style="{"))
+ fmt = "{color}{asctime}.{msecs:0<3.0f} {name} {owner_rep}:{reset} {message}\n"
+ handler = IpylabLogHandler(self.log_level)
+ handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="{", datefmt="%H:%M:%S", colors=log.COLORS))
return handler
- @observe("_ready")
- def _app_observe_ready(self, _):
- if self._ready:
- self.set_trait("selector", to_selector(self.vpath))
+ @default("log_viewer")
+ def _default_log_viewer(self):
+ return ipylab.plugin_manager.hook.get_log_viewer(app=self, handler=self.logging_handler)
+
+ @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)
ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001
ipylab.plugin_manager.hook.autostart.call_historic(
kwargs={"app": self}, result_callback=self._autostart_callback
)
+ self.logging_handler.setLevel(self.log_level)
def _autostart_callback(self, result):
self.ensure_run(result)
@@ -115,6 +117,16 @@ def _autostart_callback(self, result):
def repr_info(self):
return {"vpath": self.vpath}
+ @property
+ 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.
+ return self._selector
+
@override
async def ready(self):
if not self._ready_event._value: # type: ignore # noqa: SLF001
diff --git a/ipylab/lib.py b/ipylab/lib.py
index 35b832e..3b7176d 100644
--- a/ipylab/lib.py
+++ b/ipylab/lib.py
@@ -13,6 +13,7 @@
import ipylab
from ipylab import App
from ipylab.ipylab import Ipylab
+ from ipylab.log import IpylabLogHandler
@hookimpl
@@ -31,7 +32,8 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]:
# Register some default context menu items for Ipylab
cmd = await app.commands.add_command("Open console", app._context_open_console) # noqa: SLF001
await app.context_menu.add_item(command=cmd, rank=20)
- await app.context_menu.add_item(command="logconsole:open", args={"source": app.vpath}, rank=21)
+ cmd = await app.commands.add_command("Show log viewer", lambda: app.log_viewer.add_to_shell())
+ await app.context_menu.add_item(command=cmd, rank=21)
@hookimpl
@@ -47,3 +49,10 @@ def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str:
@hookimpl
def ready(obj: Ipylab):
"Pass through"
+
+
+@hookimpl
+def get_log_viewer(app: App, handler: IpylabLogHandler): # type: ignore
+ from ipylab.log_viewer import LogViewer
+
+ return LogViewer(app, handler)
diff --git a/ipylab/log.py b/ipylab/log.py
index 6357b58..1382e25 100644
--- a/ipylab/log.py
+++ b/ipylab/log.py
@@ -3,173 +3,125 @@
from __future__ import annotations
-import collections
import logging
import weakref
-from asyncio import Task
-from datetime import datetime
-from enum import StrEnum
-from typing import TYPE_CHECKING, Any, Literal, TypedDict
+from enum import IntEnum
+from typing import TYPE_CHECKING, Any, ClassVar
-from ipywidgets import HTML, Button, Combobox, HBox, Output, Select, dlink
+from IPython.utils.coloransi import TermColors
+from ipywidgets import CallbackDispatcher
import ipylab
-from ipylab.common import truncated_repr
+from ipylab._compat.typing import override
if TYPE_CHECKING:
- from typing import ClassVar
+ from asyncio import Task
from ipylab.ipylab import Ipylab
-__all__ = [
- "LogLevel",
- "LogTypes",
- "LogPayloadType",
- "LogPayloadText",
- "LogPayloadHtml",
- "LogPayloadOutput",
- "IpylabLogHandler",
- "log_name_mappings",
- "show_obj_log_panel",
-]
+__all__ = ["LogLevel", "IpylabLogHandler"]
-objects = {}
-log_objects = collections.deque(maxlen=1000)
+class LogLevel(IntEnum):
+ CRITICAL = 50
+ ERROR = 40
+ WARNING = 30
+ INFO = 20
+ DEBUG = 10
-class LogLevel(StrEnum):
- "The log levels available in Jupyterlab"
- debug = "debug"
- info = "info"
- warning = "warning"
- error = "error"
- critical = "critical"
-
- @classmethod
- def to_numeric(cls, value: LogLevel | int):
- return log_name_mappings[LogLevel(value)]
-
- @classmethod
- def to_level(cls, val: LogLevel | int) -> LogLevel:
- if isinstance(val, int):
- if val >= log_name_mappings[LogLevel.critical]:
- return LogLevel.critical
- if val >= log_name_mappings[LogLevel.error]:
- return LogLevel.error
- if val >= log_name_mappings[LogLevel.warning]:
- return LogLevel.warning
- if val >= log_name_mappings[LogLevel.info]:
- return LogLevel.info
- return LogLevel.debug
- return LogLevel(val)
-
-
-log_name_mappings = {
- LogLevel.debug: 10,
- LogLevel.info: 20,
- LogLevel.warning: 30,
- LogLevel.error: 40,
- LogLevel.critical: 50,
+grey = "\x1b[38;20m"
+yellow = "\x1b[33;20m"
+ret = "\x1b[31;20m"
+bold_red = "\x1b[31;1m"
+reset = TermColors.Normal
+COLORS = {
+ LogLevel.DEBUG: TermColors.LightGray,
+ LogLevel.INFO: TermColors.Cyan,
+ LogLevel.WARNING: TermColors.LightRed,
+ LogLevel.ERROR: TermColors.Red,
+ LogLevel.CRITICAL: TermColors.Purple,
}
-class LogTypes(StrEnum):
- text = "text"
- html = "html"
- output = "output"
-
-
-class OutputBase(TypedDict):
- output_type: Literal["update_display_data", "error", "stream"]
-
-
-class OutputDisplayData(OutputBase):
- output_type: Literal["update_display_data"]
- data: dict[str, str | dict] # mime-type keyed dictionary of data
-
-
-class OutputStream(OutputBase):
- output_type: Literal["stream"]
- type: Literal["stdout", "stderr"]
- text: str
-
-
-class OutputError(OutputBase):
- output_type: Literal["error"]
- ename: str
- evalue: str
- traceback: list[str] | None
-
-
-OutputTypes = OutputDisplayData | OutputStream | OutputError
-
-
-class LogPayloadBase(TypedDict):
- type: LogTypes
- level: LogLevel | int
- data: Any
-
-
-class LogPayloadText(LogPayloadBase):
- type: Literal[LogTypes.text]
- data: str
-
+def truncated_repr(obj: Any, maxlen=120, tail="…") -> str:
+ "Do truncated string representation of obj."
-class LogPayloadHtml(LogPayloadText):
- type: Literal[LogTypes.html]
+ rep = obj.repr_log if hasattr(obj, "repr_log") else repr(obj)
+ if len(rep) > maxlen:
+ return rep[0 : maxlen - len(tail)] + tail
+ return rep
-class LogPayloadOutput(LogPayloadBase):
- type: Literal[LogTypes.output]
- data: OutputTypes
+class IpylabLoggerAdapter(logging.LoggerAdapter):
+ def __init__(self, name: str, owner: Ipylab) -> None:
+ logger = logging.getLogger(name)
+ logger.addHandler(ipylab.app.logging_handler)
+ ipylab.app.logging_handler._add_logger(logger) # noqa: SLF001
+ super().__init__(logger)
+ self.owner_ref = weakref.ref(owner)
-
-LogPayloadType = LogPayloadBase | LogPayloadText | LogPayloadHtml | LogPayloadOutput
+ def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
+ obj = kwargs.pop("obj", None)
+ kwargs["extra"] = {"owner": self.owner_ref(), "obj": obj}
+ return msg, kwargs
class IpylabLogHandler(logging.Handler):
+ _log_notify_task: Task | None = None
_loggers: ClassVar[weakref.WeakSet[logging.Logger]] = weakref.WeakSet()
- def __init__(self) -> None:
- ipylab.app.observe(self._observe_app_log_level, "logger_level")
- super().__init__(LogLevel.to_numeric(ipylab.app.logger_level))
+ def __init__(self, level: LogLevel) -> None:
+ super().__init__(level)
+ self._callbacks = CallbackDispatcher()
+
+ def _add_logger(self, logger: logging.Logger):
+ if logger not in self._loggers:
+ logger.setLevel(self.level)
+ self._loggers.add(logger)
+ logger.addHandler(self)
- def _observe_app_log_level(self, change: dict):
- level = LogLevel.to_numeric(change["new"])
- self.setLevel(level)
- for log in self._loggers:
- log.setLevel(level)
+ @override
+ def setLevel(self, level: LogLevel) -> None: # noqa: N802
+ level = LogLevel(level)
+ super().setLevel(level)
+ for logger in self._loggers:
+ logger.setLevel(level)
def emit(self, record):
- msg = self.format(record)
- type_ = "stdout" if record.levelno < log_name_mappings[LogLevel.error] else "stderr"
- data = OutputStream(output_type="stream", type=type_, text=msg)
- log = LogPayloadOutput(type=LogTypes.output, level=LogLevel.to_level(record.levelno), data=data)
- ipylab.app.send_log_message(log)
+ std_ = "stderr" if record.levelno >= LogLevel.ERROR else "stdout"
+ record.output = {"output_type": "stream", "name": std_, "text": self.format(record)}
+ if std_ == "stderr":
+ ipylab.app.log_viewer # Touch for a log_viewer # noqa: B018
+ self._callbacks(record)
+
+ def register_callback(self, callback, *, remove=False):
+ """Register a callback for when a record is emitted.
- def set_as_handler(self, log: logging.Logger):
- "Set this handler as a handler for log and keep the level in sync."
- if log not in self._loggers:
- log.addHandler(self)
- log.setLevel(self.level)
- self._loggers.add(log)
+ The callback will be called with one argument, the record.
+
+ Parameters
+ ----------
+ remove: bool (optional)
+ Set to true to remove the callback from the list of callbacks.
+ """
+ self._callbacks.register_callback(callback, remove=remove)
class IpylabLogFormatter(logging.Formatter):
+ def __init__(self, *, colors: dict[LogLevel, str], reset=reset, **kwargs) -> None:
+ """Initialize the formatter with specified format strings."""
+ self.colors = COLORS | colors
+ self.reset = reset
+ super().__init__(**kwargs)
+
def format(self, record: logging.LogRecord) -> str:
- # In addition to formatting, we also make the objects available for the viewer.
- # And substitute the 'owner' and
- owner = getattr(record, "owner", None)
- obj = getattr(record, "obj", None)
- record.owner = truncated_repr(owner, 60)
- record.obj = truncated_repr(obj, 80)
- msg = super().format(record).strip()
- show = bool(record.exc_info)
- if obj or show:
- notify_log_message(owner, obj, msg, show=bool(record.exc_info))
- return msg
+ record.color = self.colors[LogLevel(record.levelno)]
+ record.reset = self.reset
+ record.owner_rep = truncated_repr(getattr(record, "owner", ""), 120)
+ record.obj_rep = truncated_repr(getattr(record, "obj", ""), 120)
+ return super().format(record)
def formatException(self, ei) -> str: # noqa: N802
(etype, value, tb) = ei
@@ -177,131 +129,6 @@ def formatException(self, ei) -> str: # noqa: N802
if not sh:
return super().formatException(ei)
itb = sh.InteractiveTB
- itb.verbose if ipylab.app.logging_handler.level <= log_name_mappings[LogLevel.debug] else itb.minimal
+ itb.verbose if ipylab.app.logging_handler.level == LogLevel.DEBUG else itb.minimal # noqa: B018
stb = itb.structured_traceback(etype, value, tb) # type: ignore
return itb.stb2text(stb)
-
-
-def notify_log_message(owner: ipylab.Ipylab | None, obj: Any, msg: str, *, show=False):
- "Create a notification that an error occurred."
-
- msg_short = datetime.now().strftime("%H:%M:%S") + " " + msg.split("\n", maxsplit=1)[0] # noqa: DTZ005
- log_objects.appendleft((msg_short, msg, owner, obj))
- task = objects.get("log_notify_task")
- if isinstance(task, Task):
- # Limit to one notification.
- if not task.done():
- return
- task.result().close()
- if show or not objects.get("log objects"):
- objects["log_notify_task"] = ipylab.app.notification.notify(
- msg_short,
- type=ipylab.NotificationType.error,
- actions=[
- ipylab.NotifyAction(
- label="📄", caption="Show object log panel.", callback=show_obj_log_panel, keep_open=True
- )
- ],
- )
-
-
-def show_obj_log_panel():
- "Show a panel that maps a log message to an object."
- key = "log panel"
- if key not in objects:
-
- class ObjViewer(ipylab.Panel):
- "A simple object viewer to map the message to the object and can put the object in the console."
-
- def __init__(self):
- self.vpath = HTML(
- f"Vpath: {ipylab.app.vpath}",
- tooltip="Use this panel to access the log console from either:\n"
- "1. The icon in the status bar, or,\n"
- "2. The context menu (right click).\n"
- "The controls below are provided to put an object into a console.\n"
- "Note: Jupyterlab loads a different 'log console' for the 'console'.",
- )
- self.select_objs = Select(
- tooltip="Most recent exception is last",
- layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"},
- )
- self.search = Combobox(
- placeholder="Search",
- tooltip="Search for a log entry or object.",
- ensure_option=True,
- layout={"width": "auto"},
- )
- self.button_add_to_console = Button(
- description="Send object and owner to console",
- tooltip="Add the object and owner to the console. as obj and owner respectively"
- "\nTip: use the context menu of this pane to access log console.",
- layout={"width": "50%", "min_width": "content"},
- )
- self.button_refresh = Button(
- description="Refresh",
- tooltip="Refresh list of objects",
- layout={"width": "auto"},
- )
- self.box_controls = HBox(
- [self.vpath, self.button_add_to_console, self.button_refresh],
- layout={"flex": "0 0 auto", "justify_content": "space-between", "height": "max-content"},
- )
- layout = {"justify_content": "center", "padding": "20px"}
- self.title.label = "Object log panel"
- self.out = Output(layout={"flex": "1 0 auto"})
- super().__init__(
- children=[self.box_controls, self.select_objs, self.search, self.out],
- layout=layout,
- )
-
- dlink((self.select_objs, "options"), (self.search, "options"))
- dlink(
- (self.select_objs, "value"),
- (self.button_add_to_console, "disabled"),
- transform=lambda value: not value,
- )
- self.button_add_to_console.on_click(self.on_click)
- self.button_refresh.on_click(self.on_click)
- self.select_objs.observe(self._observe, "value")
- self.search.observe(self._observe, "value")
-
- def _observe(self, change: dict):
- if change["owner"] is self.select_objs:
- with self.out.hold_trait_notifications():
- self.out.clear_output(wait=True)
- self.out.outputs = ()
- row = self.row
- self.out.append_stdout(row[1])
- if owner := row[2]:
- self.out.append_display_data(owner)
- if obj := row[3]:
- self.out.append_display_data(obj)
- if change["owner"] is self.search and self.search.value:
- self.select_objs.value = self.search.value
- self.search.value = ""
-
- def on_click(self, b: Button):
- if b is self.button_add_to_console and self.select_objs.index is not None:
- objs = {"owner": self.row[2], "obj": self.row[3]}
- ipylab.app.open_console(objects=objs, namespace_name=ipylab.app.active_namespace)
- if b is self.button_refresh:
- self.show()
-
- @property
- def row(self) -> tuple[str, str, Ipylab | None, Any]:
- if self.select_objs.index is None:
- return "No selection", "", None, None
- return self.data[self.select_objs.index]
-
- def show(self):
- self.data = tuple(log_objects)
- self.select_objs.value = None
- self.select_objs.options = tuple(v[0] for v in self.data)
- if self.select_objs:
- self.select_objs.value = self.select_objs.options[0]
- self.add_to_shell(mode=ipylab.InsertMode.split_bottom)
-
- objects[key] = viewer = ObjViewer()
- viewer.observe(lambda _: objects.pop(key) if objects.get(key) is viewer else None, "comm")
- objects[key].show()
diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py
new file mode 100644
index 0000000..f776d1a
--- /dev/null
+++ b/ipylab/log_viewer.py
@@ -0,0 +1,173 @@
+# Copyright (c) ipylab contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from __future__ import annotations
+
+import collections
+from typing import TYPE_CHECKING
+
+from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Output, Select, VBox
+from traitlets import directional_link, link
+
+import ipylab
+from ipylab.common import SVGSTR_TEST_TUBE, Area, InsertMode
+from ipylab.log import IpylabLogHandler, LogLevel
+from ipylab.simple_output import AutoScroll, SimpleOutput
+from ipylab.widgets import Icon, Panel
+
+if TYPE_CHECKING:
+ from asyncio import Task
+
+ from ipylab.connection import ShellConnection
+ from ipylab.jupyterfrontend import App
+
+
+class LogViewer(Panel):
+ "A log viewer and an object viewer combined."
+
+ _log_notify_task: None | Task = None
+ _updating = False
+
+ def __init__(self, app: App, handler: IpylabLogHandler, buffersize=100):
+ self.info = HTML(
+ f"Vpath: {app.vpath}",
+ tooltip="Use this panel to access the log console from either:\n"
+ "1. The icon in the status bar, or,\n"
+ "2. The context menu (right click).\n"
+ "The controls below are provided to put an object into a console.\n"
+ "Note: Jupyterlab loads a different 'log console' for the 'console'.",
+ layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"},
+ )
+ options = [(v.name.capitalize(), v) for v in LogLevel]
+ self.log_level = Dropdown(
+ description="Level",
+ value=app.log_level,
+ options=options,
+ layout={"width": "max-content"},
+ )
+ self.buffer_size = BoundedIntText(
+ description="Buffer size",
+ value=buffersize,
+ min=1,
+ max=1e6,
+ layout={"width": "max-content"},
+ )
+ self.button_show_send_dialog = Button(
+ description="📪",
+ tooltip="Send the record to the console",
+ layout={"width": "auto"},
+ )
+ self.autoscroll_enabled = Checkbox(description="Automatic scroll", layout={"width": "auto"})
+ children = [self.info, self.log_level, self.autoscroll_enabled, self.buffer_size, self.button_show_send_dialog]
+ self.box_controls = HBox(children, layout={"justify_content": "space-between", "flex": "0 0 auto"})
+ self.output = SimpleOutput()
+ self.autoscroll_widget = AutoScroll(content=self.output)
+
+ self.title.label = f"Log: {app.vpath}"
+ self.title.icon = Icon(name="ipylab-test_tube", svgstr=SVGSTR_TEST_TUBE)
+ self._records = collections.deque(maxlen=buffersize)
+
+ super().__init__(children=[self.box_controls, self.autoscroll_widget])
+
+ self.buffer_size.observe(self._observe_buffer_size, "value")
+ handler.register_callback(self._add_record)
+ link((self.autoscroll_widget, "enabled"), (self.autoscroll_enabled, "value"))
+ link((app, "log_level"), (self.log_level, "value"))
+ link((self.buffer_size, "value"), (self.output, "max_outputs"))
+ directional_link(
+ (self.output, "length"), (self.buffer_size, "tooltip"), transform=lambda size: f"Current size: {size}"
+ )
+ self.button_show_send_dialog.on_click(self._button_on_click)
+
+ def close(self):
+ "Cannot close"
+
+ def _add_record(self, record):
+ self._records.append(record)
+ self.output.push(record.output)
+
+ def _notify_exception(self, msg: str):
+ "Create a notification that an error occurred."
+
+ msg_short = msg.split("\n", maxsplit=1)[0]
+ if self._log_notify_task:
+ # Limit to one notification.
+ if not self._log_notify_task.done():
+ return
+ self._log_notify_task.result().close()
+ self._log_notify_task = ipylab.app.notification.notify(
+ msg_short,
+ type=ipylab.NotificationType.error,
+ actions=[
+ ipylab.NotifyAction(
+ label="📄",
+ caption="Show object log panel.",
+ callback=self.add_to_shell,
+ keep_open=True,
+ )
+ ],
+ )
+
+ def _observe_buffer_size(self, change):
+ if change["owner"] is self.buffer_size:
+ self._records = collections.deque(self._records, maxlen=self.buffer_size.value)
+
+ def _button_on_click(self, _):
+ self.button_show_send_dialog.disabled = True
+ ipylab.app.dialog.to_task(
+ self._show_send_dialog(),
+ hooks={"callbacks": [lambda _: self.button_show_send_dialog.set_trait("disabled", False)]},
+ )
+
+ async def _show_send_dialog(self):
+ # TODO: make a formatter to simplify the message with obj and owner)
+ options = {r.msg: r for r in reversed(self._records)} # type: ignore
+ select = Select(
+ tooltip="Most recent exception is first",
+ layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"},
+ options=options,
+ )
+ search = Combobox(
+ placeholder="Search",
+ tooltip="Search for a log entry or object.",
+ ensure_option=True,
+ layout={"width": "auto"},
+ options=tuple(options),
+ )
+ output = Output()
+ body = VBox([select, search, output])
+
+ def observe(change: dict):
+ if change["owner"] is select:
+ body.children = [select, search, select.value] if select.value else [select, search]
+ elif change["owner"] is search and change["new"] in options:
+ select.value = options[change["new"]]
+
+ select.observe(observe, "value")
+ search.observe(observe, "value")
+ try:
+ await ipylab.app.dialog.show_dialog("Send record to console", body=body)
+ if select.value:
+ await ipylab.app.open_console(
+ objects={"record": select.value.record}, namespace_name=ipylab.app.active_namespace
+ )
+ except Exception:
+ return
+ finally:
+ for w in [search, body, select]:
+ w.close()
+
+ def add_to_shell(
+ self,
+ *,
+ area: ipylab.Area = Area.main,
+ activate: bool = True,
+ mode: ipylab.InsertMode = InsertMode.split_bottom,
+ rank: int | None = None,
+ ref: ipylab.ShellConnection | None = None,
+ options: dict | None = None,
+ **kwgs,
+ ) -> Task[ShellConnection]:
+ return super().add_to_shell(
+ area=area, activate=activate, mode=mode, rank=rank, ref=ref, options=options, **kwgs
+ )
diff --git a/ipylab/shell.py b/ipylab/shell.py
index dfccd45..f5c894a 100644
--- a/ipylab/shell.py
+++ b/ipylab/shell.py
@@ -25,15 +25,7 @@
class Shell(Ipylab):
- """
- Provides access to the shell.
- The minimal interface is:
- https://jupyterlab.readthedocs.io/en/latest/api/interfaces/application.App.IShell.html
-
- Likely the full labShell interface.
-
- ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/application.App.IShell.html#add
- """
+ """Provides access to the shell."""
SINGLE = True
@@ -92,8 +84,8 @@ def add(
"ref": f"{pack(ref)}.id" if isinstance(ref, ShellConnection) else None,
} | (options or {})
args["area"] = area
- if "asDocument" not in args:
- args["asDocument"] = area in [Area.left, Area.main, Area.right, Area.down]
+ if "asMainArea" not in args:
+ args["asMainArea"] = area in [Area.left, Area.main, Area.right, Area.down]
if isinstance(obj, ShellConnection):
if "cid" in args and args["cid"] != obj.cid:
msg = f"The provided {args['cid']=} does not match {obj.cid=}"
diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py
new file mode 100644
index 0000000..c4c710b
--- /dev/null
+++ b/ipylab/simple_output.py
@@ -0,0 +1,117 @@
+# Copyright (c) ipylab contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from ipywidgets import DOMWidget, Widget, register
+from traitlets import Bool, Enum, Int, Unicode
+
+import ipylab
+from ipylab.ipylab import Ipylab
+
+if TYPE_CHECKING:
+ from asyncio import Task
+ from typing import Unpack
+
+ from ipylab.common import IpylabKwgs
+from typing import TYPE_CHECKING
+
+from ipywidgets import widget_serialization
+from traitlets import Instance, observe
+
+from ipylab.ipylab import WidgetBase
+
+if TYPE_CHECKING:
+ from asyncio import Task
+ from typing import Unpack
+
+
+@register
+class SimpleOutput(DOMWidget, Ipylab):
+ """An output with no prompts designed to accept frequent additions.
+
+ The interface differs from Ipywidgets.Output widget in almost every way.
+
+ Note: Automatic widget restoration is not implemented.
+ """
+
+ _model_name = Unicode("SimpleOutputModel").tag(sync=True)
+ _view_name = Unicode("SimpleOutputView").tag(sync=True)
+ max_outputs = Int(100, help="The maximum number of individual widgets.").tag(sync=True)
+ max_continuous_streams = Int(100, help="Max streams to put in same output.").tag(sync=True)
+ length = Int(read_only=True, help="The current length of the output area").tag(sync=True)
+
+ invalid_data_mode = Enum(["raise", "skip"], "raise", help="What to do with invalid output")
+
+ def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str, ...]):
+ outputs_ = []
+ for output in outputs:
+ if hasattr(output, "_repr_mimebundle_"):
+ if not callable(output._repr_mimebundle_): # type: ignore
+ if self.invalid_data_mode == "raise":
+ msg = f"Invalid data {output}"
+ raise TypeError(msg)
+ continue
+ outputs_.append(output._repr_mimebundle_()) # type: ignore
+ if isinstance(output, str):
+ outputs_.append({"output_type": "stream", "name": "stdout", "text": output})
+ elif isinstance(output, dict):
+ outputs_.append(output)
+ else:
+ data, metadata = ipylab.app._ipy_shell.display_formatter.format(output) # type: ignore # noqa: SLF001
+ outputs_.append({"output_type": "display_data", "data": data, "metadata": metadata})
+
+ return outputs_
+
+ def push(self, *outputs: dict[str, str] | Widget | str):
+ """Add one or more items to the output.
+ Consecutive `streams` of the same type are placed in the same 'output'.
+ Outputs passed as dicts are assumed to be correctly packed as `repr_mime` data.
+ """
+ if outputs_ := self._pack_outputs(outputs):
+ self.send({"add": outputs_})
+
+ def clear(self, *, wait=True):
+ "Clear the output"
+ self.send({"clear": wait})
+
+ def set(self, *outputs: dict[str, str] | Widget | str, **kwgs: Unpack[IpylabKwgs]) -> Task[int]:
+ """Set the output explicitly by first clearing and then adding the outputs."""
+ return self.operation("setOutputs", {"outputs": self._pack_outputs(outputs)}, **kwgs)
+
+
+@register
+class AutoScroll(DOMWidget, WidgetBase):
+ """An automatic scrolling container.
+
+ The content can be changed and the scrolling enabled and disabled on the fly.
+
+ Fast scrolling depends on `onscrollend`. Presently supported by common browsers except for Safari.
+ https://developer.mozilla.org/en-US/docs/Web/API/Document/scrollend_event
+ """
+
+ _model_name = Unicode("AutoscrollModel").tag(sync=True)
+ _view_name = Unicode("AutoscrollView").tag(sync=True)
+
+ content = Instance(Widget, (), allow_none=True).tag(sync=True, **widget_serialization)
+
+ enabled = Bool().tag(sync=True)
+ mode = Enum(["start", "end"], "end").tag(sync=True)
+ sentinel_text = Unicode(help="Provided for debugging purposes").tag(sync=True)
+
+ @observe("enabled")
+ def _observe(self, _):
+ layout = self.layout
+ with layout.hold_trait_notifications():
+ if self.enabled:
+ layout.overflow = "hidden"
+ layout.height = "100%"
+ else:
+ layout.overflow = "auto"
+ layout.height = "auto"
+
+ def __init__(self, *, enabled=True, **kwargs):
+ self.enabled = enabled
+ super().__init__(**kwargs)
diff --git a/package.json b/package.json
index 058227f..8a9aa5a 100644
--- a/package.json
+++ b/package.json
@@ -92,7 +92,6 @@
"@jupyterlab/coreutils": "^6.2.5",
"@jupyterlab/filebrowser": "^4.2.5",
"@jupyterlab/launcher": "^4.2.5",
- "@jupyterlab/logconsole": "^4.2.5",
"@jupyterlab/mainmenu": "^4.2.5",
"@jupyterlab/observables": "^5.2.5",
"@jupyterlab/rendermime": "^4.2.5",
diff --git a/src/plugin.ts b/src/plugin.ts
index 3d4ee78..e91e10f 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -11,7 +11,6 @@ import {
import { ICommandPalette } from '@jupyterlab/apputils';
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
import { ILauncher } from '@jupyterlab/launcher';
-import { ILoggerRegistry } from '@jupyterlab/logconsole';
import { IMainMenu } from '@jupyterlab/mainmenu';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
@@ -26,8 +25,6 @@ const PLUGIN_ID = 'ipylab:settings';
namespace CommandIDs {
export const restore = 'ipylab:restore';
export const checkStartKernel = 'ipylab:check-start-kernel';
- export const openConsole = 'ipylab:open-console';
- export const toggleLogConsole = 'ipylab:toggle-log-console';
}
/**
@@ -44,8 +41,7 @@ const extension: JupyterFrontEndPlugin = {
IDefaultFileBrowser,
ILauncher,
ITranslator,
- IMainMenu,
- ILoggerRegistry
+ IMainMenu
],
activate: async (
app: JupyterFrontEnd,
@@ -58,8 +54,7 @@ const extension: JupyterFrontEndPlugin = {
defaultBrowser: IDefaultFileBrowser | null,
launcher: ILauncher | null,
translator: ITranslator | null,
- mainMenu: IMainMenu | null,
- loggerRegistry: ILoggerRegistry | null
+ mainMenu: IMainMenu | null
) => {
// add globals
const exports = await import('./widget');
@@ -72,7 +67,6 @@ const extension: JupyterFrontEndPlugin = {
exports.IpylabModel.translator = translator;
exports.IpylabModel.launcher = launcher;
exports.IpylabModel.mainMenu = mainMenu;
- exports.JupyterFrontEndModel.loggerRegistry = loggerRegistry;
registry.registerWidget({
name: MODULE_NAME,
diff --git a/src/widget.ts b/src/widget.ts
index 2abf3cf..ea417ab 100644
--- a/src/widget.ts
+++ b/src/widget.ts
@@ -9,10 +9,12 @@ import { IconModel, IconView } from './widgets/icon';
import { IpylabModel } from './widgets/ipylab';
import { NotificationManagerModel } from './widgets/notification';
import { PanelModel, PanelView } from './widgets/panel';
+import { AutoscrollModel, AutoscrollView } from './widgets/autoscroll';
+import { SessionManagerModel } from './widgets/sessions';
import { ShellModel } from './widgets/shell';
import { SplitPanelModel, SplitPanelView } from './widgets/split_panel';
import { TitleModel } from './widgets/title';
-import { SessionManagerModel } from './widgets/sessions';
+import { SimpleOutputModel, SimpleOutputView } from './widgets/simple_output';
export {
CommandRegistryModel,
ConnectionModel,
@@ -24,6 +26,10 @@ export {
NotificationManagerModel,
PanelModel,
PanelView,
+ AutoscrollModel,
+ AutoscrollView,
+ SimpleOutputModel,
+ SimpleOutputView,
SessionManagerModel,
ShellConnectionModel,
ShellModel,
diff --git a/src/widgets/autoscroll.ts b/src/widgets/autoscroll.ts
new file mode 100644
index 0000000..5743111
--- /dev/null
+++ b/src/widgets/autoscroll.ts
@@ -0,0 +1,177 @@
+// Copyright (c) ipylab contributors
+// Distributed under the terms of the Modified BSD License.
+import { DOMWidgetModel, DOMWidgetView } from '@jupyter-widgets/base';
+import { Message } from '@lumino/messaging';
+import { PanelLayout, Widget } from '@lumino/widgets';
+import $ from 'jquery';
+import { MODULE_NAME, MODULE_VERSION } from '../version';
+import { IpylabModel } from './ipylab';
+
+/**
+ *
+ * Inspiration: @jupyterlab/logconsole -> ScrollingWidget
+ *
+ * Implements a panel which autoscrolls the content.
+ */
+export class AutoScroll extends Widget {
+ constructor({ content, ...options }: AutoScroll.IOptions) {
+ super(options);
+ this._view = options.view;
+ this.layout = new PanelLayout();
+ this._sentinel = document.createElement('div');
+ this._view.model.on('change:content', this.loadContent, this);
+ this.loadContent();
+ }
+
+ /**
+ * The content widget.
+ */
+ get content() {
+ return this._content;
+ }
+
+ set content(content: Widget | undefined) {
+ if (this._content === content) {
+ return;
+ }
+ if (this._content) {
+ this.layout.removeWidget(this._content);
+ }
+ this._content = content;
+ if (content) {
+ (this.layout as PanelLayout).addWidget(content);
+ }
+ }
+
+ async loadContent() {
+ const id = this._view.model.get('content');
+ this.content = id ? await IpylabModel.toLuminoWidget({ id }) : undefined;
+ this.update();
+ }
+
+ scrollOnce() {
+ if (this.enabled) {
+ this._sentinel.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
+ }
+ }
+
+ private _disconnectObserver() {
+ if (this._observer) {
+ this._observer.disconnect();
+ delete this._observer;
+ }
+ }
+
+ update(): void {
+ this._disconnectObserver();
+ if (this.node.contains(this._sentinel)) {
+ this.node.removeChild(this._sentinel);
+ }
+ this.enabled = this._view.model.get('enabled');
+ if (!this.enabled) {
+ (this.node as any).onscrollend = null;
+ return;
+ }
+ this._sentinel.textContent = this._view.model.get('sentinel_text') || '';
+
+ if (this._view.model.get('mode') === 'start') {
+ this.node.prepend(this._sentinel);
+ } else {
+ this.node.appendChild(this._sentinel);
+ }
+ (this.node as any).onscrollend = (event: Event) => {
+ if (this.enabled) {
+ this.scrollOnce();
+ }
+ };
+
+ this._observer = new IntersectionObserver(
+ args => {
+ if (!args[0].isIntersecting) {
+ this.scrollOnce();
+ }
+ },
+ { root: this.node }
+ );
+ this._observer.observe(this._sentinel);
+ this.scrollOnce();
+ }
+
+ processMessage(msg: Message): void {
+ super.processMessage(msg);
+ this._view?.processLuminoMessage(msg);
+ }
+ /**
+ * Dispose the widget.
+ *
+ * This causes the view to be destroyed as well with 'remove'
+ */
+ dispose(): void {
+ if (this.isDisposed) {
+ return;
+ }
+ this._disconnectObserver();
+ super.dispose();
+ this._view?.model?.off('change:content', this.loadContent, this);
+ this._view?.remove();
+ this._view = null!;
+ }
+ enabled: boolean;
+ private _content?: Widget;
+ private _observer: IntersectionObserver | null = null;
+ private _sentinel: HTMLDivElement;
+ private _view: DOMWidgetView;
+}
+
+export namespace AutoScroll {
+ export interface IOptions extends Widget.IOptions {
+ content?: T;
+ view: DOMWidgetView;
+ }
+}
+
+/**
+ * The model for a logger.
+ */
+export class AutoscrollModel extends DOMWidgetModel {
+ /**
+ * The default attributes.
+ */
+ defaults(): Backbone.ObjectHash {
+ return {
+ ...super.defaults(),
+ _model_name: 'AutoscrollModel',
+ _model_module: MODULE_NAME,
+ _model_module_version: MODULE_VERSION,
+ _view_name: 'AutoscrollView',
+ _view_module: MODULE_NAME,
+ _view_module_version: MODULE_VERSION
+ };
+ }
+}
+
+/**
+ * The view for a AutoScroll.
+ */
+
+export class AutoscrollView extends DOMWidgetView {
+ _createElement(tagName: string): HTMLElement {
+ this.luminoWidget = new AutoScroll({
+ view: this
+ });
+ return this.luminoWidget.node;
+ }
+
+ _setElement(el: HTMLElement): void {
+ this.el = this.luminoWidget.node;
+ this.$el = $(this.luminoWidget.node);
+ }
+
+ update(options?: any): void {
+ super.update();
+ this.luminoWidget.update();
+ }
+
+ model: AutoscrollModel;
+ luminoWidget: AutoScroll;
+}
diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts
index 3d551b2..978bac5 100644
--- a/src/widgets/frontend.ts
+++ b/src/widgets/frontend.ts
@@ -3,10 +3,8 @@
import { KernelWidgetManager } from '@jupyter-widgets/jupyterlab-manager';
import { SessionContext, SessionContextDialogs } from '@jupyterlab/apputils';
-import { ILogger, ILoggerRegistry, IStateChange } from '@jupyterlab/logconsole';
import { Kernel } from '@jupyterlab/services';
import { PromiseDelegate } from '@lumino/coreutils';
-import { Signal } from '@lumino/signaling';
import { IpylabModel } from './ipylab';
/**
@@ -23,57 +21,27 @@ export class JupyterFrontEndModel extends IpylabModel {
initialize(attributes: any, options: any): void {
super.initialize(attributes, options);
this.kernelId = this.kernel.id;
- this.logger = JFEM.loggerRegistry.getLogger(this.vpath);
- this.logger.stateChanged.connect(this.loggerStateChanged as any, this);
Private.jfems.set(this.kernel.id, this);
}
async ipylabInit(base: any = null) {
+ const vpath = await JFEM.getVpath(this.kernelId);
+ this.set('vpath', vpath);
this.set('version', JFEM.app.version);
this.set('per_kernel_widget_manager_detected', JFEM.PER_KERNEL_WM);
- this.set('logger_level', this.logger.level);
await super.ipylabInit(base);
- if (!Private.vpathTojfem.has(this.vpath)) {
- Private.vpathTojfem.set(this.vpath, new PromiseDelegate());
+ if (!Private.vpathTojfem.has(vpath)) {
+ Private.vpathTojfem.set(vpath, new PromiseDelegate());
}
- Private.vpathTojfem.get(this.vpath).resolve(this);
+ Private.vpathTojfem.get(vpath).resolve(this);
}
close(comm_closed?: boolean): Promise {
Private.jfems.delete(this.kernelId);
- Private.vpathTojfem.delete(this.vpath);
- this.logger.stateChanged.disconnect(this.loggerStateChanged as any, this);
+ Private.vpathTojfem.delete(this.get('vpath'));
return super.close(comm_closed);
}
- get vpath() {
- let vpath = this.get('vpath');
- if (!vpath) {
- const cs = this.get('current_session');
- if (cs?.kernel?.id === this.kernelId) {
- vpath = cs?.path;
- }
- if (!vpath) {
- for (const session of JFEM.sessionManager.running()) {
- if (session.kernel.id === this.kernelId) {
- vpath = session.path;
- break;
- }
- }
- }
- this.set('vpath', vpath);
- this.save_changes();
- }
- return vpath;
- }
-
- private loggerStateChanged(sender: ILogger, change: IStateChange): void {
- if (this.get('logger_level') !== this.logger.level) {
- this.set('logger_level', this.logger.level);
- this.save_changes();
- }
- }
-
async operation(op: string, payload: any): Promise {
switch (op) {
case 'evaluate':
@@ -96,8 +64,27 @@ export class JupyterFrontEndModel extends IpylabModel {
}
}
- static getModelByKernelId(kernelId: string) {
- return Private.jfems.get(kernelId);
+ /**
+ * Get the vpath for given a kernel id.
+ */
+ static async getVpath(kernelId: string): Promise {
+ if (Private.kernelIdToVpath.has(kernelId)) {
+ return Private.kernelIdToVpath.get(kernelId);
+ }
+ for (const session of JFEM.sessionManager.running()) {
+ if (session.kernel.id === kernelId) {
+ Private.kernelIdToVpath.set(kernelId, session.path);
+ return session.path;
+ }
+ }
+ await JFEM.sessionManager.refreshRunning();
+ for (const session of JFEM.sessionManager.running()) {
+ if (session.kernel.id === kernelId) {
+ Private.kernelIdToVpath.set(kernelId, session.path);
+ return session.path;
+ }
+ }
+ throw new Error(`Failed to determine vpath for kernelId='${kernelId}'`);
}
/**
@@ -128,6 +115,7 @@ export class JupyterFrontEndModel extends IpylabModel {
const sessionContext = await JFEM.newSessionContext(vpath);
kernel = sessionContext.session.kernel;
}
+ Private.kernelIdToVpath.set(kernel.id, vpath);
// Relies on per-kernel widget manager.
const getManager = (KernelWidgetManager as any).getManager;
const widget_manager: KernelWidgetManager = await getManager(kernel);
@@ -227,65 +215,18 @@ export class JupyterFrontEndModel extends IpylabModel {
await IpylabModel.JFEM.getModelByVpath('ipylab');
}
- /**
- * Open a console using vpath for path.
- *
- * @param args not used.
- * @returns
- */
- static async openConsole(args: any) {
- const currentWidget = JFEM.tracker.currentWidget;
- const ipylabSettings = (currentWidget as any)?.ipylabSettings;
- const jfem = await JFEM.getModelByVpath(ipylabSettings.vpath);
- const payload = { cid: ipylabSettings.cid };
- return await jfem.scheduleOperation('open_console', payload, 'auto');
- }
-
- /**
- * Opening/close the console using vpath.
- *
- * logconsole:open corresponds to a toggle command, so we the best
- * we can do is toggle the console.
- *
- * @param args not used.
- */
- static async toggleLogConsole(args: any) {
- const currentWidget = JFEM.tracker.currentWidget;
- const ipylabSettings = (currentWidget as any)?.ipylabSettings;
- const source = ipylabSettings.vpath;
- JFEM.app.commands.execute('logconsole:open', { source });
- }
- context = new IpylabContext(this.vpath);
kernelId: string;
- logger: ILogger;
- static loggerRegistry: ILoggerRegistry;
}
IpylabModel.JFEM = JupyterFrontEndModel;
const JFEM = JupyterFrontEndModel;
-/**
- * Provide minimal context necessary to create a DocumentWidget.
- */
-class IpylabContext {
- constructor(path: string) {
- this.path = path;
- // this.contentsModel.path = path;
- }
- readonly path: string;
- ready = new Promise(resolve => resolve(null));
- pathChanged: Signal = new Signal(this);
- model: object = { stateChanged: new Signal(this) };
- localPath = '';
- async rename(newName: string) {}
-}
-
/**
* A namespace for private data
*/
namespace Private {
/**
- * Mapping of vpath to JupyterFrontEndModel
+ * A mapping of vpath to JupyterFrontEndModel.
*/
export const vpathTojfem = new Map<
string,
@@ -293,7 +234,12 @@ namespace Private {
>();
/**
- * Mapping of kernelId to JupyterFrontEndModel
+ * A mapping of kernelId to vpath, possibly set before the model is created.
+ */
+ export const kernelIdToVpath = new Map();
+
+ /**
+ * A mapping of kernelId to JupyterFrontEndModel.
*/
export const jfems = new Map();
}
diff --git a/src/widgets/ipylab.ts b/src/widgets/ipylab.ts
index 16feafd..e27956b 100644
--- a/src/widgets/ipylab.ts
+++ b/src/widgets/ipylab.ts
@@ -1,7 +1,7 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { ICallbacks, ISerializers, WidgetModel } from '@jupyter-widgets/base';
+import { DOMWidgetModel, ICallbacks } from '@jupyter-widgets/base';
import { KernelWidgetManager } from '@jupyter-widgets/jupyterlab-manager';
import { JupyterFrontEnd, LabShell } from '@jupyterlab/application';
import {
@@ -37,7 +37,7 @@ import type { ShellModel } from './shell';
*
* Subclass as required but can also be used directly.
*/
-export class IpylabModel extends WidgetModel {
+export class IpylabModel extends DOMWidgetModel {
/**
* The default attributes.
*/
@@ -225,12 +225,8 @@ export class IpylabModel extends WidgetModel {
*
* @param msg
*/
- private async onCustomMessage(msg: any) {
- if (typeof msg !== 'string') {
- return;
- }
- const content = JSON.parse(msg);
-
+ protected async onCustomMessage(msg: any) {
+ const content = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (content.ipylab_FE) {
// Result of an operation request sent to Python.
const op = this._pendingOperations.get(content.ipylab_FE);
@@ -252,10 +248,6 @@ export class IpylabModel extends WidgetModel {
this.doOperationForPython(content);
} else if (content.close) {
this.close(true);
- } else if (content.log) {
- const { log, toLuminoWidget, toObject } = content;
- this.replaceParts(log, toLuminoWidget, toObject);
- IpylabModel.JFEM.getModelByKernelId(this.kernel.id).logger.log(log);
}
}
@@ -527,9 +519,6 @@ export class IpylabModel extends WidgetModel {
return IpylabModel.app.serviceManager.sessions;
}
- static serializers: ISerializers = {
- ...WidgetModel.serializers
- };
widget_manager: KernelWidgetManager;
private _pendingOperations = new Map>();
readonly base: any;
diff --git a/src/widgets/shell.ts b/src/widgets/shell.ts
index fea939c..cf4c292 100644
--- a/src/widgets/shell.ts
+++ b/src/widgets/shell.ts
@@ -1,7 +1,6 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
import { MainAreaWidget } from '@jupyterlab/apputils';
-import { DocumentWidget } from '@jupyterlab/docregistry';
import { UUID } from '@lumino/coreutils';
import { Widget } from '@lumino/widgets';
import { IpylabModel } from './ipylab';
@@ -115,19 +114,12 @@ export class ShellModel extends IpylabModel {
}
args.cid =
args.cid || ShellModel.ConnectionModel.new_cid('ShellConnection');
- if (args.asDocument && !(widget instanceof DocumentWidget)) {
- widget.addClass('ipylab-Document');
- const jfem = await ShellModel.JFEM.getModelByVpath(args.vpath);
- const context = jfem.context as any;
- const label = widget?.title?.label || args.vpath;
- const w = (widget = new DocumentWidget({ context, content: widget }));
+ if (args.asMainArea && !(widget instanceof MainAreaWidget)) {
+ widget.addClass('ipylab-MainArea');
+ const w = (widget = new MainAreaWidget({ content: widget }));
w.toolbar.dispose();
w.contentHeader.dispose();
w.id = args.cid;
- // Disconnect the following callback which overwrites the `title.label`.
- w.title.changed.disconnect((w as any)._onTitleChanged, w);
- w.title.label = label;
- w.title.caption = w.title.caption || `vpath=${args.vpath}`;
}
ShellModel.ConnectionModel.registerConnection(args.cid, widget);
diff --git a/src/widgets/simple_output.ts b/src/widgets/simple_output.ts
new file mode 100644
index 0000000..7d5ba3e
--- /dev/null
+++ b/src/widgets/simple_output.ts
@@ -0,0 +1,195 @@
+// Copyright (c) ipylab contributors
+// Distributed under the terms of the Modified BSD License.
+import { DOMWidgetView } from '@jupyter-widgets/base';
+import * as nbformat from '@jupyterlab/nbformat';
+import { IOutput } from '@jupyterlab/nbformat';
+import {
+ OutputArea,
+ OutputAreaModel,
+ SimplifiedOutputArea
+} from '@jupyterlab/outputarea';
+import { IOutputModel } from '@jupyterlab/rendermime';
+import { Message } from '@lumino/messaging';
+import $ from 'jquery';
+import { IpylabModel } from './ipylab';
+
+class IpylabOutputAreaModel extends OutputAreaModel {
+ /**
+ * Whether a new value should be consolidated with the previous output.
+ *
+ * This will only be called if the minimal criteria of both being stream
+ * messages of the same type.
+ */
+ protected shouldCombine(options: {
+ value: nbformat.IOutput;
+ lastModel: IOutputModel;
+ }): boolean {
+ if (
+ options.value.name === 'stdout' &&
+ this._continuousCount < this.maxContinuous
+ ) {
+ this._continuousCount++;
+ return true;
+ } else {
+ this._continuousCount = 0;
+ }
+ return false;
+ }
+ /**
+ * Remove oldest outputs to the length limit.
+ */
+ removeOldest(maxLength: number) {
+ if (this.list.length > maxLength) {
+ this.list.removeRange(0, this.list.length - maxLength);
+ }
+ }
+
+ private _continuousCount = 0;
+ maxContinuous = 100;
+}
+
+/**
+ * The model for a panel.
+ */
+export class SimpleOutputModel extends IpylabModel {
+ /**
+ * The default attributes.
+ */
+ defaults(): Backbone.ObjectHash {
+ return {
+ ...super.defaults(),
+ _model_name: 'SimpleOutputModel',
+ _view_name: 'SimpleOutputView'
+ };
+ }
+
+ setReady(): void {
+ this.on('change:max_continuous_streams', this.configure, this);
+ this.on('change:max_outputs', this.configure, this);
+ this.configure();
+ this.outputAreaModel.changed.connect(this._outputAreaModelChange, this);
+ super.setReady();
+ }
+
+ protected async onCustomMessage(msg: any) {
+ const content = typeof msg === 'string' ? JSON.parse(msg) : msg;
+ if ('add' in content) {
+ this.add(content.add);
+ } else if ('clear' in content) {
+ this.outputAreaModel.clear(content.clear);
+ } else {
+ await super.onCustomMessage(content);
+ }
+ }
+
+ async operation(op: string, payload: any): Promise {
+ switch (op) {
+ case 'setOutputs':
+ return await this.setOutputs(payload);
+ default:
+ return await super.operation(op, payload);
+ }
+ }
+
+ configure() {
+ this._maxOutputs = this.get('max_outputs');
+ this.outputAreaModel.maxContinuous = this.get('max_continuous_streams');
+ }
+
+ _outputAreaModelChange() {
+ const length = this.outputAreaModel.length;
+ if (this.outputAreaModel.length > this._maxOutputs) {
+ this.outputAreaModel.removeOldest(this._maxOutputs);
+ return;
+ }
+ if (length !== this.get('length')) {
+ this.set('length', length);
+ this.save_changes();
+ }
+ }
+
+ async setOutputs({ outputs }: { outputs: Array }) {
+ this.outputAreaModel.clear(true);
+ this.add(outputs);
+ return this.outputAreaModel.length;
+ }
+
+ add(items: Array) {
+ for (const output of items) {
+ this.outputAreaModel.add(output);
+ }
+ }
+
+ close(comm_closed?: boolean) {
+ this.outputAreaModel.changed.disconnect(this._outputAreaModelChange, this);
+ this.outputAreaModel.dispose();
+ return super.close(comm_closed);
+ }
+ private _maxOutputs = 100;
+ outputAreaModel = new IpylabOutputAreaModel({ trusted: true });
+}
+
+export class SimpleOutputView extends DOMWidgetView {
+ _createElement(tagName: string): HTMLElement {
+ this.luminoWidget = new IpylabSimplifiedOutputArea({
+ view: this,
+ rendermime: IpylabModel.rendermime,
+ contentFactory: OutputArea.defaultContentFactory,
+ model: this.model.outputAreaModel,
+ promptOverlay: false
+ });
+ this.luminoWidget.addClass('jupyter-widgets');
+ this.luminoWidget.addClass('jupyter-widget-output');
+ return this.luminoWidget.node;
+ }
+
+ _setElement(el: HTMLElement): void {
+ if (this.el || el !== this.luminoWidget.node) {
+ // Boxes don't allow setting the element beyond the initial creation.
+ throw new Error('Cannot reset the DOM element.');
+ }
+
+ this.el = this.luminoWidget.node;
+ this.$el = $(this.luminoWidget.node);
+ }
+ model: SimpleOutputModel;
+ luminoWidget: IpylabSimplifiedOutputArea;
+}
+
+class IpylabSimplifiedOutputArea extends SimplifiedOutputArea {
+ constructor(
+ options: IpylabSimplifiedOutputArea.IOptions & OutputArea.IOptions
+ ) {
+ const view = options.view;
+ delete (options as any).view;
+ super(options);
+ this._view = view;
+ }
+
+ processMessage(msg: Message): void {
+ super.processMessage(msg);
+ this._view?.processLuminoMessage(msg);
+ }
+
+ /**
+ * Dispose the widget.
+ *
+ * This causes the view to be destroyed as well with 'remove'
+ */
+ dispose(): void {
+ if (this.isDisposed) {
+ return;
+ }
+ super.dispose();
+ this._view?.remove();
+ this._view = null!;
+ }
+
+ private _view: SimpleOutputView;
+}
+
+export namespace IpylabSimplifiedOutputArea {
+ export interface IOptions {
+ view: SimpleOutputView;
+ }
+}
diff --git a/style/widget.css b/style/widget.css
index f9c11fa..ecc81d3 100644
--- a/style/widget.css
+++ b/style/widget.css
@@ -22,7 +22,7 @@
background-color: var(--jp-border-color2);
}
-.ipylab-Document {
+.ipylab-MainArea {
height: 100%;
width: 100%;
}
diff --git a/yarn.lock b/yarn.lock
index 5c5ee05..c0cbfb6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -978,7 +978,7 @@ __metadata:
languageName: node
linkType: hard
-"@jupyterlab/logconsole@npm:^3.0.0 || ^4.0.0, @jupyterlab/logconsole@npm:^4.2.5":
+"@jupyterlab/logconsole@npm:^3.0.0 || ^4.0.0":
version: 4.2.5
resolution: "@jupyterlab/logconsole@npm:4.2.5"
dependencies:
@@ -4170,7 +4170,6 @@ __metadata:
"@jupyterlab/coreutils": ^6.2.5
"@jupyterlab/filebrowser": ^4.2.5
"@jupyterlab/launcher": ^4.2.5
- "@jupyterlab/logconsole": ^4.2.5
"@jupyterlab/mainmenu": ^4.2.5
"@jupyterlab/observables": ^5.2.5
"@jupyterlab/rendermime": ^4.2.5