gridworks-admin 0.1.0.dev3__tar.gz
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.
- gridworks_admin-0.1.0.dev3/PKG-INFO +19 -0
- gridworks_admin-0.1.0.dev3/README.md +0 -0
- gridworks_admin-0.1.0.dev3/pyproject.toml +34 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/cli.py +167 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/settings.py +31 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/actions.py +46 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/actions05.tcss +20 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/cli.py +39 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/stopwatch.py +109 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/stopwatch.tcss +51 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/switch.py +42 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/switch.tcss +28 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/admin_client.py +249 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/constrained_mqtt_client.py +337 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/relay_client.py +342 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/relay_app.py +135 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/relay_app.tcss +52 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/watchex_app.py +49 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/watchex_app.tcss +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/__init__.py +0 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/keepalive.py +76 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/mqtt.py +47 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_state_text.py +33 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_toggle_button.py +110 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_widget_info.py +124 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relays.py +265 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/time_input.py +13 -0
- gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/timer.py +64 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: gridworks-admin
|
|
3
|
+
Version: 0.1.0.dev3
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Andrew Schweitzer
|
|
6
|
+
Author-email: Andrew Schweitzer <schweitz72@gmail.com>
|
|
7
|
+
Requires-Dist: gridworks-proactor>=4.1.9
|
|
8
|
+
Requires-Dist: gridworks-scada-protocol
|
|
9
|
+
Requires-Dist: numpy>=2.3.3
|
|
10
|
+
Requires-Dist: paho-mqtt>=2.1.0
|
|
11
|
+
Requires-Dist: pydantic>=2.11.9
|
|
12
|
+
Requires-Dist: python-dotenv>=1.1.1
|
|
13
|
+
Requires-Dist: result>=0.9.0
|
|
14
|
+
Requires-Dist: rich>=14.1.0
|
|
15
|
+
Requires-Dist: textual>=6.1.0
|
|
16
|
+
Requires-Dist: typer>=0.17.4
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gridworks-admin"
|
|
3
|
+
version = "0.1.0.dev3"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Andrew Schweitzer", email = "schweitz72@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"gridworks-proactor>=4.1.9",
|
|
12
|
+
"gridworks-scada-protocol",
|
|
13
|
+
"numpy>=2.3.3",
|
|
14
|
+
"paho-mqtt>=2.1.0",
|
|
15
|
+
"pydantic>=2.11.9",
|
|
16
|
+
"python-dotenv>=1.1.1",
|
|
17
|
+
"result>=0.9.0",
|
|
18
|
+
"rich>=14.1.0",
|
|
19
|
+
"textual>=6.1.0",
|
|
20
|
+
"typer>=0.17.4",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
gwa = "gwadmin:cli.app"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.8.8,<0.9.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
gridworks-scada-protocol = { path = "../gridworks-scada-protocol", editable = true }
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "gwadmin"
|
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import dotenv
|
|
8
|
+
import rich
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from gwadmin.tdemo.cli import app as tdemo_cli
|
|
12
|
+
from gwadmin.settings import AdminClientSettings
|
|
13
|
+
from gwadmin.watch.relay_app import RelaysApp, __version__
|
|
14
|
+
from gwadmin.watch.watchex.watchex_app import WatchExApp
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
pretty_exceptions_enable=False,
|
|
19
|
+
rich_markup_mode="rich",
|
|
20
|
+
help="GridWorks Scada Admin Client, version {__version__}",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app.add_typer(tdemo_cli, name="demo", help="Textual demo commands.")
|
|
24
|
+
|
|
25
|
+
DEFAULT_TARGET: str = "d1.isone.me.versant.keene.orange.scada"
|
|
26
|
+
|
|
27
|
+
class RelayState(StrEnum):
|
|
28
|
+
open = "0"
|
|
29
|
+
closed = "1"
|
|
30
|
+
|
|
31
|
+
def watch_settings(
|
|
32
|
+
target: str = "",
|
|
33
|
+
env_file: str = ".env",
|
|
34
|
+
verbose: int = 0,
|
|
35
|
+
paho_verbose: int = 0,
|
|
36
|
+
show_clock: bool = False,
|
|
37
|
+
show_footer: bool = False,
|
|
38
|
+
) -> AdminClientSettings:
|
|
39
|
+
# https://github.com/koxudaxi/pydantic-pycharm-plugin/issues/1013
|
|
40
|
+
# noinspection PyArgumentList
|
|
41
|
+
settings = AdminClientSettings(
|
|
42
|
+
_env_file=dotenv.find_dotenv(env_file),
|
|
43
|
+
show_clock=show_clock,
|
|
44
|
+
show_footer=show_footer,
|
|
45
|
+
).update_paths_name("admin")
|
|
46
|
+
if target:
|
|
47
|
+
settings.target_gnode = target
|
|
48
|
+
elif not settings.target_gnode:
|
|
49
|
+
settings.target_gnode = DEFAULT_TARGET
|
|
50
|
+
if verbose:
|
|
51
|
+
if verbose == 1:
|
|
52
|
+
verbosity = logging.INFO
|
|
53
|
+
else:
|
|
54
|
+
verbosity = logging.DEBUG
|
|
55
|
+
settings.verbosity = verbosity
|
|
56
|
+
if paho_verbose:
|
|
57
|
+
if paho_verbose == 1:
|
|
58
|
+
paho_verbosity = logging.INFO
|
|
59
|
+
else:
|
|
60
|
+
paho_verbosity = logging.DEBUG
|
|
61
|
+
settings.paho_verbosity = paho_verbosity
|
|
62
|
+
return settings
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def watch(
|
|
66
|
+
target: str = "",
|
|
67
|
+
env_file: str = ".env",
|
|
68
|
+
verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
|
|
69
|
+
paho_verbose: Annotated[int, typer.Option("--paho-verbose", count=True)] = 0,
|
|
70
|
+
show_clock: Annotated[
|
|
71
|
+
bool,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--show-clock",
|
|
74
|
+
),
|
|
75
|
+
] = False,
|
|
76
|
+
show_footer: Annotated[
|
|
77
|
+
bool,
|
|
78
|
+
typer.Option(
|
|
79
|
+
"--show-footer",
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Watch and set relays."""
|
|
84
|
+
settings = watch_settings(
|
|
85
|
+
target,
|
|
86
|
+
env_file,
|
|
87
|
+
verbose,
|
|
88
|
+
paho_verbose,
|
|
89
|
+
show_clock=show_clock,
|
|
90
|
+
show_footer=show_footer,
|
|
91
|
+
)
|
|
92
|
+
rich.print(settings)
|
|
93
|
+
watch_app = RelaysApp(settings=settings)
|
|
94
|
+
watch_app.run()
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def watchex(
|
|
98
|
+
target: str = "",
|
|
99
|
+
env_file: str = ".env",
|
|
100
|
+
verbose: Annotated[
|
|
101
|
+
int,
|
|
102
|
+
typer.Option(
|
|
103
|
+
"--verbose", "-v", count=True
|
|
104
|
+
)
|
|
105
|
+
] = 0,
|
|
106
|
+
paho_verbose: Annotated[
|
|
107
|
+
int,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--paho-verbose", count=True
|
|
110
|
+
)
|
|
111
|
+
] = 0
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Watch and set relays with experimental features"""
|
|
114
|
+
settings = watch_settings(target, env_file, verbose, paho_verbose)
|
|
115
|
+
rich.print(settings)
|
|
116
|
+
watch_app = WatchExApp(settings=settings)
|
|
117
|
+
watch_app.run()
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def config(
|
|
121
|
+
target: str = "",
|
|
122
|
+
env_file: str = ".env",
|
|
123
|
+
verbose: Annotated[
|
|
124
|
+
int,
|
|
125
|
+
typer.Option(
|
|
126
|
+
"--verbose", "-v", count=True
|
|
127
|
+
)
|
|
128
|
+
] = 0,
|
|
129
|
+
paho_verbose: Annotated[
|
|
130
|
+
int,
|
|
131
|
+
typer.Option(
|
|
132
|
+
"--paho-verbose", count=True
|
|
133
|
+
)
|
|
134
|
+
] = 0
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Show admin settings."""
|
|
137
|
+
settings = watch_settings(target, env_file, verbose, paho_verbose)
|
|
138
|
+
rich.print(
|
|
139
|
+
f"Env file: <{env_file}> exists: {bool(env_file and Path(env_file).exists())}"
|
|
140
|
+
)
|
|
141
|
+
rich.print(settings)
|
|
142
|
+
missing_tls_paths_ = settings.check_tls_paths_present(raise_error=False)
|
|
143
|
+
if missing_tls_paths_:
|
|
144
|
+
rich.print(missing_tls_paths_)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def version_callback(value: bool):
|
|
148
|
+
if value:
|
|
149
|
+
print(f"gws admin {__version__}")
|
|
150
|
+
raise typer.Exit()
|
|
151
|
+
|
|
152
|
+
@app.callback()
|
|
153
|
+
def _main(
|
|
154
|
+
_version: Annotated[
|
|
155
|
+
Optional[bool],
|
|
156
|
+
typer.Option(
|
|
157
|
+
"--version",
|
|
158
|
+
callback=version_callback,
|
|
159
|
+
is_eager=True,
|
|
160
|
+
help="Show version and exit."
|
|
161
|
+
),
|
|
162
|
+
] = None,
|
|
163
|
+
) -> None: ...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
app()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from gwproactor import AppSettings
|
|
6
|
+
from gwproactor.config import MQTTClient
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
from pydantic_settings import SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
from gwsproto.data_classes.house_0_names import H0N
|
|
11
|
+
|
|
12
|
+
MAX_ADMIN_TIMEOUT = 60 * 60 * 24
|
|
13
|
+
|
|
14
|
+
class AdminClientSettings(AppSettings):
|
|
15
|
+
target_gnode: str = ""
|
|
16
|
+
default_timeout_seconds: int = int(5*60)
|
|
17
|
+
link: MQTTClient = MQTTClient()
|
|
18
|
+
verbosity: int = logging.WARN
|
|
19
|
+
paho_verbosity: Optional[int] = None
|
|
20
|
+
show_clock: bool = False
|
|
21
|
+
show_footer: bool = False
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
env_prefix="GWADMIN_",
|
|
24
|
+
env_nested_delimiter="__",
|
|
25
|
+
extra="ignore",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def validate(self) -> Self:
|
|
30
|
+
self.link.update_tls_paths(self.paths.certs_dir, H0N.admin)
|
|
31
|
+
return self
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.widgets import Button
|
|
3
|
+
from textual.widgets import Footer
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
TEXT = """
|
|
7
|
+
[b]Set your background[/b]
|
|
8
|
+
[@click=set_background('cyan')]Cyan[/]
|
|
9
|
+
[@click=set_background('magenta')]Magenta[/]
|
|
10
|
+
[@click=set_background('yellow')]Yellow[/]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ColorSwitcher(Static, can_focus=True):
|
|
15
|
+
BINDINGS = [
|
|
16
|
+
("o", "set_background('olive')", "olive"),
|
|
17
|
+
("w", "set_background('ansi_white')", "ansi_white"),
|
|
18
|
+
("s", "set_background('silver')", "silver"),
|
|
19
|
+
]
|
|
20
|
+
def action_set_background(self, color: str) -> None:
|
|
21
|
+
self.styles.background = color
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActionsApp(App):
|
|
25
|
+
CSS_PATH = "actions05.tcss"
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
("q", "quit"),
|
|
28
|
+
("ctrl+c", "quit"),
|
|
29
|
+
("r", "set_background('red')", "Red"),
|
|
30
|
+
("g", "set_background('green')", "Green"),
|
|
31
|
+
("b", "set_background('blue')", "Blue"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
yield ColorSwitcher(TEXT)
|
|
36
|
+
yield ColorSwitcher(TEXT)
|
|
37
|
+
yield Button("foo")
|
|
38
|
+
yield Footer()
|
|
39
|
+
|
|
40
|
+
def action_set_background(self, color: str) -> None:
|
|
41
|
+
self.screen.styles.background = color
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
app = ActionsApp()
|
|
46
|
+
app.run()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
layout: grid;
|
|
3
|
+
grid-size: 1;
|
|
4
|
+
grid-gutter: 2 4;
|
|
5
|
+
grid-rows: 1fr;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
ColorSwitcher {
|
|
9
|
+
height: 100%;
|
|
10
|
+
margin: 2 4;
|
|
11
|
+
border: solid blue;
|
|
12
|
+
|
|
13
|
+
&:focus {
|
|
14
|
+
background: $primary;
|
|
15
|
+
color: $text;
|
|
16
|
+
text-style: bold;
|
|
17
|
+
outline-left: thick $accent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from gwadmin.tdemo.actions import ActionsApp
|
|
4
|
+
from gwadmin.tdemo.stopwatch import StopwatchApp
|
|
5
|
+
from gwadmin.tdemo.switch import SwitchApp
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
pretty_exceptions_enable=False,
|
|
10
|
+
rich_markup_mode="rich",
|
|
11
|
+
help="Textual demo apps",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def stopwatch():
|
|
17
|
+
"""Run textual stopwatch demo"""
|
|
18
|
+
stopwatch_app = StopwatchApp()
|
|
19
|
+
stopwatch_app.run()
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def switch():
|
|
23
|
+
"""Run textual switch demo"""
|
|
24
|
+
switch_app = SwitchApp()
|
|
25
|
+
switch_app.run()
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def actions():
|
|
29
|
+
"""Run textual actions demo"""
|
|
30
|
+
actions_app = ActionsApp()
|
|
31
|
+
actions_app.run()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def _main() -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
app()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.containers import HorizontalGroup, VerticalScroll
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Button, Digits, Footer, Header
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TimeDisplay(Digits):
|
|
10
|
+
"""A widget to display elapsed time."""
|
|
11
|
+
|
|
12
|
+
start_time = reactive(monotonic)
|
|
13
|
+
time = reactive(0.0)
|
|
14
|
+
total = reactive(0.0)
|
|
15
|
+
|
|
16
|
+
def on_mount(self) -> None:
|
|
17
|
+
"""Event handler called when widget is added to the app."""
|
|
18
|
+
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
|
|
19
|
+
|
|
20
|
+
def update_time(self) -> None:
|
|
21
|
+
"""Method to update time to current."""
|
|
22
|
+
self.time = self.total + (monotonic() - self.start_time)
|
|
23
|
+
|
|
24
|
+
def watch_time(self, time: float) -> None:
|
|
25
|
+
"""Called when the time attribute changes."""
|
|
26
|
+
minutes, seconds = divmod(time, 60)
|
|
27
|
+
hours, minutes = divmod(minutes, 60)
|
|
28
|
+
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
|
|
29
|
+
|
|
30
|
+
def start(self) -> None:
|
|
31
|
+
"""Method to start (or resume) time updating."""
|
|
32
|
+
self.start_time = monotonic()
|
|
33
|
+
self.update_timer.resume()
|
|
34
|
+
|
|
35
|
+
def stop(self):
|
|
36
|
+
"""Method to stop the time display updating."""
|
|
37
|
+
self.update_timer.pause()
|
|
38
|
+
self.total += monotonic() - self.start_time
|
|
39
|
+
self.time = self.total
|
|
40
|
+
|
|
41
|
+
def reset(self):
|
|
42
|
+
"""Method to reset the time display to zero."""
|
|
43
|
+
self.total = 0
|
|
44
|
+
self.time = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Stopwatch(HorizontalGroup):
|
|
48
|
+
"""A stopwatch widget."""
|
|
49
|
+
|
|
50
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
51
|
+
"""Event handler called when a button is pressed."""
|
|
52
|
+
button_id = event.button.id
|
|
53
|
+
time_display = self.query_one(TimeDisplay)
|
|
54
|
+
if button_id == "start":
|
|
55
|
+
time_display.start()
|
|
56
|
+
self.add_class("started")
|
|
57
|
+
elif button_id == "stop":
|
|
58
|
+
time_display.stop()
|
|
59
|
+
self.remove_class("started")
|
|
60
|
+
elif button_id == "reset":
|
|
61
|
+
time_display.reset()
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
"""Create child widgets of a stopwatch."""
|
|
65
|
+
yield Button("Start", id="start", variant="success")
|
|
66
|
+
yield Button("Stop", id="stop", variant="error")
|
|
67
|
+
yield Button("Reset", id="reset")
|
|
68
|
+
yield TimeDisplay()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StopwatchApp(App):
|
|
72
|
+
"""A Textual app to manage stopwatches."""
|
|
73
|
+
|
|
74
|
+
CSS_PATH = "stopwatch.tcss"
|
|
75
|
+
|
|
76
|
+
BINDINGS = [
|
|
77
|
+
("d", "toggle_dark", "Toggle dark mode"),
|
|
78
|
+
("a", "add_stopwatch", "Add"),
|
|
79
|
+
("r", "remove_stopwatch", "Remove"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def compose(self) -> ComposeResult:
|
|
83
|
+
"""Called to add widgets to the app."""
|
|
84
|
+
yield Header()
|
|
85
|
+
yield Footer()
|
|
86
|
+
yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
|
|
87
|
+
|
|
88
|
+
def action_add_stopwatch(self) -> None:
|
|
89
|
+
"""An action to add a timer."""
|
|
90
|
+
new_stopwatch = Stopwatch()
|
|
91
|
+
self.query_one("#timers").mount(new_stopwatch)
|
|
92
|
+
new_stopwatch.scroll_visible()
|
|
93
|
+
|
|
94
|
+
def action_remove_stopwatch(self) -> None:
|
|
95
|
+
"""Called to remove a timer."""
|
|
96
|
+
timers = self.query("Stopwatch")
|
|
97
|
+
if timers:
|
|
98
|
+
timers.last().remove()
|
|
99
|
+
|
|
100
|
+
def action_toggle_dark(self) -> None:
|
|
101
|
+
"""An action to toggle dark mode."""
|
|
102
|
+
self.theme = (
|
|
103
|
+
"textual-dark" if self.theme == "textual-light" else "textual-light"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
app = StopwatchApp()
|
|
109
|
+
app.run()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Stopwatch {
|
|
2
|
+
background: $boost;
|
|
3
|
+
height: 5;
|
|
4
|
+
margin: 1;
|
|
5
|
+
min-width: 50;
|
|
6
|
+
padding: 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
TimeDisplay {
|
|
10
|
+
text-align: center;
|
|
11
|
+
color: $foreground-muted;
|
|
12
|
+
height: 3;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Button {
|
|
16
|
+
width: 16;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#start {
|
|
20
|
+
dock: left;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#stop {
|
|
24
|
+
dock: left;
|
|
25
|
+
display: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#reset {
|
|
29
|
+
dock: right;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.started {
|
|
33
|
+
background: $success-muted;
|
|
34
|
+
color: $text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.started TimeDisplay {
|
|
38
|
+
color: $foreground;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.started #start {
|
|
42
|
+
display: none
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.started #stop {
|
|
46
|
+
display: block
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.started #reset {
|
|
50
|
+
visibility: hidden
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.containers import Horizontal
|
|
3
|
+
from textual.widgets import Static, Switch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SwitchApp(App):
|
|
7
|
+
CSS_PATH = "switch.tcss"
|
|
8
|
+
|
|
9
|
+
BINDINGS = [
|
|
10
|
+
("d", "toggle_dark", "Toggle dark mode"),
|
|
11
|
+
("q", "quit", "Quit"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
def compose(self) -> ComposeResult:
|
|
15
|
+
yield Static("[b]Example switches\n", classes="label")
|
|
16
|
+
yield Horizontal(
|
|
17
|
+
Static("off: ", classes="label"),
|
|
18
|
+
Switch(animate=False),
|
|
19
|
+
classes="container",
|
|
20
|
+
)
|
|
21
|
+
yield Horizontal(
|
|
22
|
+
Static("on: ", classes="label"),
|
|
23
|
+
Switch(value=True),
|
|
24
|
+
classes="container",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
focused_switch = Switch()
|
|
28
|
+
focused_switch.focus()
|
|
29
|
+
yield Horizontal(
|
|
30
|
+
Static("focused: ", classes="label"), focused_switch, classes="container"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
yield Horizontal(
|
|
34
|
+
Static("custom: ", classes="label"),
|
|
35
|
+
Switch(id="custom-design"),
|
|
36
|
+
classes="container",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app = SwitchApp(css_path="switch.tcss")
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
app.run()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.container {
|
|
6
|
+
height: auto;
|
|
7
|
+
width: auto;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
Switch {
|
|
11
|
+
height: auto;
|
|
12
|
+
width: auto;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.label {
|
|
16
|
+
height: 3;
|
|
17
|
+
content-align: center middle;
|
|
18
|
+
width: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#custom-design {
|
|
22
|
+
background: darkslategrey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#custom-design > .switch--slider {
|
|
26
|
+
color: dodgerblue;
|
|
27
|
+
background: darkslateblue;
|
|
28
|
+
}
|
|
File without changes
|
|
File without changes
|