If you're building a UI where you track input, yet want to allow users to step back through a history and branch off in a new direction — you'll eventually need to handle a specific problem: when the user goes back and makes a new entry, everything that came after that point needs to be removed.
In this tutorial, we'll work through how to manage this with a QTableWidget in PyQt6. We'll build a working chess move list where White and Black moves are displayed in two columns, and selecting a previous row then adding a new move clears all subsequent rows.
The problem
Imagine a chess move table with two columns: one for White's moves and one for Black's. Each row represents a full turn (White's move, then Black's move). As the game progresses, the table fills up with move history.
Now the user clicks on a row partway up the table — they want to explore a different line. When a new move is added at that point, all the rows below the selected row should be removed. The new move takes their place.
There are two cases to handle:
- White's turn: Insert a new row after the selected one, place the move in column 0, and clear everything below.
- Black's turn: Replace (or fill in) the move in column 1 of the selected row, and clear everything below.
Setting up the table
Let's start by building a simple QTableWidget with two columns and a button to simulate adding moves. We'll create a small Move class and a generator that produces alternating White and Black moves with random chess-like notation.
from PyQt6.QtWidgets import (
QTableWidget, QVBoxLayout, QWidget, QPushButton,
QTableWidgetItem, QMainWindow, QApplication,
)
from random import choice
WHITE = 0
BLACK = 1
class Move:
def __init__(self, turn, move):
self.turn = turn
self.move = move
def __str__(self):
return f"{self.turn} {self.move}"
def chess_moves():
"""Generator producing chess moves, alternating White and Black."""
turn = WHITE
while True:
move_str = choice("KQRBN ") + choice("abcdefgh") + choice("12345678")
yield Move(turn, move_str)
if turn == WHITE:
turn = BLACK
else:
turn = WHITE
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.moves = chess_moves()
self.moves_widget = QTableWidget()
self.moves_widget.setColumnCount(2)
self.moves_widget.setHorizontalHeaderLabels(["White", "Black"])
self.btn = QPushButton("Move")
self.btn.pressed.connect(self.add_chess_move)
layout = QVBoxLayout()
layout.addWidget(self.moves_widget)
layout.addWidget(self.btn)
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)
def add_chess_move(self):
move = next(self.moves)
item = QTableWidgetItem(move.move)
row = self.moves_widget.rowCount() - 1
if move.turn == WHITE:
row += 1
self.moves_widget.setRowCount(row + 1)
self.moves_widget.setItem(row, move.turn, item)
self.moves_widget.scrollToBottom()
app = QApplication([])
w = MainWindow()
w.show()
app.exec()
Run this and click the Move button a few times. You'll see White and Black moves filling in alternately across two columns. So far, so good — but selecting a row and clicking Move still just appends to the bottom. We need to add the deletion logic.
Removing subsequent rows with setRowCount()
When the user selects a row and then triggers a new move, we need to chop off everything below that row. The most straightforward way to do this is with setRowCount().
If the selected row is row 3 (zero-indexed), and you want to keep only rows 0 through 3, you set the row count to 4:
self.moves_widget.setRowCount(row + 1)
The + 1 is because row indices are zero-based but setRowCount() expects a count. Row 0 means one row, row 3 means you want four rows total.
This single call removes every row after the specified count. You could also loop with removeRow(), but setRowCount() is cleaner and does the job in one step.
Handling the two branches
With the deletion logic in mind, the add_chess_move method needs two separate code paths:
- A row is selected — truncate the table, then place the move at the right spot.
- No row is selected — append the move to the end, as before.
Within the "row is selected" path, there's a further distinction:
- If it's White's turn, place the White move on the selected row and clear any existing Black move on that row (since we're starting a new line from here).
- If it's Black's turn, place the Black move on the selected row alongside the existing White move.
Here's how that looks in practice:
def add_chess_move(self):
move = next(self.moves)
item = QTableWidgetItem(move.move)
selected = self.moves_widget.selectedIndexes()
if selected:
# A row is selected — truncate everything after it.
row = selected[0].row()
self.moves_widget.setRowCount(row + 1)
# Clear the selection so the next move appends normally.
self.moves_widget.clearSelection()
# Place the move in the correct column.
self.moves_widget.setItem(row, move.turn, item)
if move.turn == WHITE:
# We're overwriting this row from White's move onward,
# so remove any existing Black move.
self.moves_widget.takeItem(row, BLACK)
else:
# No selection — append as usual.
row = self.moves_widget.rowCount() - 1
if move.turn == WHITE:
row += 1
self.moves_widget.setRowCount(row + 1)
self.moves_widget.setItem(row, move.turn, item)
self.moves_widget.scrollToBottom()
After truncating and placing the move, we call self.moves_widget.clearSelection(). This is easy to overlook, but without it the next move would also trigger the "selected" branch and delete rows again unintentionally.
The takeItem() call removes the Black move cell content when White overwrites a row. This keeps things tidy — you don't want a stale Black move sitting next to a brand-new White move from a different line.
Complete working example
Here's the full application with both branches wired up. Click Move a bunch of times to populate the table, then click on an earlier row and click Move again to see the subsequent rows disappear.
from PyQt6.QtWidgets import (
QTableWidget, QVBoxLayout, QWidget, QPushButton,
QTableWidgetItem, QMainWindow, QApplication,
)
from random import choice
WHITE = 0
BLACK = 1
class Move:
def __init__(self, turn, move):
self.turn = turn
self.move = move
def __str__(self):
return f"{self.turn} {self.move}"
def chess_moves():
"""Generator producing alternating White and Black chess moves."""
turn = WHITE
while True:
move_str = choice("KQRBN ") + choice("abcdefgh") + choice("12345678")
yield Move(turn, move_str)
if turn == WHITE:
turn = BLACK
else:
turn = WHITE
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.moves = chess_moves()
self.moves_widget = QTableWidget()
self.moves_widget.setColumnCount(2)
self.moves_widget.setHorizontalHeaderLabels(["White", "Black"])
self.btn = QPushButton("Move")
self.btn.pressed.connect(self.add_chess_move)
layout = QVBoxLayout()
layout.addWidget(self.moves_widget)
layout.addWidget(self.btn)
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)
def add_chess_move(self):
move = next(self.moves)
item = QTableWidgetItem(move.move)
selected = self.moves_widget.selectedIndexes()
if selected:
# A row is selected — truncate the table to this row.
row = selected[0].row()
self.moves_widget.setRowCount(row + 1)
# Clear the selection so the next move appends normally.
self.moves_widget.clearSelection()
# Place the new move in the correct column.
self.moves_widget.setItem(row, move.turn, item)
if move.turn == WHITE:
# Clear the Black column — we're starting fresh from here.
self.moves_widget.takeItem(row, BLACK)
else:
# No selection — append to the end of the table.
row = self.moves_widget.rowCount() - 1
if move.turn == WHITE:
# White always starts a new row.
row += 1
self.moves_widget.setRowCount(row + 1)
self.moves_widget.setItem(row, move.turn, item)
self.moves_widget.scrollToBottom()
app = QApplication([])
w = MainWindow()
w.show()
app.exec()
Adapting this for a real chess application
In a real chess GUI using the python-chess library, you'd replace the random move generator with actual board moves. The slot might look something like this:
@pyqtSlot(chess.Move)
def on_new_move_made(self, move):
row = self.moves_widget.rowCount()
column = 0 if settings.board.turn == chess.WHITE else 1
san_move = settings.board.san(move)
new_move = QTableWidgetItem(san_move)
selected_move = self.moves_widget.selectedIndexes()
if selected_move:
row = selected_move[0].row()
if settings.board.turn == chess.WHITE:
# Truncate to the selected row, then add a fresh row.
self.moves_widget.setRowCount(row + 1)
self.moves_widget.insertRow(row + 1)
row += 1
elif settings.board.turn == chess.BLACK:
# Truncate to the selected row — Black fills in column 1.
self.moves_widget.setRowCount(row + 1)
else:
if settings.board.turn == chess.WHITE:
self.moves_widget.setRowCount(row + 1)
elif settings.board.turn == chess.BLACK:
row -= 1
self.moves_widget.setItem(row, column, new_move)
self.moves_widget.clearSelection()
self.moves_widget.scrollToBottom()
The structure is the same: check for a selection, truncate if needed, then place the move. The clearSelection() call at the end ensures the table returns to its normal append behavior on the following move.
An alternative approach: Model Views
If you find yourself doing a lot of list manipulation — inserting, deleting, slicing — you might want to look into Qt's Model/View architecture using QTableView with a custom model. With that approach, your move list is a plain Python list, and operations like "delete everything after index 5" become a simple slice:
self.move_list = self.move_list[:selected_index + 1]
The view updates automatically when the model changes. For a chess application with potentially complex move trees or undo/redo support, this separation of data and display can save you a lot of headaches down the road.
But for a straightforward move list, QTableWidget with setRowCount() and takeItem() works well and keeps things simple.
Packaging Python Applications with PyInstaller by Martin Fitzpatrick
This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.