A Custom Sudoku Widget

Creating a custom Jupyter widget for editing and displaying Sudoku puzzles.
programming
python
puzzles
Author

Aswin van Woudenberg

Published

February 26, 2021

In this post I’ll demonstrate how to build a custom Jupyter widget for displaying and editing Sudoku puzzles. I’ll also show how to create a Sudoku solver that uses this widget.

How to play Sudoku

Only read this if you’ve been living under a rock, otherwise skip to the good stuff.

In Sudoku, the objective is to fill a 9x9 grid with digits so that each column, each row, and each of the nine 3x3 blocks that compose the grid contain all of the digits from 1 to 9.

An example Sudoku puzzle:

8 5 6 1
9 4
2 3 8
4 2
7 9 5
3 8
5 8
7 1
6 4

The solution to this puzzle looks like this:

3 8 5 9 6 1 4 2 7
9 2 4 8 7 3 1 5 6
1 6 7 5 4 2 3 9 8
5 4 3 1 8 7 9 6 2
7 1 8 2 9 6 5 4 3
2 9 6 4 3 5 8 7 1
4 7 1 6 5 8 2 3 9
8 3 9 7 2 4 6 1 5
6 5 2 3 1 9 7 8 4

The first row (3 8 5 9 6 1 4 2 7) contains all digits from 1 to 9. Also the first column (3 9 1 5 7 2 4 8 6) contains all digits from 1 to 9, as does the first subblock (3 8 5 - 9 2 4 - 1 6 7) and all the other ones.

Creating the widget

There are two ways to create Jupyter widgets - an easy way and a more complicated way. For this post, we will be using the easy way, which involves creating two cells in a Jupyter notebook. The first cell contains the Python code for the back-end of the widget, while the second cell contains the JavaScript for the front-end.

If you want to create a proper Python package that can be installed with pip install, you can follow the more complicated way. A good resource is this tutorial.

For this post, we’ll stick with the easy way.

The Python back-end

The following code defines a Python class named Sudoku that extends the DOMWidget class from the ipywidgets library.

from traitlets import Unicode, Bool, Int, List, validate, observe, TraitError, All
from ipywidgets import DOMWidget, register
import copy

@register
class Sudoku(DOMWidget):
    _view_name = Unicode('SudokuView').tag(sync=True)
    _view_module = Unicode('sudoku_widget').tag(sync=True)
    _view_module_version = Unicode('0.1.0').tag(sync=True)
    
    # Attributes
    fixed = List(trait=Bool(), default_value=[False] * 81, minlen=81, maxlen=81, help="A list of booleans that indicate whether a value is part of the puzzle.").tag(sync=True)
    _value = List(trait=Int(), default_value=[0] * 81, minlen=81, maxlen=81, help="A list of integers for each cell.").tag(sync=True)
    disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True)

    # Basic validator for value
    @validate('_value')
    def _valid_value(self, proposal):
        for i in proposal['value']:
            if i < 0 or i > 9:
                raise TraitError('Invalid value: all elements must be numbers from 0 to 9')
        return proposal['value']
    
    @property
    def value(self):
        return copy.deepcopy(self._value)
    
    @value.setter
    def value(self, v):
        self._value = v

    def __init__(self,*args,**kwargs):
        kwargs['_value'] = kwargs.pop('value', [0]*81)
        DOMWidget.__init__(self,*args,**kwargs)
    
    def __getitem__(self,index):
        return self._value[index]
    
    
    def __setitem__(self,index,val):
        vals = self.value
        vals[index] = val
        self._value = vals

This Sudoku class has the following attributes:

  • value: A list of integers that represents the current state of the puzzle.
  • fixed: A list of booleans that indicates whether a value is part of the original puzzle and cannot be changed by the user.
  • disabled: A boolean that enables or disables user changes to the puzzle.

The fixed and value attributes are defined using the List trait from the traitlets library. The validate decorator is used to define a validator for the value attribute that checks that all elements are numbers from 0 to 9.

The __getitem__ and __setitem__ methods are implemented to allow indexing and assignment of elements in the value attribute.

The @register decorator registers the Sudoku class as an ipywidget, which allows it to be displayed and interacted with in a Jupyter environment.

The JavaScript front-end

The front-end contains a bit more code.

%%javascript
require.undef('sudoku_widget');

define('sudoku_widget', ["@jupyter-widgets/base"], function(widgets) {
    
    // Define the SudokuView
    class SudokuView extends widgets.DOMWidgetView {
        
        // Render the view.
        render() {
            this.sudoku_table = document.createElement('table');
            this.sudoku_table.style.borderCollapse = 'collapse';
            this.sudoku_table.style.marginLeft = '0';
            
            for (let i=0; i<3; i++) {
                let colgroup = document.createElement('colgroup');
                colgroup.style.border = 'solid medium';
                for (let j=0; j<3; j++) {
                    let col = document.createElement('col');
                    col.style.border = 'solid thin';
                    col.style.width = '2em';
                    colgroup.appendChild(col);
                }
                this.sudoku_table.appendChild(colgroup);
            }
            
            for (let t=0; t<3; t++) {
                let tbody = document.createElement('tbody');
                tbody.style.border = 'solid medium';
                for (let r=0; r<3; r++) {
                    let tr = document.createElement('tr');
                    tr.style.height = '2em';
                    tr.style.border = 'solid thin';
                    for (let c=0; c<9; c++) {
                        let td = document.createElement('td');
                        tr.appendChild(td);
                    }
                    tbody.appendChild(tr);
                }
                this.sudoku_table.appendChild(tbody);
            }
            
            this.el.appendChild(this.sudoku_table);
            
            this.model_changed();
        
            // Python -> JavaScript update
            this.model.on('change', this.model_changed, this);
        }

        model_changed() {
            let tds = this.sudoku_table.getElementsByTagName('td');
            let disabled = this.model.get('disabled');
                        
            for (let i=0; i < 81; i++) {
                let td = tds[i];
                td.innerText = ''; // Delete td contents
                td.style.textAlign = 'center';
                td.style.height = '2em';
                let value = this.model.get('_value')[i];
                let fixed = this.model.get('fixed')[i];

                if (fixed && value > 0) {
                    let b = document.createElement('b');
                    b.innerText = value;
                    td.appendChild(b);
                } else if (disabled && value > 0) {
                    td.innerText = value;
                } else if (!disabled && !fixed) {
                    let input = document.createElement('input');
                    input.type = 'text';
                    input.maxLength = 1;
                    input.style.top = 0;
                    input.style.left = 0;
                    input.style.margin = 0;
                    input.style.height = '100%';
                    input.style.width = '100%';
                    input.style.border = 'none';
                    input.style.textAlign = 'center';
                    input.style.marginTop = 0;
                    input.style.padding = 0;
                    input.value = (value > 0 ? value : '');
                    input.oninput = this.input_input.bind(this, i);
                    input.onchange = this.input_changed.bind(this, i); // JavaScript -> Python update
                    td.appendChild(input);
                }
            }
            
        }
        
        input_input(i) {
            this.sudoku_table.getElementsByTagName('td')[i].getElementsByTagName('input')[0].value = 
                this.sudoku_table.getElementsByTagName('td')[i].
                    getElementsByTagName('input')[0].value.replace(/[^1-9]/g,'');
        }
        
        input_changed(i) {
            this.sudoku_table.getElementsByTagName('td')[i].getElementsByTagName('input')[0].value = 
                this.sudoku_table.getElementsByTagName('td')[i].
                    getElementsByTagName('input')[0].value.replace(/[^1-9]/g,'');
            let v = parseInt(this.sudoku_table.getElementsByTagName('td')[i].getElementsByTagName('input')[0].value) || 0;
            let value = this.model.get('_value').slice();
            value[i] = v;
            this.model.set('_value', value);
            this.model.save_changes();
        }
        
    }

    return {
        SudokuView: SudokuView
    }
    
});

The define function defines the sudoku_widget module, which depends on the @jupyter-widgets/base module. It creates a SudokuView class that extends the base class widgets.DOMWidgetView, which is responsible for rendering and updating the widget.

The render method of the SudokuView class creates a table element with 9 rows and 9 columns, representing the Sudoku game board. It adds the table to the widget’s HTML element, and registers a listener for model changes. The model_changed method is called when the model changes, and it updates the widget’s HTML to reflect the new model state.

The input_input and input_changed methods are event handlers that respond to user input on the Sudoku board. They update the model and the widget’s HTML to reflect the new user input.

How to use this widget

Once we have executed these two cells, we’re good to use our widget.

import ipywidgets as widgets

puzzle = [
    0,8,5, 0,6,1, 0,0,0,
    9,0,4, 0,0,0, 0,0,0,
    0,0,0, 0,0,2, 3,0,8,
    
    0,4,0, 0,0,0, 0,0,2,
    7,0,0, 0,9,0, 5,0,0,
    0,0,0, 0,3,0, 8,0,0,
    
    0,0,0, 0,5,8, 0,0,0,
    0,0,0, 7,0,0, 0,1,0,
    6,0,0, 0,0,0, 0,0,4]

fixed_digits = [v > 0 for v in puzzle]

sudoku = Sudoku(value=puzzle, fixed=fixed_digits, disabled=False)

display(sudoku)

The widget accepts three parameters: value, fixed and disabled. The parameter value is a list of digits. A digit of 0 means empty. The parameter fixed is a list of boolean values, where True means a digit can’t be edited and will be printed in bold. The boolean disabled indicates whether a user can edit digits.

One can read the values in a grid like this:

print(sudoku.value)
[0, 8, 5, 0, 6, 1, 0, 0, 0, 9, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 8, 0, 4, 0, 0, 0, 0, 0, 0, 2, 7, 0, 0, 0, 9, 0, 5, 0, 0, 0, 0, 0, 0, 3, 0, 8, 0, 0, 0, 0, 0, 0, 5, 8, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 1, 0, 6, 0, 0, 0, 0, 0, 0, 0, 4]

Running the next cell would show the solution by updating the widget.

solution = [
    3,8,5, 9,6,1, 4,2,7,
    9,2,4, 8,7,3, 1,5,6,
    1,6,7, 5,4,2, 3,9,8,
    
    5,4,3, 1,8,7, 9,6,2,
    7,1,8, 2,9,6, 5,4,3,
    2,9,6, 4,3,5, 8,7,1,

    4,7,1, 6,5,8, 2,3,9,
    8,3,9, 7,2,4, 6,1,5,
    6,5,2, 3,1,9, 7,8,4]

sudoku.value = solution

Creating a Sudoku solver

Now that we have this widget to our disposal, we’ll create a Sudoku solver.

Building the user interface

First, let’s tackle the easy part: creating the user interface for our Sudoku solver. We’ll use the Sudoku widget along with some other widgets to make it easy for the user to select from pre-made puzzles or enter their own.

puzzle1 = [
    0,8,5, 0,6,1, 0,0,0,
    9,0,4, 0,0,0, 0,0,0,
    0,0,0, 0,0,2, 3,0,8,
    
    0,4,0, 0,0,0, 0,0,2,
    7,0,0, 0,9,0, 5,0,0,
    0,0,0, 0,3,0, 8,0,0,
    
    0,0,0, 0,5,8, 0,0,0,
    0,0,0, 7,0,0, 0,1,0,
    6,0,0, 0,0,0, 0,0,4]

puzzle2 = [
    3,6,0, 0,0,0, 0,0,5,
    0,1,0, 0,9,0, 2,0,8,
    0,5,0, 1,8,0, 0,0,7,
    
    5,0,0, 0,0,6, 4,0,0,
    2,4,6, 0,5,0, 7,0,0,
    0,0,0, 0,7,0, 0,0,0,
    
    0,0,0, 0,0,7, 1,0,3,
    0,0,3, 9,4,0, 0,0,0,
    0,0,0, 0,0,1, 0,0,0]

puzzle3 = [
    0,2,0, 0,4,0, 0,0,5,
    0,5,8, 0,0,0, 0,0,0,
    0,1,0, 8,0,0, 4,0,0,
    
    7,0,0, 0,0,8, 0,4,0,
    0,0,1, 9,0,5, 7,0,0,
    0,3,0, 7,0,0, 0,0,2,
    
    0,0,4, 0,0,3, 0,1,0,
    0,0,0, 0,0,0, 9,6,0,
    2,0,0, 0,1,0, 0,5,0
]

sudoku = Sudoku(value=puzzle1, fixed=[v > 0 for v in puzzle1], disabled=False)
example_dropdown = widgets.Dropdown(
    options=[('Empty', [0] * 81), ('Example 1', puzzle1), ('Example 2', puzzle2), ('Example 3', puzzle3)], 
    value=puzzle1,
    layout=widgets.Layout(margin='10px 0px 0px 20px', width='150px')
)
solve_button = widgets.Button(
    description="Solve", 
    layout=widgets.Layout(margin='20px 0px 0px 20px', width='150px')
)
next_button = widgets.Button(
    description="Next", 
    layout=widgets.Layout(margin='20px 0px 0px 20px', width='150px', display='none')
)
vbox = widgets.VBox([example_dropdown, solve_button, next_button])
hbox = widgets.HBox([sudoku, vbox])
label = widgets.Label()

The Sudoku widget displays a Sudoku board.

There is also a Dropdown widget for selecting pre-made puzzles or an empty board, and two Button widgets for solving the puzzle and showing the next solution (if there are multiple solutions).

Finally, there is a Label widget that can be used to display messages to the user. All of these widgets are arranged in a layout using VBox and HBox widgets.

Writing the event handlers

The widgets are not functional on their own; we need to write code to make them responsive to user input.

# global variables
gen = None
solution = None

def on_example_dropdown_change(change):
    if change['type'] == 'change' and change['name'] == 'value':
        value = change['new']
        fixed = [v > 0 for v in value]
        sudoku.value = value
        sudoku.fixed = fixed
        label.value = ""
        solve_button.layout.display = 'inline-block'
        next_button.layout.display = 'none'

example_dropdown.observe(on_example_dropdown_change)

def on_solve_button_clicked(b):
    global gen
    global solution
    
    val = sudoku.value.copy()
    sudoku.fixed = [v > 0 for v in val]
    gen = solve_sudoku(val)
    try:
        solution = next(gen)
        sudoku.value = solution
    except StopIteration:
        label.value = "This sudoku has no solution."
        sudoku.fixed = [False] * 81
        return
    
    try:
        solution = next(gen).copy()
        label.value = "This sudoku has multiple solutions."
        solve_button.layout.display = 'none'
        next_button.layout.display = 'inline-block'
    except StopIteration:
        label.value = ""
        solve_button.layout.display = 'none'
    
solve_button.on_click(on_solve_button_clicked)

def on_next_button_clicked(b):
    global gen
    global solution
    
    sudoku.value = solution
    try:
        solution = next(gen)
    except StopIteration:
        label.value = ""
        next_button.layout.display = 'none'

next_button.on_click(on_next_button_clicked)

The on_example_dropdown_change function is called when the user selects an example puzzle from a dropdown menu, and it sets up the Sudoku grid with the selected puzzle and clears any previous solutions.

The on_solve_button_clicked function is called when the user clicks a button to solve the puzzle, and it generates a Sudoku solver object and attempts to find a solution to the puzzle. If a solution is found, it updates the Sudoku grid with the solution and enables a button to find the next solution if there are multiple solutions. If no solution is found, it displays an error message.

The on_next_button_clicked function is called when the user clicks the “next” button to find the next solution to a puzzle with multiple solutions, and it updates the Sudoku grid with the next solution if there is one, or disables the “next” button if there are no more solutions.

The gen and solution variables are used to keep track of the state of the Sudoku solver object and the next solution.

The solver

We can easily solve puzzles using backtracking. The solve_sudoku function utilizes recursion to generate solutions. It is called by the on_solve_button_clicked function above.

def solve_sudoku(puzzle, index=0):
    if index == 81:
        # Solution found
        yield puzzle
    elif puzzle[index] > 0:
        # Already filled
        yield from solve_sudoku(puzzle, index + 1)
    else:
        for v in range(1,10):
            # Fill in a digit and check constraints
            puzzle[index] = v
            if is_valid_square(puzzle, index):
                yield from solve_sudoku(puzzle, index + 1)
            puzzle[index] = 0

The solve_sudoku function takes in a puzzle parameter which is a list of length 81, representing the 9x9 Sudoku grid with empty squares represented as 0s. The function yields solutions as they are found.

The following functions are used to check the constraints.

def get_column(puzzle, k):
    column = []
    for i in range(9):
        column.append(puzzle[i*9 + k])
    return column

def get_row(puzzle, r):
    return puzzle[r*9:(r+1)*9]

def get_block(puzzle, b):
    block = []
    for r in range(3):
        for k in range(3):
            block.append(puzzle[[0,3,6,27,30,33,54,57,60][b]+9*r+k])
    return block

def is_valid(l):
    # Check for duplicate values
    digits = [v for v in l if v > 0]
    s = set(digits)
    return len(digits) == len(s)

def is_valid_square(puzzle, i):
    k = i % 9
    r = int(i / 9)
    b = int(r / 3) * 3 + int(k / 3)
    
    return is_valid(get_row(puzzle, r)) and is_valid(get_column(puzzle, k)) and is_valid(get_block(puzzle, b))

The get_column, get_row, and get_block functions are used to retrieve the values in the columns, rows, and 3x3 blocks that a given index belongs to.

The is_valid function checks if a list of values contains duplicate values. It returns True if the list contains no duplicates (excluding 0s) and False otherwise.

The is_valid_square function checks if a value can be placed in a given square of the Sudoku grid without violating the rules of the game. It uses the get_row, get_column, get_block, and is_valid functions.

Displaying the user interface

It’s time to show the user interface.

display(hbox, label)

To play with an interactive version, you’ll need to run it in Jupyter Notebook. Sadly, it won’t work in JupyterLab. It does work in Voilà in case you wish to turn it into a web app. The corresponding gist can be found here.

Conclusion

In this post, we saw how to create a custom widget in Jupyter Notebook. It’s worth noting that the approach I presented here is more of a quick fix. Originally, I developed this code as part of a Sudoku programming assignment for my students, and I had control over the environment they were using (Jupyter Notebook and Voila).

For creating Jupyter widgets, it is recommended to use widget-cookiecutter (for JavaScript) or widget-ts-cookiecutter (for TypeScript). These tools offer a more robust and reliable approach to building widgets in Jupyter.