Compare commits

..

18 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.'
)

@ -0,0 +1,182 @@
from textual.reactive import reactive
from textual.widget import Widget
from textual.app import ComposeResult
from textual.message import Message
from textual import events
from datetime import datetime
import random
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__()
self.fields = [
'Deko aufgehängt',
'6 Stunden Schlaf',
'Tschunk getrunken',
'Spaß gehabt',
'Sticker getauscht',
'DECT genutzt',
'Hardware gehackt',
'Kabel vergessen',
'Halle gesucht',
'$dinge gelötet',
'Code geschrieben',
'Neue Leute kennengelernt',
'Wasser getrunken',
'Waffel gegessen',
'Corona Test gemacht',
'2 Mahlzeiten gegessen',
'Fairydust bewundert',
'Talk angeschaut',
'CCC Merch getragen',
'getrollt',
'In der Lounge getanzt',
'Etwas Neues ausprobiert',
'Maske getragen',
'geduscht',
'Gulasch gegessen'
]
self.default_fields = self.fields
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: 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 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:
case 'up':
self.cursor_y -= 1 if self.cursor_y > 0 else 0
case 'down':
self.cursor_y += 1 if self.cursor_y < 4 else 0
case 'left':
self.cursor_x -= 1 if self.cursor_x > 0 else 0
case 'right':
self.cursor_x += 1 if self.cursor_x < 4 else 0
case 'enter':
fields[self.fieldnum_from_cursor()].on_click()
fields[self.fieldnum_from_cursor()].set_highlighted(True)
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)
await sleep(0.25)
self.screen.styles.animate("background", 'orange', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'yellow', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'green', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'lightblue', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'blue', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'purple', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", '#1c1c1c', duration=0.25)
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 ] )
# check cols
bingo = bingo or any([ all(self.fieldstate[num::5]) for num in range(5)])
# check bottom left to upper right
bingo = bingo or all([
self.fieldstate[4],
self.fieldstate[8],
self.fieldstate[12],
self.fieldstate[16],
self.fieldstate[20]
])
# check top left to bottom right
bingo = bingo or all([
self.fieldstate[0],
self.fieldstate[6],
self.fieldstate[12],
self.fieldstate[18],
self.fieldstate[24]
])
return bingo

@ -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))

@ -0,0 +1,63 @@
from textual.widgets import Static
from textual.message import Message
from textual.reactive import reactive
class BingoField(Static):
'''
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('')
class Selected(Message):
'''Send message to the board containing clicked field info'''
def __init__(self, num: int, selected: bool) -> None:
self.num = num
self.selected = selected
super().__init__()
def __init__(self, num, text: str) -> None:
self.num = num
self.selected = False
self.highlighted = False
super().__init__()
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')
else:
self.remove_class('field_selected')
# The post_message method sends an event to be handled in the DOM
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')
else:
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,197 +1,49 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Header, Input, Static, Button from textual.widgets import Header
from textual.widget import Widget
from textual.message import Message
from textual.containers import Horizontal
from textual.validation import Number
from textual.reactive import reactive from textual.reactive import reactive
from asyncio import sleep from BingoDisplay import BingoDisplay
from AboutCommand import AboutCommand
from Sidebar import Sidebar
from datetime import datetime class BingoApp(App):
import random '''
A Textual app to run a Bingo board.
class BingoField(Static):
"""A Bingo field widget."""
cursor_x, cursor_y = 2, 2
text = reactive("temp")
class Selected(Message):
"""Send message to the board containing clicked field info"""
def __init__(self, num: int, selected: bool) -> None:
self.num = num
self.selected = selected
super().__init__()
def __init__(self, num, text: str) -> None:
self.num = num
self.selected = False
super().__init__()
self.text = text
def on_mount(self) -> None:
self.styles.content_align = ("center", "middle")
self.styles.border = ("solid", "green")
self.styles.height = '100%'
self.styles.width = '100%'
def on_click(self) -> None:
self.selected = not self.selected
if self.selected:
self.styles.animate("background", 'green', duration=0.1)
self.styles.border = ("solid", "black")
else:
self.styles.animate("background", '#1c1c1c', duration=0.1)
self.styles.border = ("solid", "green")
# The post_message method sends an event to be handled in the DOM
self.post_message(self.Selected(self.num, self.selected))
def render(self) -> str: Contains the sidebar, header, and BingoDisplay.
return str(self.text) '''
class BingoApp(App):
"""A Textual app to run a Bingo board."""
CSS_PATH = "bingo.tcss" CSS_PATH = "bingo.tcss"
COMMANDS = App.COMMANDS | {AboutCommand}
AUTO_FOCUS = 'Input'
show_sidebar = reactive(False)
def action_toggle_sidebar(self) -> None:
'''Toggle the sidebar on or off'''
sidebar = self.query_one(Sidebar)
self.set_focus(None)
if sidebar.has_class("-hidden"):
sidebar.remove_class("-hidden")
sidebar.button.disabled = False
else:
if sidebar.query("*:focus"):
self.screen.set_focus(None)
sidebar.add_class("-hidden")
sidebar.button.disabled = True
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" '''Create child widgets for the app.'''
yield Header() yield Sidebar(classes="-hidden")
yield Header(show_clock=True)
yield BingoDisplay() yield BingoDisplay()
def action_toggle_dark(self) -> None: def on_mount(self) -> None:
"""An action to toggle dark mode.""" '''Set title of the app'''
self.dark = not self.dark self.title = 'CCC Bingo'
self.sub_title = 'GPN22 Edition'
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:
""""""
# reroll
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:
if event.validation_result.is_valid:
self.board.roll_board(int(event.value))
class BingoBoard(Widget):
fields = reactive([], recompose = True)
def __init__(self) -> None:
self.fieldstate = [False for _ in range(25)]
super().__init__()
self.fields = [
'Datenelch',
'6 Stunden Schlaf',
'Tschunk getrunken',
'Spaß gehabt',
'Sticker getauscht',
'DECT genutzt',
'Hardware gehackt',
'Kabel vergessen',
'Halle gesucht',
'$dinge gelötet',
'an SoS teilgenommen',
'Neue Leute kennengelernt',
'Wasser getrunken',
'Waffel gegessen',
'Corona Test gemacht',
'2 Mahlzeiten gegessen',
'Fairydust bewundert',
'Talk angeschaut',
'CCC Merch getragen',
'getrollt',
'In der Lounge getanzt',
'Etwas Neues ausprobiert',
'Maske getragen',
'geduscht',
'Gulasch gegessen'
]
self.default_fields = self.fields
self.roll_board(int(datetime.now().timestamp()))
def roll_board(self, seed):
self.seed = seed
random.seed(seed)
self.fields = random.sample(self.default_fields, len(self.fields))
def watch_fields(self, new_state) -> None:
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."""
for _ in range(25):
yield BingoField(_, self.fields[_])
async def on_bingo_field_selected(self, message: BingoField.Selected) -> None:
self.fieldstate[message.num] = message.selected
if self.is_bingo():
self.screen.styles.animate("background", 'red', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'orange', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'yellow', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'green', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'lightblue', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'blue', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", 'purple', duration=0.25)
await sleep(0.25)
self.screen.styles.animate("background", '#1c1c1c', duration=0.25)
def is_bingo(self):
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 ] )
# check cols
bingo = bingo or any([ all(self.fieldstate[num::5]) for num in range(5)])
# check bottom left to upper right
bingo = bingo or all([
self.fieldstate[4],
self.fieldstate[8],
self.fieldstate[12],
self.fieldstate[16],
self.fieldstate[20]
])
# check top left to bottom right
bingo = bingo or all([
self.fieldstate[0],
self.fieldstate[6],
self.fieldstate[12],
self.fieldstate[18],
self.fieldstate[24]
])
return bingo
if __name__ == "__main__": if __name__ == "__main__":
app = BingoApp() app = BingoApp()

@ -1,9 +1,30 @@
Screen {
layers: base overlay;
}
BingoBoard { BingoBoard {
layout: grid; layout: grid;
grid-size: 5 5; grid-size: 5 5;
height: 100%; height: 100%;
} }
BingoField {
content-align: center middle;
text-align: center;
height: 100%;
width: 100%;
background: $panel;
border: thick $background;
}
BingoField.field_selected {
background: $success;
}
BingoField.field_highlighted {
color: $secondary;
}
BingoDisplay { BingoDisplay {
layout: vertical; layout: vertical;
height: 100%; height: 100%;
@ -17,21 +38,51 @@ BingoDisplay {
.seed_input { .seed_input {
width: 70%; width: 70%;
border-title-align: left; border-title-align: left;
background: $background;
} }
.roll_btn { .roll_btn {
width: 30%; width: 30%;
} }
.box {
height: 100%;
border: solid green;
text-align: center;
content-align: center middle;
}
Input.-valid { Input.-valid {
border: tall $success 60%; border: tall $success 60%;
} }
Input.-valid:focus { Input.-valid:focus {
border: tall $success; border: tall $success;
} }
Sidebar {
width: 40;
background: $panel;
transition: offset 500ms in_out_cubic;
layer: overlay;
}
Sidebar:focus-within {
offset: 0 0 !important;
}
Sidebar.-hidden {
offset-x: -100%;
}
Static.title {
background: $boost;
color: $secondary;
padding: 2 4;
border-right: vkey $background;
dock: top;
text-align: center;
text-style: bold;
}
Static.message {
background: $boost;
color: $text;
height: 1fr;
border-right: vkey $background;
text-align: center;
}
Button.btn_sidebar {
width: 100%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Loading…
Cancel
Save