import io
import random
import chess
import chess.pgn
import chess.svg
import streamlit as st
from datasets import load_dataset
st.set_page_config(page_title="Chess Openings", page_icon="♖")
# Load external CSS
with open("style.css") as f:
st.markdown(f"", unsafe_allow_html=True)
@st.cache_data
def load_data():
ds = load_dataset("Lichess/chess-openings", split="train")
df = ds.to_pandas()
print(f"Total openings: {len(df)}")
print(df["pgn"].head())
return df
# Initialize session state variables
if "move_index" not in st.session_state:
st.session_state.move_index = 0
if "user_move" not in st.session_state:
st.session_state.user_move = ""
if "current_opening" not in st.session_state:
st.session_state.current_opening = None
if "board" not in st.session_state:
st.session_state.board = chess.Board()
if "moves" not in st.session_state:
st.session_state.moves = []
if "final_move_completed" not in st.session_state:
st.session_state.final_move_completed = False
if "random_opening" not in st.session_state:
st.session_state.random_opening = None
if "completed_openings" not in st.session_state:
st.session_state.completed_openings = set()
if "score" not in st.session_state:
st.session_state.score = 0
data = load_data()
if data.empty:
st.error("No data available. Failed to load from Hugging Face dataset.")
st.stop()
def update_score():
# Note: keeping it as a function to be able to try different scores
st.session_state.score += 1
# score = 0
# for opening in st.session_state.completed_openings:
# opening_data = data[data["name"] == opening].iloc[0]
# moves_count = opening_data["pgn"].count(".")
# score += moves_count
# return score
# App layout
# st.markdown(
# "
Practice Chess Openings
",
# unsafe_allow_html=True,
# )
with st.sidebar:
st.header("Settings")
# Move range slider
min_moves, max_moves = st.slider(
"Select range of moves:", min_value=1, max_value=18, value=(1, 18), step=1
)
print(f"{min_moves=}, {max_moves=}")
# Hide next moves checkbox
hide_next_moves = st.checkbox("Hide next moves", value=True)
# Filter the data based on the min and max number of moves
filtered_data = data[
(data["pgn"].str.count("\.") >= min_moves)
& (data["pgn"].str.count("\.") <= max_moves)
]
print(f"Total openings after filtering: {len(filtered_data)}")
if filtered_data.empty:
st.error(
"No openings found with the specified number of moves. Please adjust your selection."
)
st.stop()
# Random Opening button with custom styling
random_button = st.button(
"Random Opening",
key="random_opening_button",
use_container_width=True,
help="Select a random opening",
type="primary", # This will give it a filled style
)
# Remove the previous CSS styling attempt
# The CSS is now loaded from the external file
if random_button:
available_openings = [
opening
for opening in filtered_data["name"].unique()
if opening not in st.session_state.completed_openings
]
if available_openings:
st.session_state.random_opening = random.choice(available_openings)
else:
st.warning(
"You've completed all available openings! Resetting completed list."
)
st.session_state.completed_openings.clear()
st.session_state.random_opening = random.choice(
list(filtered_data["name"].unique())
)
st.rerun()
# Check if the random_opening is still in the filtered data
unique_openings = list(filtered_data["name"].unique())
if st.session_state.random_opening not in unique_openings:
st.session_state.random_opening = None
opening = st.selectbox(
label="Select an opening",
options=unique_openings,
index=unique_openings.index(st.session_state.random_opening)
if st.session_state.random_opening
else 0,
key="opening_selector",
placeholder="Select an opening",
)
if opening and opening != st.session_state.current_opening:
st.session_state.current_opening = opening
st.session_state.move_index = 0
st.session_state.user_move = ""
st.session_state.board = chess.Board()
st.session_state.final_move_completed = False
if "success_message" in st.session_state:
del st.session_state.success_message
# Get PGN for the selected opening and parse it
selected_opening = filtered_data[filtered_data["name"] == opening].iloc[0]
pgn = selected_opening["pgn"]
game = chess.pgn.read_game(io.StringIO(pgn))
st.session_state.moves = list(game.mainline_moves())
with st.expander("Instructions"):
st.write("This app lets you practice ~3500 chess openings.")
st.write("Entering moves:")
st.write("Use standard algebraic notation (SAN)")
st.write("Examples: e4, Nf3, O-O (castling), exd5 (pawn capture)")
st.write("Specify the piece (except for pawns) + destination square")
st.write("Use 'x' for captures, '+' for check, '#' for checkmate")
st.write("\nPiece symbols:")
col1, col2, col3 = st.columns(3)
with col1:
st.write("♔ King (K)")
st.write("♕ Queen (Q)")
with col2:
st.write("♖ Rook (R)")
st.write("♗ Bishop (B)")
with col3:
st.write("♘ Knight (N)")
st.write("♙ Pawn (no letter)")
st.write(
"See full notation [here](https://en.wikipedia.org/wiki/Algebraic_notation_(chess))"
)
st.write("---")
st.write(
"This app is using the [Lichess](https://lichess.org/) openings dataset via [HuggingFace](https://huggingface.co/datasets/Lichess/chess-openings)"
)
st.write(
"This is just a toy app. Go to [Lichess](https://lichess.org/) \
or [Chess.com](https://chess.com) for serious chess practice \
(although I think this functionality isn't available there)"
)
def update_board():
st.session_state.board = chess.Board()
for move in st.session_state.moves[: st.session_state.move_index]:
st.session_state.board.push(move)
def update_next_move():
if st.session_state.move_index < len(st.session_state.moves):
st.session_state.move_index += 1
update_board()
def update_prev_move():
if st.session_state.move_index > 0:
st.session_state.move_index -= 1
st.session_state.final_move_completed = False
if "success_message" in st.session_state:
del st.session_state.success_message
update_board()
# Create two columns: one for the board and buttons, one for the move list
col1, col2 = st.columns([3, 1])
with col1:
if st.session_state.current_opening:
st.markdown(
f"""
{st.session_state.current_opening}
""",
unsafe_allow_html=True,
)
col_prev, col_next, right_col = st.columns([5, 2, 2])
with col_prev:
st.button(
"⬅️ Previous",
disabled=st.session_state.move_index == 0,
on_click=update_prev_move,
)
with col_next:
st.button(
"Next ➡️",
disabled=(st.session_state.move_index >= len(st.session_state.moves))
or (
not hide_next_moves
and st.session_state.move_index == len(st.session_state.moves)
),
on_click=update_next_move,
)
board_container = st.empty()
board_container.image(chess.svg.board(board=st.session_state.board, size=400))
# User input for next move
if hide_next_moves and st.session_state.move_index < len(
st.session_state.moves
):
col_input, col_submit, col_right = st.columns([3, 1, 1])
def submit_move():
if st.session_state.user_move.strip() == "":
return
user_move = st.session_state.user_move
try:
user_chess_move = st.session_state.board.parse_san(user_move)
correct_move = st.session_state.moves[st.session_state.move_index]
if user_chess_move == correct_move:
update_score()
st.session_state.move_index += 1
if st.session_state.move_index == len(st.session_state.moves):
st.session_state.success_message = "🎉 Well done!"
st.session_state.final_move_completed = True
st.session_state.completed_openings.add(
st.session_state.current_opening
)
else:
st.session_state.success_message = (
"✅ Correct! Moving to the next one"
)
st.session_state.user_move = ""
update_board()
else:
st.session_state.error_message = "😭 Incorrect move. Try again!"
except ValueError as e:
error_message = str(e).lower()
if (
"invalid san" in error_message
or "unexpected" in error_message
or "unterminated" in error_message
):
st.session_state.error_message = "🚫 Invalid format. Please use standard SAN notation (e.g., e4 or Nf3)."
else:
st.session_state.error_message = "⛔ Invalid move. This move is not allowed in the current position."
with col_input:
user_move = st.text_input(
label="Enter your move",
placeholder="Enter your move (e.g., e4 or Nf3)",
key="user_move",
value=st.session_state.user_move,
label_visibility="hidden",
on_change=submit_move,
)
if "error_message" in st.session_state:
st.error(st.session_state.error_message)
del st.session_state.error_message
elif "success_message" in st.session_state:
st.success(st.session_state.success_message)
if not st.session_state.final_move_completed:
del st.session_state.success_message
with col_submit:
st.markdown("
", unsafe_allow_html=True)
submit_button = st.button(
"Submit",
on_click=submit_move,
)
if st.session_state.final_move_completed:
col_success, col_empty = st.columns([1, 2])
with col_success:
st.success("🎉 Well done!")
else:
st.info("Please select an opening from the sidebar to begin.")
with col2:
if st.session_state.current_opening:
st.subheader("Moves", divider="green")
move_text = ""
current_node = chess.pgn.Game()
for i, move in enumerate(st.session_state.moves):
if i % 2 == 0:
move_number = i // 2 + 1
move_text += f"{move_number}. "
san_move = current_node.board().san(move)
if i < st.session_state.move_index:
move_text += f"**{san_move}** "
elif hide_next_moves:
move_text += "... "
else:
move_text += f"{san_move} "
if i % 2 == 1 or i == len(st.session_state.moves) - 1:
move_text += "\n"
current_node = current_node.add_variation(move)
st.markdown(move_text)
with st.sidebar:
st.markdown("---")
# score = update_score()
st.metric(
label="Score",
value=st.session_state.score,
help="1 point per correct move",
)
col1, col2 = st.columns([2, 1])
with col1:
st.subheader("Completed Openings")
with col2:
if st.button("Reset", key="reset_completed", help="Reset completed openings"):
st.session_state.completed_openings.clear()
st.rerun()
with st.expander("View Completed Openings"):
if st.session_state.completed_openings:
for completed_opening in st.session_state.completed_openings:
st.markdown(f"- {completed_opening}")
else:
st.markdown("No openings completed yet.")