Point and click directory navigation inside a Jupyter notebook

jupyter
Author

Wasim Lorgat

Published

June 23, 2022

Here’s a tiny demo of a point-and-click navigation interface with rich output powered by Jupyter notebooks and ipywidgets! I originally mentioned the idea in a previous TIL. I also tweeted about it which is the best place to leave any comments or questions if you’d like.

TL;DR

If all you need is a copy-pastable code snippet, here you go. Read on for a more in-depth description.

from base64 import b64encode
from functools import partial
from IPython.display import Javascript, display
from ipywidgets import Box, Button, Layout
from pathlib import Path

def create_code_cell(code):
    encoded_code = b64encode(code.encode()).decode()
    display(Javascript(f"""
        var code = IPython.notebook.insert_cell_below('code');
        code.set_text(atob("{encoded_code}"));
        code.execute();
        code.focus_cell()"""))

def on_click_dir(path, button): create_code_cell(f"ls('{path}')")
def on_click_file(path, button): create_code_cell(f"Path('{path}')")

def ls(root=Path()):
    if isinstance(root, str): root = Path(root).expanduser()
    paths = sorted(root.iterdir())
    if not paths: return
    button_layout = Layout(width='fit-content')
    buttons = []
    for path in paths:
        button = Button(description=str(path.relative_to(root)), layout=button_layout)
        button.on_click(partial(on_click_dir if path.is_dir() else on_click_file, path))
        buttons.append(button)
    box_layout = Layout(overflow='scroll hidden', height='500px', display='flex',
                        flex_flow='column wrap', align_content='flex-start')
    return Box(buttons, layout=box_layout)

Minimal implementation

We start by defining a function to create and execute a code cell below the focused cell (see my previous TIL if you’d like more detail on this part):

def create_code_cell(code):
    encoded_code = b64encode(code.encode()).decode()
    display(Javascript(f"""
        var code = IPython.notebook.insert_cell_below('code');
        code.set_text(atob("{encoded_code}"));
        code.execute();
        code.focus_cell()
    """))

We’re going to be using button widgets, which expect an on-click callback, so let’s define those next. The callback is expected to be a function accepting a single argument, button, to which the button object itself is passed - although we won’t be using it. We need to know the path that was clicked on as well, so we’ll have to partial that in later:

def on_click_dir(path, button): create_code_cell(f"ls('{path}')")
def on_click_file(path, button): create_code_cell(f"Path('{path}')")

Test if it works:

on_click_file(Path('point-and-click-directory-navigation-inside-a-jupyter-notebook.ipynb'), None)
Path('point-and-click-directory-navigation-inside-a-jupyter-notebook.ipynb')
PosixPath('point-and-click-directory-navigation-inside-a-jupyter-notebook.ipynb')

Neat! The cell above this was created by calling on_click_file.

Finally, we implement a straightforward minimal ls function using Button widgets for Paths, and wrapping those in a VBox widget:

from ipywidgets import VBox

def ls(root=Path()):
    if isinstance(root, str): root = Path(root).expanduser()
    paths = sorted(root.iterdir())
    if not paths: return
    buttons = []
    for path in paths:
        button = Button(description=str(path))
        button.on_click(partial(on_click_dir if path.is_dir() else on_click_file, path))
        buttons.append(button)
    return VBox(buttons)

Improved styling

I don’t like how the implementation above is styled, so here is another with a few purely stylistic improvements:

def ls(root=Path()):
    if isinstance(root, str): root = Path(root).expanduser()
    paths = sorted(root.iterdir())
    if not paths: return
    button_layout = Layout(width='fit-content')
    buttons = []
    for path in paths:
        button = Button(description=str(path.relative_to(root)), layout=button_layout)
        button.on_click(partial(on_click_dir if path.is_dir() else on_click_file, path))
        buttons.append(button)
    box_layout = Layout(overflow='scroll hidden', height='500px', display='flex',
                        flex_flow='column wrap', align_content='flex-start')
    return Box(buttons, layout=box_layout)
ls('~/code/fastai')

Unfortunately, my current blog setup doesn’t support widgets, but you should be able to run this locally. You can also check out the demo video in my tweet.

If you pay close attention to the demo video, you’ll notice that it’s still styled slightly differently to what we’ve built here. Some styles can’t be changed through ipywidget’s style interface, so that was achieved by manually writing CSS with the %%html magic command followed by a <style>...</style> tag, and then assigning a class to the buttons and boxes using their add_class method. I also implemented a custom widget with a small render JavaScript function that resized the output grid until it fit the width of the screen.

I’m really excited with how this turned out! And it was far simpler than I’d expected. I’ll definitely be exploring the point-and-click navigation pattern more. I’m thinking about trying it out for exploring documentation about Python objects.