Compare commits

..

8 Commits

@ -0,0 +1,24 @@
from textual.command import DiscoveryHit, Provider, Hits, Hit
class AboutCommand(Provider):
'''Class to represent the About command in the menu'''
async def discover(self) -> Hits:
yield DiscoveryHit(
display='About',
command=self.app.action_toggle_sidebar,
help='Link to repo etc.'
)
async def search(self, query: str) -> Hits:
'''Called when the search functionality is used'''
matcher = self.matcher(query)
command = "About"
score = matcher.match(command)
if score > 0:
yield Hit(
score,
matcher.highlight(command),
self.app.action_toggle_sidebar,
'Link to repo etc.'
)

@ -11,12 +11,36 @@ from asyncio import sleep
from BingoField import BingoField
class BingoBoard(Widget):
'''
A widget to display the fields of the bingo board.
Attributes
----------
fieldstate : list
A list of booleans indicating the selection state of each field
fields : reactive(list)
List of strings containing the actual text for each field
default_fields : list
List of field strings in default order, required for reproducability
seed : int
The seed used for the current board
Methods
-------
roll_board(seed):
Shuffle the fields according to the field and reset the board.
fieldnum_from_cursor():
Get the list index of the currently highlighted cell
is_bingo():
Check if there is a bingo.
'''
fields = reactive([], recompose = True)
cursor_x, cursor_y = 2, 2
def __init__(self) -> None:
'''Initialize the game'''
self.fieldstate = [False for _ in range(25)]
self.can_focus = True
super().__init__()
@ -51,32 +75,48 @@ class BingoBoard(Widget):
self.roll_board(int(datetime.now().timestamp()))
def fieldnum_from_cursor(self) -> int:
'''Returns the list index position of the highlighted field'''
return self.cursor_x + ( self.cursor_y * 5)
def roll_board(self, seed):
def roll_board(self, seed: int) -> None:
'''
Roll the board according to the seed. Board updated through reactivity.
Parameters:
seed (int): A UNIX timestamp used as RNG seed
'''
self.seed = seed
random.seed(seed)
self.fields = random.sample(self.default_fields, len(self.fields))
def watch_fields(self, new_state) -> None:
'''
Triggered when fields change (see roll_board).
Parameters:
new_state (list): The field strings in newly shuffled order
'''
self.fieldstate = [False for _ in range(25)]
for idx, field in enumerate(self.query(BingoField)):
field.text = new_state[idx]
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
'''Create 25 bingo fields.'''
for _ in range(25):
yield BingoField(_, self.fields[_])
def on_focus(self, message: Message) -> None:
'''Called when the BingoBoard receives focus. Enables cursor.'''
fields = self.query(BingoField)
fields[self.fieldnum_from_cursor()].set_highlighted(True)
def on_blur(self, message: Message) -> None:
'''Called when the BingoBoard loses focus. Disables cursor.'''
fields = self.query(BingoField)
fields[self.fieldnum_from_cursor()].set_highlighted(False)
def on_key(self, event: events.Key) -> None:
'''Handles keyboard input when BingoBoard is focused.'''
fields = self.query(BingoField)
fields[self.fieldnum_from_cursor()].set_highlighted(False)
match event.key:
@ -94,6 +134,10 @@ class BingoBoard(Widget):
async def on_bingo_field_selected(self, message: BingoField.Selected) -> None:
'''
Triggered by child when it is clicked.
Updates fieldstate and checks for bingo, possibly triggering win animation.
'''
self.fieldstate[message.num] = message.selected
if self.is_bingo():
self.screen.styles.animate("background", 'red', duration=0.25)
@ -112,7 +156,8 @@ class BingoBoard(Widget):
await sleep(0.25)
self.screen.styles.animate("background", '#1c1c1c', duration=0.25)
def is_bingo(self):
def is_bingo(self) -> bool:
'''Check the board for a bingo (5 in a row).'''
array = [ self.fieldstate[i:i+5] for i in range(0, len(self.fieldstate), 5)]
# check rows
bingo = any( [ all(row) for row in array ] )

@ -0,0 +1,54 @@
from textual.widgets import Input, Static, Button
from textual.containers import Horizontal
from textual.validation import Number
from textual.app import ComposeResult
from datetime import datetime
from BingoBoard import BingoBoard
class BingoDisplay(Static):
'''
A Widget to represent the bingo UI.
Contains the board, input field and re-roll button.
Attributes
----------
board : BingoBoard
The BingoBoard object
input_field : Input
User input for game seed
'''
def compose(self) -> ComposeResult:
'''Create child widgets for the app.'''
self.board = BingoBoard()
yield self.board
self.input_field = Input(
str(self.board.seed),
type='integer',
placeholder='UNIX timestamp',
max_length=10,
classes='seed_input',
validators=[
Number(minimum=1000000000, maximum = 2000000000)
]
)
self.input_field.border_title = 'Seed'
yield Horizontal(
self.input_field,
Button.error(':game_die: re-roll', classes='roll_btn'),
classes='bottom_line'
)
def on_button_pressed(self, event: Button.Pressed) -> None:
'''Re-roll the board state with current time as seed'''
self.board.roll_board(int(datetime.now().timestamp()))
self.input_field.value = str(self.board.seed)
def on_input_submitted(self, event: Input.Submitted) -> None:
'''Re-roll the board state with the seed from the input'''
if event.validation_result.is_valid:
self.board.roll_board(int(event.value))

@ -3,7 +3,25 @@ from textual.message import Message
from textual.reactive import reactive
class BingoField(Static):
'''A Bingo field widget.'''
'''
A Bingo field widget.
Attributes
----------
text : str
The text displayed in the field
num : int
The running number of this field (0-24)
selected : bool
Wether the bingo field has been checked or not
highlighted : bool
Wether the cursor is currently on this field or not
Methods
-------
set_highlighted(new_highlight):
Sets the highlighted state (cursor) of this field
'''
text = reactive('')
@ -22,6 +40,7 @@ class BingoField(Static):
self.text = text
def on_click(self) -> None:
'''Add CSS class field_selected to self and POST to BingoBoard'''
self.selected = not self.selected
if self.selected:
self.add_class('field_selected')
@ -32,6 +51,7 @@ class BingoField(Static):
self.post_message(self.Selected(self.num, self.selected))
def set_highlighted(self, new_highlight: bool) -> None:
'''Add or remove the field_highlighted CSS class from self'''
self.highlighted = new_highlight
if self.highlighted:
self.add_class('field_highlighted')
@ -39,4 +59,5 @@ class BingoField(Static):
self.remove_class('field_highlighted')
def render(self) -> str:
'''Return the fields text'''
return str(self.text)

@ -0,0 +1,40 @@
# bingo-cli - Play bingo in your terminal (or browser)
bingo-cli is a TUI to play Bingo, built using [Textual](https://textual.textualize.io/).
![bingo screenshot](img/bingo.png)
## Features
- Mouse and keyboard controls
- Reproducible board states (by supplying UNIX timestamps)
- Playable directly in your terminal, remotely via SSH or web browser
## Installation
### Install in python3 venv
```
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# test to see if it works:
python3 bingo.py
```
### Serve as a webpage
- Install requirements as above.
- Download the latest build of `ttyd`: https://github.com/tsl0922/ttyd/releases
```
chmod +x ttyd
# if not already in venv:
source venv/bin/activate
./ttyd -W ./bingo.py
```
- Bingo is now served on `http://localhost:7681`.
- Check `ttyd --help` for more options and consider building a reverse proxy and `systemd` service.
### Serve via SSH
TBA

@ -0,0 +1,31 @@
from textual.containers import Container
from textual.app import ComposeResult
from textual.widgets import Static, Button
MESSAGE = '''
Be excellent to each other!
Made with :red_heart: by Panki
[@click="app.open_link('https://tty0.social/@panki')"]Mastodon:[/] tty0.social/@panki
[@click="app.open_link('https://git.theresno.cloud/panki/bingo-cli')"]Source:[/] git.theresno.cloud/panki/bingo-cli
Built using [@click="app.open_link('https://textual.textualize.io/')"]Textual[/]
'''
class Sidebar(Container):
'''Class to represent the sidebar'''
def compose(self) -> ComposeResult:
'''Create the widgets that make up the sidebar'''
yield Static('CCC Bingo', classes='title')
yield Static(MESSAGE, classes='message')
self.button = Button('< Back', variant='primary', classes='btn_sidebar')
self.button.disabled = True
yield self.button
def on_button_pressed(self, event: Button.Pressed) -> None:
'''Closes the sidebar'''
self.app.action_toggle_sidebar()

@ -1,58 +1,20 @@
#!/usr/bin/env python3
from textual.app import App, ComposeResult
from textual.widgets import Header, Input, Static, Button
from textual.containers import Horizontal, Container
from textual.validation import Number
from textual.command import DiscoveryHit, Provider, Hits, Hit
#from textual.command import Provider, Hits, Hit
from textual.widgets import Header
from textual.reactive import reactive
from datetime import datetime
from BingoBoard import BingoBoard
MESSAGE = '''
Be excellent to each other!
Made with :red_heart: by Panki
[@click="app.open_link('https://tty0.social/@panki')"]Mastodon:[/] tty0.social/@panki
[@click="app.open_link('https://git.theresno.cloud/panki/bingo-cli')"]Source:[/] git.theresno.cloud/panki/bingo-cli
Built using [@click="app.open_link('https://textual.textualize.io/')"]Textual[/]
from BingoDisplay import BingoDisplay
from AboutCommand import AboutCommand
from Sidebar import Sidebar
class BingoApp(App):
'''
A Textual app to run a Bingo board.
class AboutCommand(Provider):
async def discover(self) -> Hits:
yield DiscoveryHit(display='About', command=self.app.action_toggle_sidebar, help='Link to repo etc.')
#yield Hit(1, 'About', self.app.action_toggle_sidebar, help='Link to repo etc.')
def show_about(self):
pass
async def search(self, query: str) -> Hits:
matcher = self.matcher(query)
command = "About"
score = matcher.match(command)
if score > 0:
yield Hit(score, matcher.highlight(command), self.app.action_toggle_sidebar, 'Link to repo etc.')
class Sidebar(Container):
def compose(self) -> ComposeResult:
yield Static('CCC Bingo', classes='title')
yield Static(MESSAGE, classes='message')
self.button = Button('< Back', variant='primary', classes='btn_sidebar')
self.button.disabled = True
yield self.button
def on_button_pressed(self, event: Button.Pressed) -> None:
print('sidebar button press')
self.app.action_toggle_sidebar()
Contains the sidebar, header, and BingoDisplay.
'''
class BingoApp(App):
'''A Textual app to run a Bingo board.'''
CSS_PATH = "bingo.tcss"
COMMANDS = App.COMMANDS | {AboutCommand}
AUTO_FOCUS = 'Input'
@ -60,7 +22,7 @@ class BingoApp(App):
show_sidebar = reactive(False)
def action_toggle_sidebar(self) -> None:
print('sidebar toggle')
'''Toggle the sidebar on or off'''
sidebar = self.query_one(Sidebar)
self.set_focus(None)
if sidebar.has_class("-hidden"):
@ -79,45 +41,10 @@ class BingoApp(App):
yield BingoDisplay()
def on_mount(self) -> None:
'''Set title of the app'''
self.title = 'CCC Bingo'
self.sub_title = 'GPN22 Edition'
def action_toggle_dark(self) -> None:
'''An action to toggle dark mode.'''
self.dark = not self.dark
class BingoDisplay(Static):
def compose(self) -> ComposeResult:
'''Create child widgets for the app.'''
self.board = BingoBoard()
yield self.board
self.input_field = Input(
str(self.board.seed),
type='integer',
placeholder='UNIX timestamp',
max_length=10,
classes='seed_input',
validators=[
Number(minimum=1000000000, maximum = 2000000000)
]
)
self.input_field.border_title = 'Seed'
yield Horizontal(
self.input_field,
Button.error(':game_die: re-roll', classes='roll_btn'),
classes='bottom_line'
)
def on_button_pressed(self, event: Button.Pressed) -> None:
'''Re-roll the board state with current time as seed'''
self.board.roll_board(int(datetime.now().timestamp()))
self.input_field.value = str(self.board.seed)
def on_input_submitted(self, event: Input.Submitted) -> None:
'''Re-roll the board state with the seed from the input'''
if event.validation_result.is_valid:
self.board.roll_board(int(event.value))
if __name__ == "__main__":
app = BingoApp()
app.run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Loading…
Cancel
Save