Skip to content

Commit

Permalink
feat: New "Copy URL" button in revamped UI
Browse files Browse the repository at this point in the history
  • Loading branch information
so-rose committed Jan 15, 2025
1 parent 1e4d2be commit 30622c8
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 37 deletions.
1 change: 1 addition & 0 deletions bpy_jupyter/contracts/operator_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ class OperatorType(enum.StrEnum):

StartJupyterKernel = f'{ADDON_NAME}.start_jupyter_kernel'
StopJupyterKernel = f'{ADDON_NAME}.stop_jupyter_kernel'
CopyJupyURLToClip = f'{ADDON_NAME}.copy_jupy_url_to_clip'
5 changes: 4 additions & 1 deletion bpy_jupyter/operators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,24 @@
from functools import reduce

from .. import contracts as ct
from . import start_jupyter_kernel, stop_jupyter_kernel
from . import copy_jupy_url_to_clip, start_jupyter_kernel, stop_jupyter_kernel

BL_REGISTER: list[ct.BLClass] = [
*start_jupyter_kernel.BL_REGISTER,
*stop_jupyter_kernel.BL_REGISTER,
*copy_jupy_url_to_clip.BL_REGISTER,
]
BL_HANDLERS: ct.BLHandlers = reduce(
lambda a, b: a + b,
[
start_jupyter_kernel.BL_HANDLERS,
stop_jupyter_kernel.BL_HANDLERS,
copy_jupy_url_to_clip.BL_HANDLERS,
],
ct.BLHandlers(),
)
BL_KEYMAP_ITEMS: list[ct.BLKeymapItem] = [
*start_jupyter_kernel.BL_KEYMAP_ITEMS,
*stop_jupyter_kernel.BL_KEYMAP_ITEMS,
*copy_jupy_url_to_clip.BL_KEYMAP_ITEMS,
]
155 changes: 155 additions & 0 deletions bpy_jupyter/operators/copy_jupy_url_to_clip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# bpy_jupyter
# Copyright (C) 2025 bpy_jupyter Project Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Defines the `CopyJupyURLToClip` operator.
Inspired by <https://github.com/cheng-chi/blender_notebook/blob/master/blender_notebook/kernel.py>
"""

import datetime
import ipaddress
import secrets

import bpy
import pyperclipfix

from .. import contracts as ct
from ..services import jupyter_kernel as jkern

####################
# - Constants
####################
CLIPBOARD_CLEAR_DELAY = 45


####################
# - Constants
####################
class CopyJupyURLToClip(bpy.types.Operator):
"""Copy a Jupyter Server URL to the system clipboard. The system clipboard will be cleared after a timeout, unless otherwise altered."""

bl_idname = ct.OperatorType.CopyJupyURLToClip
bl_label = 'Copy Jupyter Server URL'

_timer = None
_start_time = None
url_type: bpy.props.EnumProperty(
name='Jupyter URL Type',
description='The type of Jupyter URL to copy to the clipboard.',
items=[
(
'API',
'API Server',
'The API server, which allows connections from any Jupyter client.',
),
(
'LAB',
'Lab Server',
'The Lab server, which provides a browser-based IDE.',
),
],
default='API',
)

def jupyter_url(
self,
jupyter_ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
jupyter_port: int,
) -> str:
match self.url_type:
case 'API':
return jkern.jupyter_api_url(jupyter_ip, jupyter_port)
case 'LAB':
return jkern.jupyter_lab_url(jupyter_ip, jupyter_port)

msg = 'URL Type not valid'
raise ValueError(msg)

@classmethod
def poll(cls, _: bpy.types.Context) -> bool:
return jkern.is_kernel_running()

def modal(
self, context: bpy.types.Context, event: bpy.types.Event
) -> set[ct.BLOperatorStatus]:
if (
event.type == 'TIMER'
and (datetime.datetime.now() - self._start_time).seconds
> CLIPBOARD_CLEAR_DELAY // 2
):
# Stop the Timer
wm = context.window_manager
wm.event_timer_remove(self._timer)
self._timer = None
self.start_time = None

# Clear Clipboard
## - ONLY if the clipboard still contains our token.
## - This helps prevent interfering with the user's life.
clipboard: str = pyperclipfix.paste()
if secrets.compare_digest(
clipboard,
self.jupyter_url(
ipaddress.IPv4Address(context.scene.jupyter_ip),
context.scene.jupyter_port,
),
):
pyperclipfix.copy(' ')
self.report(
{'INFO'},
'Cleared System Clipboard',
)

return {'FINISHED'}
return {'PASS_THROUGH'}

def execute(self, context: bpy.types.Context) -> set[ct.BLOperatorStatus]:
# Setup Timer
wm = context.window_manager
self._timer = wm.event_timer_add(CLIPBOARD_CLEAR_DELAY, window=context.window)
wm.modal_handler_add(self)

self._start_time = datetime.datetime.now()

# Set System Clipboard to Jupyter Server URL
pyperclipfix.copy(
self.jupyter_url(
ipaddress.IPv4Address(context.scene.jupyter_ip),
context.scene.jupyter_port,
)
)
self.report(
{'INFO'},
f'Copied URL to System Clipboard. Will clear in {CLIPBOARD_CLEAR_DELAY} seconds.',
)

return {'RUNNING_MODAL'}

def cancel(self, context):
jkern.stop_kernel()

wm = context.window_manager
wm.event_timer_remove(self._timer)

self._timer = None


####################
# - Blender Registration
####################
BL_REGISTER = [CopyJupyURLToClip]
BL_HANDLERS: ct.BLHandlers = ct.BLHandlers()
BL_KEYMAP_ITEMS: list[ct.BLKeymapItem] = []
2 changes: 1 addition & 1 deletion bpy_jupyter/operators/start_jupyter_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@


class StartJupyterKernel(bpy.types.Operator):
"""Operator that starts a Jupyter kernel within Blender."""
"""Start a notebook kernel, and Jupyter Lab server, from within Blender."""

bl_idname = ct.OperatorType.StartJupyterKernel
bl_label = 'Start Jupyter Kernel'
Expand Down
2 changes: 1 addition & 1 deletion bpy_jupyter/operators/stop_jupyter_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@


class StopJupyterKernel(bpy.types.Operator):
"""Operator that starts a Jupyter kernel within Blender."""
"""Stop a notebook kernel and Jupyter Lab server running within Blender."""

bl_idname = ct.OperatorType.StopJupyterKernel
bl_label = 'Stop Jupyter Kernel'
Expand Down
102 changes: 70 additions & 32 deletions bpy_jupyter/panels/jupyter_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
(
'IPYKERNEL',
'IPyKernel',
'A traditional, well-tested Python notebook kernel',
'IPyKernel is the standard Python notebook kernel',
),
# (
# 'MARIMO',
Expand All @@ -45,27 +45,27 @@
# Behavior
bpy.types.Scene.jupyter_notebook_dir = bpy.props.StringProperty(
name='Notebook Root Folder',
description='The default notebook folder.',
description='The top-level folder in which the Jupyter server can find and store notebooks.',
subtype='DIR_PATH',
default=platformdirs.user_desktop_dir(),
)
bpy.types.Scene.jupyter_launch_browser = bpy.props.BoolProperty(
name='Auto-Launch Browser?',
description='Whether to launch a browser automatically when starting the kernel',
description='Whether to launch the default browser automatically, pointing to Jupyter Lab, when starting Jupyter.',
default=True,
)

# Networking
## - See https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers
bpy.types.Scene.jupyter_ip = bpy.props.StringProperty(
name='Jupyter Network IP',
description='IP address of the Jupyter server',
description='IPv4 address of the launched Jupyter server',
default='127.0.0.1',
)

bpy.types.Scene.jupyter_port = bpy.props.IntProperty(
name='Jupyter Network Port',
description='Network port of the Jupyter server',
description='Network port of the launched Jupyter server',
min=1024,
max=49151,
default=10462,
Expand Down Expand Up @@ -111,32 +111,65 @@ def draw(self, context: bpy.types.Context) -> None:
split_fac = 0.25
layout = self.layout

# Kernel Panel
####################
# - Section: Stop/Start Kernel
####################
# Operator: Start Kernel
row = layout.row(align=True)
row.enabled = not jkern.is_kernel_running()
split = row.split(factor=split_fac, align=True)
split = row.split(factor=0.85, align=True)
split.alignment = 'RIGHT'
split.label(text='Kernel')
split.prop(context.scene, 'jupyter_kernel_type', text='')
split.operator(ct.OperatorType.StartJupyterKernel)
split.prop(
context.scene,
'jupyter_launch_browser',
icon=ct.Icon.LaunchBrowser,
toggle=True,
icon_only=True,
)

# Operator: Stop Kernel
row = layout.row(align=True)
row.enabled = not jkern.is_kernel_running()
split = row.split(factor=split_fac, align=True)
split.alignment = 'RIGHT'
split.label(text='Root Dir')
split.prop(context.scene, 'jupyter_notebook_dir', text='')
row.operator(ct.OperatorType.StopJupyterKernel)

####################
# - Section: Basic Options
####################
header, body = layout.panel(
'jupyter_network_subpanel',
'jupyter_basic_options_subpanel',
default_closed=False,
)
header.label(text='Basic Options')
if body is not None:
# Kernel Panel
row = body.row(align=True)
row.enabled = not jkern.is_kernel_running()
split = row.split(factor=split_fac, align=True)
split.alignment = 'RIGHT'
split.label(text='Kernel')
split.prop(context.scene, 'jupyter_kernel_type', text='')

row = body.row(align=True)
row.enabled = not jkern.is_kernel_running()
split = row.split(factor=split_fac, align=True)
split.alignment = 'RIGHT'
split.label(text='Root Dir')
split.prop(context.scene, 'jupyter_notebook_dir', text='')

####################
# - Section: Network Options
####################
header, body = layout.panel(
'jupyter_network_options_subpanel',
default_closed=True,
)
header.label(text='Network')
header.label(text='Network Options')
if body is not None:
body_row = body.row(align=True)
body_row.enabled = not jkern.is_kernel_running()
body_split = body_row.split(factor=split_fac, align=True)
body_split.alignment = 'RIGHT'
body_split.label(text='IP')
body_split.label(text='IPv4')
body_split.prop(context.scene, 'jupyter_ip', text='')

body_row = body.row(align=True)
Expand All @@ -146,23 +179,28 @@ def draw(self, context: bpy.types.Context) -> None:
body_split.label(text='Port')
body_split.prop(context.scene, 'jupyter_port', text='')

# Operator: Start Kernel
row = layout.row(align=True)
row.enabled = not jkern.is_kernel_running()
split = row.split(factor=0.85, align=True)
split.alignment = 'RIGHT'
split.operator(ct.OperatorType.StartJupyterKernel)
split.prop(
context.scene,
'jupyter_launch_browser',
icon=ct.Icon.LaunchBrowser,
toggle=True,
icon_only=True,
####################
# - Section: Copyable URLs
####################
header, body = layout.panel(
'jupyter_copy_urls_subpanel',
default_closed=False,
)
header.label(text='URLs')
if body is not None:
# Label
row = body.row(align=False)
row.alignment = 'CENTER'
row.label(text='Copy URLs to Clipboard:')

# Operator: Stop Kernel
row = layout.row(align=True)
row.operator(ct.OperatorType.StopJupyterKernel)
# Operators
row = body.row(align=False)

op = row.operator(ct.OperatorType.CopyJupyURLToClip, text='Lab URL')
op.url_type = 'LAB'

op = row.operator(ct.OperatorType.CopyJupyURLToClip, text='API URL')
op.url_type = 'API'


####################
Expand Down
Loading

0 comments on commit 30622c8

Please sign in to comment.