Skip to content

Commit b1b9fa6

Browse files
agriyakhetarpalkrassowskigithub-actions[bot]
authored
Add a "Run" (▶️) button next to each cell (#205)
* Disable `@jupyterlab/notebook-extension:factory` * Add a run button factory * Override notebook factory, use separate plugins * Apply suggestions from code review Co-authored-by: Michał Krassowski <[email protected]> * Drop `runCellButtonPlugin` * `layout` is just `PanelLayout`, no need to cast * Drop translator imports * Clean up, define app * Add notebook and panel imports * Oops, fix tooltip translation * Drop `runCellButtonPlugin` import * Conditionally show the run button * Add runCell SVG icon * Don't resize all toolbar button components * Fix sizing and placement of the orange buttons * Update Playwright Snapshots * Drop extra attributes from run cell SVG Co-authored-by: Michał Krassowski <[email protected]> * Use CSS to show/hide the cells * Fix sizes for code input areas * Fix lint * Hide input prompts and adjust position Co-Authored-By: Michał Krassowski <[email protected]> * Hide the `•` (U+2022) character for dirty cells * Add tests * Fix selector for hiding dirty cell indicators * Add license from upstream PRs and JupyterLab * Update src/run-button.ts * Input area editor should not be flex to allow multi-line Markdown --------- Co-authored-by: Michał Krassowski <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 79ec64c commit b1b9fa6

12 files changed

+328
-5
lines changed

lite/jupyter-lite.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080

8181
"@jupyterlab/filebrowser-extension:widget",
8282

83+
"@jupyterlab/notebook-extension:factory",
84+
8385
"@jupyterlab/workspaces-extension",
8486

8587
"@jupyterlab/console-extension",

src/icons.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import competitionSvg from '../style/icons/competition.svg';
2121
import notebookSvg from '../style/icons/notebook.svg';
2222
import logoSvg from '../style/icons/logo.svg';
2323
import runSvg from '../style/icons/run.svg';
24+
import runCellSvg from '../style/icons/run-cell.svg';
2425
import refreshSvg from '../style/icons/refresh.svg';
2526
import stopSvg from '../style/icons/stop.svg';
2627
import fastForwardSvg from '../style/icons/fast-forward.svg';
@@ -89,6 +90,10 @@ export namespace EverywhereIcons {
8990
name: 'everywhere:logo',
9091
svgstr: logoSvg
9192
});
93+
export const runCell = new LabIcon({
94+
name: 'everywhere:run-cell',
95+
svgstr: runCellSvg
96+
});
9297
export const downloadCaret = new LabIcon({
9398
name: 'everywhere:download-caret',
9499
svgstr: downloadCaretSvg

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828

2929
import { KERNEL_DISPLAY_NAMES, switchKernel } from './kernels';
3030
import { singleDocumentMode } from './single-mode';
31+
import { notebookFactoryPlugin } from './run-button';
3132

3233
/**
3334
* Generate a shareable URL for the currently active notebook.
@@ -580,6 +581,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
580581

581582
export default [
582583
viewOnlyNotebookFactoryPlugin,
584+
notebookFactoryPlugin,
583585
plugin,
584586
notebookPlugin,
585587
files,

src/run-button.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Most of the code in this file was inspired by the following PRs in JupyterLab upstream:
2+
// 1. https://github.com/jupyterlab/jupyterlab/pull/16602
3+
// 2. https://github.com/jupyterlab/jupyterlab/pull/17775
4+
5+
// SPDX-License-Identifier: BSD-3-Clause
6+
7+
// JupyterLab uses a shared copyright model that enables all contributors to maintain
8+
// the copyright on their contributions. All code is licensed under the terms of the
9+
// revised BSD license.
10+
11+
// Copyright (c) 2015-2025 Project Jupyter Contributors
12+
// All rights reserved.
13+
14+
// Redistribution and use in source and binary forms, with or without
15+
// modification, are permitted provided that the following conditions are met:
16+
17+
// 1. Redistributions of source code must retain the above copyright notice, this
18+
// list of conditions and the following disclaimer.
19+
20+
// 2. Redistributions in binary form must reproduce the above copyright notice,
21+
// this list of conditions and the following disclaimer in the documentation
22+
// and/or other materials provided with the distribution.
23+
24+
// 3. Neither the name of the copyright holder nor the names of its
25+
// contributors may be used to endorse or promote products derived from
26+
// this software without specific prior written permission.
27+
28+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
29+
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
30+
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
31+
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
32+
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
33+
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
34+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
35+
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
36+
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
37+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38+
39+
import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application';
40+
import { IEditorServices } from '@jupyterlab/codeeditor';
41+
import { ToolbarButton } from '@jupyterlab/ui-components';
42+
import { Widget, PanelLayout } from '@lumino/widgets';
43+
import { Notebook, NotebookPanel } from '@jupyterlab/notebook';
44+
import { EverywhereIcons } from './icons';
45+
46+
const INPUT_PROMPT_CLASS = 'jp-InputPrompt';
47+
const INPUT_AREA_PROMPT_INDICATOR_CLASS = 'jp-InputArea-prompt-indicator';
48+
const INPUT_AREA_PROMPT_INDICATOR_EMPTY_CLASS = 'jp-InputArea-prompt-indicator-empty';
49+
const INPUT_AREA_PROMPT_RUN_CLASS = 'jp-InputArea-prompt-run';
50+
51+
export interface IInputPromptIndicator extends Widget {
52+
executionCount: string | null;
53+
}
54+
55+
export interface IInputPrompt extends IInputPromptIndicator {
56+
runButton?: ToolbarButton;
57+
}
58+
59+
export class InputPromptIndicator extends Widget implements IInputPromptIndicator {
60+
private _executionCount: string | null = null;
61+
62+
constructor() {
63+
super();
64+
this.addClass(INPUT_AREA_PROMPT_INDICATOR_CLASS);
65+
}
66+
67+
get executionCount(): string | null {
68+
return this._executionCount;
69+
}
70+
71+
set executionCount(value: string | null) {
72+
this._executionCount = value;
73+
if (value) {
74+
this.node.textContent = `[${value}]:`;
75+
this.removeClass(INPUT_AREA_PROMPT_INDICATOR_EMPTY_CLASS);
76+
} else {
77+
this.node.textContent = '[ ]:';
78+
this.addClass(INPUT_AREA_PROMPT_INDICATOR_EMPTY_CLASS);
79+
}
80+
}
81+
}
82+
83+
export class JEInputPrompt extends Widget implements IInputPrompt {
84+
private _customExecutionCount: string | null = null;
85+
private _promptIndicator: InputPromptIndicator;
86+
private _runButton: ToolbarButton;
87+
88+
constructor(private _app: JupyterFrontEnd) {
89+
super();
90+
this.addClass(INPUT_PROMPT_CLASS);
91+
92+
const layout = (this.layout = new PanelLayout());
93+
this._promptIndicator = new InputPromptIndicator();
94+
layout.addWidget(this._promptIndicator);
95+
this._runButton = new ToolbarButton({
96+
icon: EverywhereIcons.runCell,
97+
onClick: () => {
98+
this._app.commands.execute('notebook:run-cell');
99+
},
100+
tooltip: 'Run this cell'
101+
});
102+
this._runButton.addClass(INPUT_AREA_PROMPT_RUN_CLASS);
103+
this._runButton.addClass('je-cell-run-button');
104+
layout.addWidget(this._runButton);
105+
}
106+
107+
get executionCount(): string | null {
108+
return this._customExecutionCount;
109+
}
110+
111+
set executionCount(value: string | null) {
112+
this._customExecutionCount = value;
113+
this._promptIndicator.executionCount = value;
114+
}
115+
}
116+
117+
export namespace JENotebookContentFactory {
118+
export interface IOptions extends Notebook.ContentFactory.IOptions {
119+
app: JupyterFrontEnd;
120+
}
121+
}
122+
123+
export class JENotebookContentFactory extends Notebook.ContentFactory {
124+
private _app: JupyterFrontEnd;
125+
126+
constructor(options: JENotebookContentFactory.IOptions) {
127+
super(options);
128+
this._app = options.app;
129+
}
130+
131+
createInputPrompt(): JEInputPrompt {
132+
return new JEInputPrompt(this._app);
133+
}
134+
135+
createNotebook(options: Notebook.IOptions): Notebook {
136+
return new Notebook(options);
137+
}
138+
}
139+
140+
/**
141+
* Plugin that provides the custom notebook factory with run buttons
142+
*/
143+
export const notebookFactoryPlugin: JupyterFrontEndPlugin<NotebookPanel.IContentFactory> = {
144+
id: 'jupytereverywhere:notebook-factory',
145+
description: 'Provides notebook cell factory with input prompts',
146+
provides: NotebookPanel.IContentFactory,
147+
requires: [IEditorServices],
148+
autoStart: true,
149+
activate: (app: JupyterFrontEnd, editorServices: IEditorServices) => {
150+
const editorFactory = editorServices.factoryService.newInlineEditor;
151+
152+
const factory = new JENotebookContentFactory({
153+
editorFactory,
154+
app
155+
});
156+
157+
return factory;
158+
}
159+
};

style/base.css

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,92 @@
119119
z-index: var(--je-toastify-z-index);
120120
}
121121

122+
.jp-InputArea-prompt-indicator {
123+
left: 0;
124+
line-height: 25px;
125+
}
126+
127+
.jp-InputArea-prompt-indicator::before {
128+
left: 0;
129+
line-height: 25px;
130+
top: 5px;
131+
}
132+
133+
.jp-InputArea-prompt-run.je-cell-run-button {
134+
position: absolute;
135+
right: 8px;
136+
top: 50%;
137+
transform: translateY(-50%) scale(calc(var(--je-scale) * 2.5));
138+
opacity: 0;
139+
transition: opacity 0.15s ease-in-out;
140+
}
141+
142+
/* Don't show run button on raw cells */
143+
.jp-RawCell .jp-InputArea-prompt-run.je-cell-run-button {
144+
display: none;
145+
}
146+
147+
.jp-Cell:hover:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button,
148+
.jp-Cell.jp-mod-active:not(.jp-RawCell) .jp-InputArea-prompt-run.je-cell-run-button {
149+
opacity: 1;
150+
}
151+
152+
/* Hide all dirty state indicators */
153+
.jp-Cell.jp-mod-dirty .jp-Cell-inputCollapser,
154+
.jp-InputCollapser.jp-Cell-inputCollapser,
155+
.jp-OutputCollapser.jp-Cell-outputCollapser,
156+
.jp-Collapser-child {
157+
display: none;
158+
}
159+
160+
.jp-Notebook .jp-CodeCell:hover .jp-InputArea-prompt-indicator,
161+
.jp-Notebook .jp-CodeCell.jp-mod-active .jp-InputArea-prompt-indicator,
162+
.jp-Notebook .jp-CodeCell.jp-mod-selected .jp-InputArea-prompt-indicator,
163+
.jp-Notebook .jp-CodeCell:hover .jp-InputPrompt > .jp-InputArea-prompt-indicator,
164+
.jp-Notebook .jp-CodeCell.jp-mod-active .jp-InputPrompt > .jp-InputArea-prompt-indicator,
165+
.jp-Notebook .jp-CodeCell.jp-mod-selected .jp-InputPrompt > .jp-InputArea-prompt-indicator {
166+
visibility: hidden;
167+
}
168+
169+
.jp-Notebook .jp-Cell .jp-OutputPrompt {
170+
visibility: visible;
171+
}
172+
173+
.jp-Cell.jp-mod-dirty::before {
174+
display: none;
175+
}
176+
177+
.jp-Cell.jp-mod-dirty .jp-InputArea::before {
178+
display: none;
179+
}
180+
181+
/* Hide the • (U+2022) character for dirty cells */
182+
.jp-Cell.jp-mod-dirty .jp-InputPrompt.jp-InputArea-prompt::before {
183+
content: '';
184+
}
185+
186+
.jp-InputArea {
187+
position: relative;
188+
}
189+
190+
.jp-InputArea-editor {
191+
border-radius: var(--je-round-corners);
192+
padding: calc(var(--je-scale) * 5px);
193+
}
194+
195+
/* Ensure markdown and raw cells have a height that's
196+
consistent with code cells */
197+
.jp-MarkdownCell .jp-InputArea,
198+
.jp-RawCell .jp-InputArea {
199+
min-height: 40px;
200+
}
201+
202+
.jp-MarkdownCell .jp-RenderedHTMLCommon,
203+
.jp-MarkdownCell .jp-InputArea-editor {
204+
min-height: 32px;
205+
align-items: center;
206+
}
207+
122208
.jp-SideBar {
123209
/* Override colors in sidebar */
124210
--jp-layout-color1: #e6e6e6;
@@ -187,11 +273,6 @@
187273
--jp-border-width: calc(var(--je-scale) * 1px);
188274
}
189275

190-
.jp-InputArea-editor {
191-
border-radius: var(--je-round-corners);
192-
padding: calc(var(--je-scale) * 5px);
193-
}
194-
195276
/* View Only header */
196277
.je-ViewOnlyHeader {
197278
min-height: 40px;

style/icons/run-cell.svg

Lines changed: 3 additions & 0 deletions
Loading

ui-tests/tests/jupytereverywhere.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,3 +701,74 @@ test.describe('Kernel commands should use memory terminology', () => {
701701
await promise;
702702
});
703703
});
704+
705+
test.describe('Per cell run buttons', () => {
706+
test('Clicking the run button executes code and shows output', async ({ page }) => {
707+
await page.waitForSelector('.jp-NotebookPanel');
708+
709+
const cell = page.locator('.jp-CodeCell').first();
710+
const editor = cell.getByRole('textbox');
711+
712+
await editor.click(); // make it active so the run button is visible
713+
await editor.fill('print("hello from jupytereverywhere")');
714+
715+
const runBtn = cell.locator('.je-cell-run-button');
716+
await expect(runBtn).toBeVisible();
717+
718+
await runBtn.click();
719+
720+
const output = cell.locator('.jp-Cell-outputArea');
721+
await expect(output).toBeVisible({ timeout: 20000 });
722+
await expect(output).toContainText('hello from jupytereverywhere', { timeout: 20000 });
723+
});
724+
725+
test('Hides input execution count on hover/active', async ({ page }) => {
726+
await page.waitForSelector('.jp-NotebookPanel');
727+
728+
// Ensure two cells so we can toggle active state cleanly, and
729+
// put some output in the first cell so it has an OutputPrompt.
730+
await runCommand(page, 'notebook:insert-cell-below');
731+
732+
const firstCell = page.locator('.jp-CodeCell').first();
733+
const secondCell = page.locator('.jp-CodeCell').nth(1);
734+
735+
await firstCell.getByRole('textbox').click();
736+
await firstCell.getByRole('textbox').fill('1+1');
737+
await firstCell.locator('.je-cell-run-button').click();
738+
await expect(firstCell.locator('.jp-Cell-outputArea')).toBeVisible({ timeout: 10000 });
739+
740+
const inputIndicator = firstCell.locator('.jp-InputArea-prompt-indicator');
741+
const outputPrompt = firstCell.locator('.jp-OutputPrompt');
742+
743+
// When the first cell is active, the input indicator should be hidden
744+
await firstCell.click();
745+
await expect(inputIndicator).toBeHidden();
746+
747+
// Make another cell active, so the first is not active/selected
748+
await secondCell.click();
749+
await expect(inputIndicator).toBeVisible();
750+
751+
// Hover over the first cell; input indicator should get hidden again
752+
// However, the output prompt should remain visible at all times
753+
await firstCell.hover();
754+
await expect(inputIndicator).toBeHidden();
755+
await expect(outputPrompt).toBeVisible();
756+
});
757+
758+
test('Run button is hidden on Raw cells and reappears on Code cells', async ({ page }) => {
759+
await page.waitForSelector('.jp-NotebookPanel');
760+
761+
const cell = page.locator('.jp-Cell').first();
762+
const runBtn = cell.locator('.je-cell-run-button');
763+
764+
await runCommand(page, 'notebook:change-cell-to-raw');
765+
await expect(runBtn).toBeHidden();
766+
767+
await runCommand(page, 'notebook:change-cell-to-code');
768+
await cell.click();
769+
await expect(runBtn).toBeVisible();
770+
771+
await runCommand(page, 'notebook:change-cell-to-markdown');
772+
await expect(runBtn).toBeVisible();
773+
});
774+
});
490 Bytes
Loading
498 Bytes
Loading
454 Bytes
Loading

0 commit comments

Comments
 (0)