quizzy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quizzy/__init__.py +3 -0
- quizzy/__main__.py +9 -0
- quizzy/app.py +208 -0
- quizzy/models.py +59 -0
- quizzy/quizzy.tcss +101 -0
- quizzy-0.1.0.dist-info/METADATA +69 -0
- quizzy-0.1.0.dist-info/RECORD +10 -0
- quizzy-0.1.0.dist-info/WHEEL +4 -0
- quizzy-0.1.0.dist-info/entry_points.txt +2 -0
- quizzy-0.1.0.dist-info/licenses/LICENSE +21 -0
quizzy/__init__.py
ADDED
quizzy/__main__.py
ADDED
quizzy/app.py
ADDED
@@ -0,0 +1,208 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import pathlib
|
5
|
+
|
6
|
+
from textual import app, containers, log, message, reactive, screen, widgets
|
7
|
+
|
8
|
+
from quizzy import __version__, models
|
9
|
+
|
10
|
+
|
11
|
+
def get_arg_parser() -> argparse.ArgumentParser:
|
12
|
+
parser = argparse.ArgumentParser(prog=__name__.split(".")[0], description="A terminal quiz app")
|
13
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
14
|
+
parser.add_argument("quizfile", type=pathlib.Path, help="Quiz file")
|
15
|
+
return parser
|
16
|
+
|
17
|
+
|
18
|
+
class AnswerScreen(screen.ModalScreen[models.Team | None]):
|
19
|
+
NOONE_ANSWERED_ID = "__noone-answered"
|
20
|
+
|
21
|
+
def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
|
22
|
+
super().__init__(classes="question-answer-screen")
|
23
|
+
self.category = category
|
24
|
+
self.question = question
|
25
|
+
self.teams = {team.id: team for team in teams}
|
26
|
+
self.border_title = f"{category} - {question.value} points"
|
27
|
+
|
28
|
+
def compose(self) -> app.ComposeResult:
|
29
|
+
question_widget = widgets.Static(self.question.question, id="question")
|
30
|
+
question_widget.border_title = "Question"
|
31
|
+
|
32
|
+
answer_widget = widgets.Static(self.question.answer, id="answer")
|
33
|
+
answer_widget.border_title = "Answer"
|
34
|
+
|
35
|
+
whoanswered = containers.HorizontalGroup(
|
36
|
+
*[
|
37
|
+
containers.Vertical(widgets.Button(team.name, id=team_id, variant="primary"))
|
38
|
+
for team_id, team in self.teams.items()
|
39
|
+
],
|
40
|
+
id="who-answered",
|
41
|
+
classes="horizontal-100",
|
42
|
+
)
|
43
|
+
whoanswered.border_title = "Who Answered Correctly?"
|
44
|
+
|
45
|
+
container = containers.Grid(
|
46
|
+
question_widget,
|
47
|
+
answer_widget,
|
48
|
+
whoanswered,
|
49
|
+
containers.Horizontal(
|
50
|
+
widgets.Button(
|
51
|
+
"😠No one answered correctly ðŸ˜", id=self.NOONE_ANSWERED_ID, variant="error", classes="button-100"
|
52
|
+
),
|
53
|
+
classes="horizontal-100",
|
54
|
+
),
|
55
|
+
classes="question-answer-dialog",
|
56
|
+
id="dialog",
|
57
|
+
)
|
58
|
+
|
59
|
+
container.border_title = f"{self.category} - {self.question.value} points"
|
60
|
+
yield container
|
61
|
+
|
62
|
+
def on_button_pressed(self, event: widgets.Button.Pressed) -> None:
|
63
|
+
if event.button.id == self.NOONE_ANSWERED_ID:
|
64
|
+
self.dismiss(None)
|
65
|
+
elif event.button.id in self.teams:
|
66
|
+
team = self.teams[event.button.id]
|
67
|
+
self.dismiss(team)
|
68
|
+
|
69
|
+
|
70
|
+
class QuestionScreen(screen.ModalScreen[models.Team | None]):
|
71
|
+
SHOW_ANSWER_ID = "show-answer"
|
72
|
+
|
73
|
+
def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
|
74
|
+
super().__init__(classes="question-answer-screen")
|
75
|
+
self.category = category
|
76
|
+
self.question = question
|
77
|
+
self.teams = teams
|
78
|
+
|
79
|
+
def compose(self) -> app.ComposeResult:
|
80
|
+
question_widget = widgets.Static(self.question.question, id="question")
|
81
|
+
question_widget.border_title = "Question"
|
82
|
+
|
83
|
+
container = containers.Grid(
|
84
|
+
question_widget,
|
85
|
+
containers.Horizontal(
|
86
|
+
widgets.Button("Show Answer", id=self.SHOW_ANSWER_ID, variant="primary", classes="button-100"),
|
87
|
+
classes="horizontal-100",
|
88
|
+
),
|
89
|
+
classes="question-answer-dialog",
|
90
|
+
id="dialog",
|
91
|
+
)
|
92
|
+
|
93
|
+
container.border_title = f"{self.category} - {self.question.value} points"
|
94
|
+
yield container
|
95
|
+
|
96
|
+
def on_button_pressed(self, event: widgets.Button.Pressed) -> None:
|
97
|
+
def dismiss(team: models.Team | None) -> None:
|
98
|
+
self.dismiss(team)
|
99
|
+
|
100
|
+
if event.button.id == self.SHOW_ANSWER_ID:
|
101
|
+
self.app.push_screen(AnswerScreen(self.category, self.question, self.teams), dismiss)
|
102
|
+
|
103
|
+
|
104
|
+
class TeamScore(containers.Vertical):
|
105
|
+
score = reactive.reactive(0, recompose=True)
|
106
|
+
|
107
|
+
def __init__(self, team: models.Team) -> None:
|
108
|
+
self.team = team
|
109
|
+
super().__init__()
|
110
|
+
self.border_title = self.team.name
|
111
|
+
self.score = team.score
|
112
|
+
|
113
|
+
def compose(self) -> app.ComposeResult:
|
114
|
+
yield widgets.Static(str(self.score))
|
115
|
+
|
116
|
+
def watch_score(self, score: int) -> None:
|
117
|
+
"""
|
118
|
+
Watch the reactive score and update the team's score in the data object. This allows dumping the quiz state
|
119
|
+
to YAML.
|
120
|
+
"""
|
121
|
+
self.team.score = score
|
122
|
+
|
123
|
+
|
124
|
+
class Scoreboard(containers.Vertical):
|
125
|
+
def __init__(self, teams: list[models.Team]) -> None:
|
126
|
+
super().__init__()
|
127
|
+
self.teams = teams
|
128
|
+
self.team_score_widgets = {team.id: TeamScore(team) for team in teams}
|
129
|
+
|
130
|
+
def compose(self) -> app.ComposeResult:
|
131
|
+
yield containers.HorizontalGroup(*self.team_score_widgets.values())
|
132
|
+
|
133
|
+
def update_team_score(self, team_id: str, value: int) -> None:
|
134
|
+
log(f"scoreboard: Updating team score {team_id} to {value}")
|
135
|
+
self.team_score_widgets[team_id].score += value
|
136
|
+
|
137
|
+
|
138
|
+
class QuestionButton(widgets.Button):
|
139
|
+
class Answered(message.Message):
|
140
|
+
def __init__(self, team: models.Team, question_value: int) -> None:
|
141
|
+
self.team = team
|
142
|
+
self.value = question_value
|
143
|
+
super().__init__()
|
144
|
+
|
145
|
+
def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
|
146
|
+
self.question = question
|
147
|
+
self.teams = teams
|
148
|
+
self.category = category
|
149
|
+
super().__init__(str(question.value), variant="warning", classes="button-100")
|
150
|
+
# Disable the button if the question has already been answered on init. This might be useful when starting with
|
151
|
+
# a state
|
152
|
+
self.disabled = question.answered
|
153
|
+
|
154
|
+
def on_click(self) -> None:
|
155
|
+
# First, disable the button to prevent multiple clicks
|
156
|
+
self.disabled = True
|
157
|
+
self.question.answered = True
|
158
|
+
|
159
|
+
def wait_for_result(team: models.Team | None) -> None:
|
160
|
+
if team is None:
|
161
|
+
log("question-button: No-one answered the question")
|
162
|
+
else:
|
163
|
+
log(f"question-button: {team.id} answered the question")
|
164
|
+
self.post_message(self.Answered(team, self.question.value))
|
165
|
+
|
166
|
+
self.app.push_screen(QuestionScreen(self.category, self.question, self.teams), wait_for_result)
|
167
|
+
|
168
|
+
|
169
|
+
class CategoryColumn(containers.VerticalGroup):
|
170
|
+
def __init__(self, category: models.Category, teams: list[models.Team]) -> None:
|
171
|
+
self.category = category
|
172
|
+
self.teams = teams
|
173
|
+
super().__init__()
|
174
|
+
|
175
|
+
def compose(self) -> app.ComposeResult:
|
176
|
+
yield widgets.Static(self.category.name)
|
177
|
+
for question in self.category.questions:
|
178
|
+
yield QuestionButton(self.category.name, question, self.teams)
|
179
|
+
|
180
|
+
|
181
|
+
class QuestionBoard(containers.HorizontalGroup):
|
182
|
+
def __init__(self, config: models.Config) -> None:
|
183
|
+
self.config = config
|
184
|
+
super().__init__()
|
185
|
+
|
186
|
+
def compose(self) -> app.ComposeResult:
|
187
|
+
for category in self.config.categories:
|
188
|
+
yield CategoryColumn(category, self.config.teams)
|
189
|
+
|
190
|
+
|
191
|
+
class QuizzyApp(app.App[None]):
|
192
|
+
CSS_PATH = "quizzy.tcss"
|
193
|
+
|
194
|
+
def __init__(self) -> None:
|
195
|
+
super().__init__()
|
196
|
+
parser = get_arg_parser()
|
197
|
+
namespace = parser.parse_args()
|
198
|
+
|
199
|
+
self.config = models.load_config(namespace.quizfile)
|
200
|
+
self.scoreboard_widget = Scoreboard(self.config.teams)
|
201
|
+
|
202
|
+
def compose(self) -> app.ComposeResult:
|
203
|
+
yield widgets.Header()
|
204
|
+
yield widgets.Footer()
|
205
|
+
yield containers.Grid(QuestionBoard(self.config), self.scoreboard_widget, id="app-grid")
|
206
|
+
|
207
|
+
def on_question_button_answered(self, event: QuestionButton.Answered) -> None:
|
208
|
+
self.scoreboard_widget.update_team_score(event.team.id, event.value)
|
quizzy/models.py
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import pathlib
|
4
|
+
from typing import Self
|
5
|
+
|
6
|
+
import yaml
|
7
|
+
from pydantic import Field, model_validator
|
8
|
+
from pydantic.dataclasses import dataclass
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class Team:
|
13
|
+
name: str = Field(min_length=2)
|
14
|
+
score: int = Field(default=0, ge=0)
|
15
|
+
id: str = Field(init=False)
|
16
|
+
|
17
|
+
def __post_init__(self) -> None:
|
18
|
+
self.id = self.name.replace(" ", "-").lower()
|
19
|
+
|
20
|
+
def __str__(self) -> str:
|
21
|
+
return self.name
|
22
|
+
|
23
|
+
|
24
|
+
@dataclass
|
25
|
+
class Question:
|
26
|
+
question: str
|
27
|
+
answer: str
|
28
|
+
value: int = Field(gt=0)
|
29
|
+
answered: bool = False
|
30
|
+
|
31
|
+
def __str__(self) -> str:
|
32
|
+
return self.question
|
33
|
+
|
34
|
+
|
35
|
+
@dataclass
|
36
|
+
class Category:
|
37
|
+
name: str
|
38
|
+
questions: list[Question] = Field(max_length=5)
|
39
|
+
|
40
|
+
@model_validator(mode="after")
|
41
|
+
def sort_questions(self) -> Self:
|
42
|
+
"""Sort the questions in this category by their value."""
|
43
|
+
|
44
|
+
self.questions.sort(key=lambda q: q.value)
|
45
|
+
return self
|
46
|
+
|
47
|
+
|
48
|
+
@dataclass
|
49
|
+
class Config:
|
50
|
+
categories: list[Category] = Field(max_length=5)
|
51
|
+
teams: list[Team] = Field(default=[Team("Team 1"), Team("Team 2")])
|
52
|
+
# TODO: Support random questions
|
53
|
+
|
54
|
+
|
55
|
+
def load_config(path: pathlib.Path) -> Config:
|
56
|
+
with path.open() as f:
|
57
|
+
raw = yaml.safe_load(f)
|
58
|
+
|
59
|
+
return Config(**raw)
|
quizzy/quizzy.tcss
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
TeamScore {
|
2
|
+
border: solid $primary;
|
3
|
+
align: center top;
|
4
|
+
height: 3;
|
5
|
+
margin: 1;
|
6
|
+
}
|
7
|
+
|
8
|
+
|
9
|
+
.question-answer-screen {
|
10
|
+
align: center middle;
|
11
|
+
}
|
12
|
+
|
13
|
+
.question-answer-dialog {
|
14
|
+
width: 80%;
|
15
|
+
height: 80%;
|
16
|
+
min-height: 40;
|
17
|
+
padding: 0 2;
|
18
|
+
border: solid $primary;
|
19
|
+
background: $surface;
|
20
|
+
content-align: center middle;
|
21
|
+
|
22
|
+
#question {
|
23
|
+
content-align: center middle;
|
24
|
+
padding: 2;
|
25
|
+
border: solid $foreground 80%;
|
26
|
+
}
|
27
|
+
|
28
|
+
#answer {
|
29
|
+
content-align: center middle;
|
30
|
+
padding: 2;
|
31
|
+
border: solid $success 80%;
|
32
|
+
|
33
|
+
}
|
34
|
+
}
|
35
|
+
|
36
|
+
QuestionScreen {
|
37
|
+
#dialog {
|
38
|
+
grid-size: 1;
|
39
|
+
grid-rows: 90% 6;
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
.horizontal-100 {
|
44
|
+
width: 100%;
|
45
|
+
}
|
46
|
+
|
47
|
+
Button.button-100 {
|
48
|
+
width: 100%;
|
49
|
+
|
50
|
+
&:focus {
|
51
|
+
/* Disable reversing for text of focussed full-width buttons */
|
52
|
+
text-style: bold;
|
53
|
+
}
|
54
|
+
}
|
55
|
+
|
56
|
+
AnswerScreen {
|
57
|
+
#dialog {
|
58
|
+
grid-size: 1;
|
59
|
+
grid-rows: 40% 40% 5 4;
|
60
|
+
|
61
|
+
#who-answered {
|
62
|
+
border: solid $foreground 80%;
|
63
|
+
Button {
|
64
|
+
margin: 0 1;
|
65
|
+
width: 100%;
|
66
|
+
&:focus {
|
67
|
+
/* Disable reversing for text of focussed buttons in the who-answered row */
|
68
|
+
text-style: bold;
|
69
|
+
}
|
70
|
+
}
|
71
|
+
}
|
72
|
+
}
|
73
|
+
|
74
|
+
}
|
75
|
+
|
76
|
+
QuestionBoard {
|
77
|
+
width: 100%;
|
78
|
+
padding: 5 0 0 0;
|
79
|
+
min-width: 50%;
|
80
|
+
align: center top;
|
81
|
+
}
|
82
|
+
|
83
|
+
CategoryColumn {
|
84
|
+
width: auto;
|
85
|
+
min-width: 30;
|
86
|
+
border: solid $secondary;
|
87
|
+
align: center middle;
|
88
|
+
|
89
|
+
Button {
|
90
|
+
margin: 1;
|
91
|
+
|
92
|
+
&:disabled {
|
93
|
+
opacity: 0.5;
|
94
|
+
}
|
95
|
+
}
|
96
|
+
}
|
97
|
+
|
98
|
+
#app-grid {
|
99
|
+
grid-size: 1 2;
|
100
|
+
grid-rows: 1fr 5;
|
101
|
+
}
|
@@ -0,0 +1,69 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: quizzy
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: A Python TUI quiz app
|
5
|
+
Author-email: Jonas Ehrlich <jonas.ehrlich@gmail.com>
|
6
|
+
License-Expression: MIT
|
7
|
+
License-File: LICENSE
|
8
|
+
Requires-Python: >=3.10
|
9
|
+
Requires-Dist: pydantic>=2.10.3
|
10
|
+
Requires-Dist: pyyaml>=6.0.2
|
11
|
+
Requires-Dist: textual>=1.0.0
|
12
|
+
Description-Content-Type: text/markdown
|
13
|
+
|
14
|
+
# Quizzy
|
15
|
+
|
16
|
+
A quiz app using [textual](https://textual.textualize.io/).
|
17
|
+
|
18
|
+

|
19
|
+
|
20
|
+
|
21
|
+
## Configuration and Questions
|
22
|
+
|
23
|
+
Create a YAML file to define the teams participating, the questions and their answers.
|
24
|
+
|
25
|
+
```yaml
|
26
|
+
teams:
|
27
|
+
- name: "Team 1"
|
28
|
+
- name: "Team 2"
|
29
|
+
categories:
|
30
|
+
- name: "General Knowledge"
|
31
|
+
questions:
|
32
|
+
- question: "What is the capital of France?"
|
33
|
+
answer: "Paris"
|
34
|
+
value: 100
|
35
|
+
- question: "What is the capital of Germany?"
|
36
|
+
answer: "Berlin"
|
37
|
+
value: 200
|
38
|
+
- name: "Science"
|
39
|
+
questions:
|
40
|
+
- question: "What is the chemical symbol for gold?"
|
41
|
+
answer: "Au"
|
42
|
+
value: 100
|
43
|
+
- question: "What is the chemical symbol for silver?"
|
44
|
+
answer: "Ag"
|
45
|
+
value: 200
|
46
|
+
```
|
47
|
+
|
48
|
+
See [examples/quizzy.yaml](examples/quizzy.yaml) for an example.
|
49
|
+
|
50
|
+
|
51
|
+
## Running it
|
52
|
+
|
53
|
+
Run normally using *uv*:
|
54
|
+
|
55
|
+
``` sh
|
56
|
+
uv run quizzy examples/quizzy.yaml
|
57
|
+
```
|
58
|
+
|
59
|
+
Serve using textual:
|
60
|
+
|
61
|
+
``` sh
|
62
|
+
uv run textual serve "uv run quizzy examples/quizzy.yaml"
|
63
|
+
```
|
64
|
+
|
65
|
+
Run in development mode:
|
66
|
+
|
67
|
+
``` sh
|
68
|
+
uv run textual run --dev quizzy.app:QuizzyApp examples/quizzy.yaml
|
69
|
+
```
|
@@ -0,0 +1,10 @@
|
|
1
|
+
quizzy/__init__.py,sha256=IILIKzO2OO60Rhsh3SqhdKHDf7XI_RLRwzztBGZZmHY,78
|
2
|
+
quizzy/__main__.py,sha256=TxTz5dT__4--aF49xzShPppphmYLCNYKMraPhp6scIc,117
|
3
|
+
quizzy/app.py,sha256=B9fIHy9L5HILbuDoY4ief0wCcVY8_X9Uqb0ByK_ihQk,7687
|
4
|
+
quizzy/models.py,sha256=JrAc2IvtXByIKNg_lX7uQMK19zwi2WsYcNbttXccbOs,1265
|
5
|
+
quizzy/quizzy.tcss,sha256=ixFfm8pT_JLdL0XZ-4n8u0fs3l9lAHWQa3-q9d4bRRU,1455
|
6
|
+
quizzy-0.1.0.dist-info/METADATA,sha256=l3qoY9aRtg1Cpx-QvLHckMP7LmpjsviO9qg15o0oeT4,1464
|
7
|
+
quizzy-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
8
|
+
quizzy-0.1.0.dist-info/entry_points.txt,sha256=2RiVMgcS4h7TM59u9dyBQFm53cG6Eyekmb8fqZ5rXHM,48
|
9
|
+
quizzy-0.1.0.dist-info/licenses/LICENSE,sha256=JWN3MACgsucm6y_rlL_2MUzst0-wNh-Wab3XkxtfVzM,1070
|
10
|
+
quizzy-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Jonas Ehrlich
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|