levycas 1.0.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.
Files changed (39) hide show
  1. levycas/__init__.py +13 -0
  2. levycas/__main__.py +160 -0
  3. levycas/cli/__init__.py +1 -0
  4. levycas/cli/__main__.py +59 -0
  5. levycas/cli/screens/__init__.py +5 -0
  6. levycas/cli/screens/graph.py +418 -0
  7. levycas/cli/screens/script.py +108 -0
  8. levycas/cli/screens/styles/graphing.tcss +118 -0
  9. levycas/cli/screens/styles/scripting.tcss +92 -0
  10. levycas/cli/screens/styles/welcome.tcss +24 -0
  11. levycas/cli/screens/welcome.py +16 -0
  12. levycas/expressions/__init__.py +33 -0
  13. levycas/expressions/exp.py +59 -0
  14. levycas/expressions/expression.py +872 -0
  15. levycas/expressions/trig.py +71 -0
  16. levycas/operations/__init__.py +101 -0
  17. levycas/operations/algebraic_ops.py +287 -0
  18. levycas/operations/calculus_ops.py +583 -0
  19. levycas/operations/equation_ops.py +41 -0
  20. levycas/operations/exponential_ops.py +156 -0
  21. levycas/operations/expression_ops.py +214 -0
  22. levycas/operations/factorization_ops.py +546 -0
  23. levycas/operations/numerical_ops.py +212 -0
  24. levycas/operations/polynomial_ops.py +896 -0
  25. levycas/operations/simplification_ops.py +461 -0
  26. levycas/operations/trig_ops.py +351 -0
  27. levycas/parser/__init__.py +8 -0
  28. levycas/parser/lexer.py +50 -0
  29. levycas/parser/parser.py +245 -0
  30. levycas/scripting/__init__.py +13 -0
  31. levycas/scripting/errors.py +7 -0
  32. levycas/scripting/execution.py +253 -0
  33. levycas/scripting/scripting.py +438 -0
  34. levycas-1.0.0.dist-info/METADATA +230 -0
  35. levycas-1.0.0.dist-info/RECORD +39 -0
  36. levycas-1.0.0.dist-info/WHEEL +5 -0
  37. levycas-1.0.0.dist-info/entry_points.txt +2 -0
  38. levycas-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
  39. levycas-1.0.0.dist-info/top_level.txt +1 -0
levycas/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """LevyCAS is an implementation of a Computer Algebra System written
2
+ in pure Python. It requires only the built-in regex (re) and math modules.
3
+
4
+ Install with the `tui` extra and run `levycas` for the interactive Textual user interface.
5
+ Or, check out https://alexlevy.me for an online LevyCAS demo, hosted with Gradio on Huggingface.
6
+
7
+ - Alex Levy (2025)
8
+ """
9
+ from .expressions import *
10
+ from .operations import *
11
+ from .parser import *
12
+ from .scripting import *
13
+ from .cli import *
levycas/__main__.py ADDED
@@ -0,0 +1,160 @@
1
+ from argparse import ArgumentParser
2
+
3
+ from levycas.parser import parse
4
+ from levycas.operations import derivative, integrate, get_symbols, factor, simplify
5
+ from levycas.expressions import Product
6
+
7
+ def print_version():
8
+ try:
9
+ from importlib.metadata import version
10
+ print(f"LevyCAS: version=={version('levycas')}")
11
+ except ImportError:
12
+ print("importlib not found in standard library... LevyCAS may not support this Python version.")
13
+
14
+ def launch_terminal_session():
15
+ raise NotImplementedError("Terminal session not yet implemented... ")
16
+
17
+ def integrate_action(args) -> None:
18
+ expr, wrt = parse(args.expr), parse(args.wrt)
19
+ print(f"{integrate(expr, wrt)}")
20
+
21
+ def diff_action(args) -> None:
22
+ expr, wrt = parse(args.expr), parse(args.wrt)
23
+ print(f"{derivative(expr, wrt)}")
24
+
25
+ def factor_action(args) -> None:
26
+ poly, var = parse(args.poly), parse(args.var)
27
+ factors = factor(poly, var)
28
+ print(simplify(Product(*factors)))
29
+
30
+ def graph_action(args) -> None:
31
+ try:
32
+ import textual, textual_plot
33
+ except ImportError as e:
34
+ raise ImportError(
35
+ f"The LevyCAS TUI requires the '{e.name}' package. "
36
+ f"Please see installation instructions in the README."
37
+ )
38
+
39
+ assert len(args.exprs) <= 4, f"Support for more than four graphs at once is not yet available."
40
+ from levycas.cli.__main__ import LevyCasApp
41
+ app = LevyCasApp(graphing=True, exprs=args.exprs)
42
+ app.run()
43
+
44
+ def launch_tui_action() -> None:
45
+ try:
46
+ import textual, textual_plot
47
+ except ImportError as e:
48
+ raise ImportError(
49
+ f"The LevyCAS TUI requires the '{e.name}' package. "
50
+ f"Please see installation instructions in the README."
51
+ )
52
+
53
+ from levycas.cli.__main__ import main as launch_tui
54
+ launch_tui()
55
+
56
+ def build_parser() -> ArgumentParser:
57
+ # Help opens with `levycas -h or --help`
58
+ parser = ArgumentParser(
59
+ prog="levycas",
60
+ description=(
61
+ "A set of symbolic computation tools, with a powerful text-based UI built with Textual. "
62
+ "Do calculus, simplification, graphing, scripting, and more. "
63
+ ),
64
+ epilog=(
65
+ "See github.com/ajlevy246/LevyCAS/ for examples and source. "
66
+ "Run with no arguments to launch the TUI."
67
+ )
68
+ )
69
+ parser.add_argument("-v", "--version", help="print the levycas version and exit.", action='store_true')
70
+ parser.add_argument("-i", "--interactive", help="launch an interactive python session with common commands already run.", action='store_true')
71
+
72
+ subparsers = parser.add_subparsers(
73
+ dest="command",
74
+ description="The specific command to run. Run with no command to launch the TUI.")
75
+
76
+ # Differentiate with `levycas diff ...`
77
+ diff_parser = subparsers.add_parser(
78
+ "diff",
79
+ help="compute the derivative of an expression.",
80
+ )
81
+ diff_parser.add_argument(
82
+ "expr", metavar="EXPR",
83
+ help="the expression to compute the derivative of, e.g. 'x^2ln(x)'",
84
+ )
85
+ diff_parser.add_argument(
86
+ "wrt", metavar="VAR",
87
+ nargs="?", default="x",
88
+ help="the variable of differentiation; 'x' by default.",
89
+ )
90
+
91
+ # Integrate with `levycas integrate ...`
92
+ int_parser = subparsers.add_parser(
93
+ "integrate",
94
+ help="compute the integral of an expression.",
95
+ )
96
+ int_parser.add_argument(
97
+ "expr", metavar="EXPR",
98
+ help="the expression to compute the integral of, e.g. 'xsin(x^2)",
99
+ )
100
+ int_parser.add_argument(
101
+ "wrt", metavar="VAR",
102
+ nargs="?", default="x",
103
+ help="the variable of integration; 'x' by default.",
104
+ )
105
+
106
+ # Factor with `levycas factor ...`
107
+ fact_parser = subparsers.add_parser(
108
+ "factor",
109
+ help="factor a univariate polynomial with rational coefficients.",
110
+ )
111
+ fact_parser.add_argument(
112
+ "poly", metavar="POLY",
113
+ help="the polynomial to factor, e.g. 'x^2 + 3x + 2'",
114
+ )
115
+ fact_parser.add_argument(
116
+ "var", metavar="VAR",
117
+ nargs="?", default="x",
118
+ help="the polynomial variable; 'x' by default.",
119
+ )
120
+
121
+ # Graph with `levycas graph ...`
122
+ graph_parser = subparsers.add_parser(
123
+ "graph",
124
+ help="graph up to four expressions.",
125
+ )
126
+ graph_parser.add_argument(
127
+ "exprs", metavar="EXPRS",
128
+ nargs="*",
129
+ help="enter up to four expressions to plot, e.g. 'sin(x)'",
130
+ )
131
+ return parser
132
+
133
+ def main():
134
+ parser = build_parser()
135
+ args = parser.parse_args()
136
+
137
+ # -v or -i launch actions and exit
138
+ if args.version:
139
+ print_version()
140
+ return
141
+
142
+ if args.interactive:
143
+ launch_terminal_session()
144
+ return
145
+
146
+ # otherwise, expect a command + optional arguments
147
+ command = args.command
148
+ if command is None:
149
+ launch_tui_action()
150
+ elif command == "integrate":
151
+ integrate_action(args)
152
+ elif command == "diff":
153
+ diff_action(args)
154
+ elif command == "factor":
155
+ factor_action(args)
156
+ elif command == "graph":
157
+ graph_action(args)
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1 @@
1
+ """The LevyCAS CLI; contains the Textual app interface and widgets."""
@@ -0,0 +1,59 @@
1
+ """A Textual interface for the LevyCAS package"""
2
+
3
+ #TODO:
4
+ # Two Interfaces:
5
+ # REPL: One-line input and outputs. Choose operation type (parse, derivate, integrate, et cetera). Input is parsed directly to output.
6
+ # Scripting: Full scripting support using ";" to deliminate instructions.
7
+ # Buttons to switch screens have event handler in main App. Format: name = 'switch-screen', id = 'demo', e.g. (name of screen)
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.containers import Horizontal, Vertical, HorizontalScroll, VerticalScroll
11
+ from textual.widgets import Header, Footer, Input, Static, Button, TextArea
12
+ from textual.theme import Theme
13
+
14
+ from .screens import WelcomeScreen, ScriptingScreen, GraphingScreen
15
+
16
+ levycas_theme = Theme(
17
+ name="levycas",
18
+ primary="#001858",
19
+ secondary="#172c66",
20
+ accent="#f582ae",
21
+ foreground="#172c66",
22
+ background="#fef6e4",
23
+ surface="#8bd3dd",
24
+ panel="#f3d2c1",
25
+ success="#10b981",
26
+ warning="#f59e0b",
27
+ error="#ef4444",
28
+ )
29
+
30
+ class LevyCasApp(App):
31
+ """Root textual app"""
32
+
33
+ TITLE = "LevyCAS - Computer Algebra System"
34
+ SCREENS = {
35
+ 'welcome': WelcomeScreen,
36
+ 'scripting': ScriptingScreen,
37
+ 'graphing': GraphingScreen
38
+ }
39
+
40
+ def __init__(self, graphing: bool=False, exprs: list[str]=None) -> None:
41
+ self.launch_graphing = graphing
42
+ self.exprs = exprs if exprs is not None else []
43
+ super().__init__()
44
+
45
+ def on_mount(self) -> None:
46
+ self.register_theme(levycas_theme)
47
+ self.theme = 'levycas'
48
+ self.push_screen("welcome")
49
+ if self.launch_graphing:
50
+ self.push_screen(GraphingScreen(self.exprs))
51
+
52
+ def on_button_pressed(self, event: Button.Pressed) -> None:
53
+ """Handle a switch-screen request"""
54
+ if event.button.name == "switch-screen":
55
+ self.switch_screen(event.button.id)
56
+
57
+ def main():
58
+ app = LevyCasApp()
59
+ app.run()
@@ -0,0 +1,5 @@
1
+ """Screens and widgets for each function of the LevyCAS Textual app."""
2
+
3
+ from .welcome import WelcomeScreen
4
+ from .script import ScriptingScreen
5
+ from .graph import GraphingScreen
@@ -0,0 +1,418 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.screen import Screen
4
+ from textual.containers import Horizontal, Vertical, VerticalScroll, Center
5
+ from textual.widgets import Header, Static, Button, Input
6
+ from textual.message import Message
7
+ from textual.widget import Widget
8
+ from textual._box_drawing import BOX_CHARACTERS
9
+ from textual.geometry import Offset
10
+
11
+ from textual_hires_canvas import Canvas, HiResMode
12
+ from textual_plot.plot_widget import PlotWidget, LegendLocation
13
+ from rich.text import Text
14
+ from rich.color import ANSI_COLOR_NAMES
15
+
16
+ from ...expressions import Expression, Variable
17
+ from ...operations import sym_eval, get_symbols, trig_simplify
18
+ from ...parser import parse
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # Graph config
23
+ MAX_PLOTS = 4
24
+ DEFAULT_X_BOUNDS = (-13.0, 13.0)
25
+ DEFAULT_Y_BOUNDS = (-10.0, 10.0)
26
+ INPUT_COLORS = ("black", "darkgreen", "purple", "red")
27
+ PLOT_COLORS = ("black", "dark_green", "purple", "red")
28
+ DEFAULT_RES_MODE = HiResMode.BRAILLE
29
+ EPS = 1e-6 # max difference to consider two floats equal
30
+ SIMPLIFY_EXPRESSIONS = True # Simplify expressions fully; may hide removable discontinuities
31
+ # Gridlines
32
+ DRAW_GRIDLINES = True
33
+ GRID_HORIZONTAL_CHAR = BOX_CHARACTERS[(0, 1, 0, 1)]
34
+ GRID_VERTICAL_CHAR = BOX_CHARACTERS[(1, 0, 1, 0)]
35
+ GRID_CROSS_CHAR = BOX_CHARACTERS[(1, 2, 1, 2)]
36
+
37
+ class ExpressionInput(Widget):
38
+ """Single-line expression input field widget.
39
+
40
+ As input is entered, validation is performed.
41
+ When a valid expression is detected, a PlotExpression
42
+ message is propagated.
43
+ """
44
+ SAMPLES = [
45
+ "sin(x)^2",
46
+ "x^2",
47
+ "ln(2x)",
48
+ "x^(1/2)",
49
+ ]
50
+ DEFAULT_TOOLTIP = \
51
+ 'Start typing an expression, e.g. "{sample}"'
52
+
53
+ @dataclass
54
+ class Plot(Message):
55
+ """Request to render a parsed expression."""
56
+ idx: int # index of the expression to graph
57
+ expr: Expression # expression to graph
58
+
59
+ @dataclass
60
+ class Clear(Message):
61
+ """Request to clear a plot."""
62
+ idx: int # index of expression to clear
63
+
64
+ @dataclass
65
+ class Add(Message):
66
+ """Request to add a new input expression."""
67
+ pass
68
+
69
+ def __init__(self, idx: int) -> None:
70
+ self.idx = idx
71
+ sample = self.SAMPLES[idx]
72
+ self.default_tooltip = self.DEFAULT_TOOLTIP.format(sample=sample)
73
+
74
+ self.container = Horizontal(
75
+ classes="expression-input-container",
76
+ id=f"expression-input-container-{idx}",
77
+ )
78
+ self.input = Input(
79
+ placeholder=self.SAMPLES[idx],
80
+ classes="expression-input",
81
+ id=f"expression-input-{idx}",
82
+ )
83
+ self.input.styles.border = ("tall", INPUT_COLORS[idx])
84
+ self.delete_button = Button(
85
+ label="x",
86
+ classes="expression-delete",
87
+ id=f"expression-delete-{idx}",
88
+ )
89
+ self.input.tooltip = self.default_tooltip
90
+
91
+ super().__init__()
92
+
93
+ def compose(self) -> ComposeResult:
94
+ with self.container:
95
+ yield self.input
96
+ yield self.delete_button
97
+
98
+ def on_input_changed(self, event: Input.Changed) -> None:
99
+ """Parse the expression, send plot request if valid."""
100
+ try:
101
+ if not event.value:
102
+ self.input.tooltip = self.default_tooltip
103
+ self.post_message(
104
+ self.Clear(idx=self.idx)
105
+ )
106
+ return
107
+ expr = parse(event.value)
108
+ symbols = get_symbols(expr)
109
+
110
+ if len(symbols) > 1:
111
+ self.input.tooltip = f"Too many variables! How do I plot this?"
112
+ return
113
+ if symbols and Variable('x') not in symbols:
114
+ self.input.tooltip = f"Expected an expression in terms of 'x'."
115
+ return
116
+
117
+ self.input.tooltip = f"Plotting: {expr}"
118
+ self.post_message(
119
+ self.Plot(
120
+ idx=self.idx,
121
+ expr=expr,
122
+ )
123
+ )
124
+
125
+ except (SyntaxError, AssertionError) as e:
126
+ self.input.tooltip = f"Failed to parse: {e}"
127
+
128
+ def key_enter(self) -> None:
129
+ """Request a new input field when enter is pressed."""
130
+ self.post_message(self.Add())
131
+
132
+ class CasPlot(PlotWidget):
133
+ """Extension of `textual-plot`'s PlotWidget.
134
+
135
+ Extended to allow for grid lines and some optimizations.
136
+ Not a general-use widget; hardcoded for the LevyCAS Graphing screen.
137
+ """
138
+ def __init__(self) -> None:
139
+ super().__init__(invert_mouse_wheel=True)
140
+ # Keep track of each expression requesting a plot.
141
+ self.expressions: list[Expression|None] = [None] * MAX_PLOTS
142
+ # Keep track of the number of plots visible for nice legend positioning.
143
+ self.num_legend_markers: int = 0
144
+
145
+ def on_mount(self) -> None:
146
+ super().on_mount()
147
+ self.show_legend(LegendLocation.BOTTOMLEFT)
148
+ self._update_legend()
149
+ legend = self.query_one_optional("#legend", Static)
150
+ legend.offset = Offset(1, 5)
151
+ # if not legend: return
152
+
153
+ def _render_plot(self) -> None:
154
+ """Renders axis lines before drawing plots, then canvas box & ticks."""
155
+ # Return if widget isn't composed yet.
156
+ canvas = self.query_one_optional("#plot", Canvas)
157
+ if canvas is None or canvas._canvas_size is None: return
158
+ canvas.reset()
159
+
160
+ if DRAW_GRIDLINES: self.draw_grid_lines(canvas)
161
+ self.render_expressions(canvas)
162
+ # Render axis, ticks, and labels
163
+ canvas.draw_rectangle_box(
164
+ 0, 0,
165
+ self._scale_rectangle.width + 1, self._scale_rectangle.height + 1,
166
+ thickness=2,
167
+ style=str(self.get_component_rich_style("plot--axis")),
168
+ )
169
+ # render tick marks and labels
170
+ self._render_x_ticks(); self._render_x_label()
171
+ self._render_y_ticks(); self._render_y_label()
172
+ # update legend
173
+ self._update_legend()
174
+
175
+ def draw_grid_lines(self, canvas: Canvas) -> None:
176
+ """Render grid lines at tick labels"""
177
+ x_ticks = self._x_formatter.get_ticks(self._x_min, self._x_max)
178
+ y_ticks = self._y_formatter.get_ticks(self._y_min, self._y_max)
179
+ rect_right_bound = self._scale_rectangle.right - 1
180
+ rect_bottom_bound = self._scale_rectangle.bottom - 1
181
+ coords = []
182
+ for x_tick in x_ticks:
183
+ for y_tick in y_ticks:
184
+ x, y = self.get_pixel_from_coordinate(x_tick, y_tick)
185
+ coords.append((x, y))
186
+ canvas.draw_line( # horizontal line (─)
187
+ 1, y,
188
+ rect_right_bound, y,
189
+ style="white",
190
+ char=GRID_HORIZONTAL_CHAR,
191
+ )
192
+ canvas.draw_line( # vertical line (│)
193
+ x, 1,
194
+ x, rect_bottom_bound,
195
+ style="white",
196
+ char=GRID_VERTICAL_CHAR,
197
+ )
198
+ for x, y in coords: # intersections (┿)
199
+ canvas.set_pixel(
200
+ x, y,
201
+ char=GRID_CROSS_CHAR,
202
+ style="white",
203
+ )
204
+
205
+ def render_expressions(self, canvas: Canvas) -> None:
206
+ """Compute & plot the current expressions.
207
+
208
+ Approximates a graph by computing one point per canvas width coord,
209
+ then drawing straight lines between them.
210
+ """
211
+ density = self._scale_rectangle.width
212
+ for idx, expr in enumerate(self.expressions):
213
+ if expr is None: continue
214
+ color = PLOT_COLORS[idx]
215
+ data = self.compute_data(
216
+ expr, self._x_min, self._x_max, density,
217
+ )
218
+ hires_pixels = [self.get_hires_pixel_from_coordinate(xi, yi) for xi, yi in data]
219
+ segments = [(*hires_pixels[i-1], *hires_pixels[i]) for i in range(1, len(hires_pixels))]
220
+ canvas.draw_hires_lines(segments, style=color, hires_mode=DEFAULT_RES_MODE)
221
+ # canvas.set_hires_pixels(hires_pixels, style=color, hires_mode=DEFAULT_RES_MODE)
222
+
223
+ def update_expression(self, idx: int, expr: Expression) -> None:
224
+ """Plot a new expression."""
225
+ if expr and SIMPLIFY_EXPRESSIONS:
226
+ expr = trig_simplify(expr)
227
+ self.expressions[idx] = expr
228
+ self._rerender()
229
+
230
+ def _update_legend(self) -> None:
231
+ """Update the content and position of the plot legend.
232
+
233
+ Updated to map plot colors to expression names without relying
234
+ on the `PlotWidget.Dataset` class.
235
+ """
236
+ legend = self.query_one_optional("#legend", Static)
237
+ if not legend: return
238
+
239
+ legend_lines = []
240
+ for idx, expr in enumerate(self.expressions):
241
+ if expr is None: continue
242
+ style = PLOT_COLORS[idx]
243
+ text = Text("▀▄▀▄") # "\u2580\u2584"*2
244
+ text.stylize(style)
245
+ text.append(f" {expr}")
246
+ legend_lines.append(text.markup)
247
+ if not legend_lines:
248
+ legend.display = False; return
249
+
250
+ legend.display = True
251
+ legend.update(Text.from_markup("\n\n".join(legend_lines)))
252
+
253
+ #TODO: Fix graph Legend positioning; hide when screen is too small; add hide/show option.
254
+ # Move the display up one line for each additional plot.
255
+ new_num_visible = len(legend_lines)
256
+ legend.offset += Offset(0, -(new_num_visible - self.num_legend_markers))
257
+ self.num_legend_markers = new_num_visible
258
+
259
+ @staticmethod
260
+ def compute_data(
261
+ expr: Expression,
262
+ x_min: float,
263
+ x_max: float,
264
+ density: int,
265
+ ) -> list[tuple[float, float]]:
266
+ """Compute (x, y) points across the canvas for a given expression."""
267
+ data = []
268
+ if (x_max - x_min) < EPS:
269
+ x_max += EPS; x_min -= EPS
270
+ dx = (x_max - x_min) / (density - 1)
271
+ for i in range(density):
272
+ x = x_min + i * dx
273
+ try:
274
+ y = float(sym_eval(expr, approximate=True, x=x))
275
+ except ValueError as e:
276
+ continue
277
+ data.append((x, y))
278
+ return data
279
+
280
+ class GraphingScreen(Screen):
281
+ TITLE = "LevyCAS - Graphing"
282
+ CSS_PATH = "styles/graphing.tcss"
283
+
284
+ def __init__(self, exprs: list[str] = None) -> None:
285
+ super().__init__()
286
+ self.initial_exprs = exprs if exprs is not None else []
287
+
288
+ # Initialize child widgets
289
+ self.expression_inputs_container = VerticalScroll(id="expression-input-menu")
290
+ self.expression_inputs_container.border_title = "expression input"
291
+ self.expression_inputs_container.border_subtitle = "input"
292
+
293
+ # self.plot_data = [[] for i in range(MAX_PLOTS)]
294
+ self.inputs = [ExpressionInput(i) for i in range(MAX_PLOTS)]
295
+ for input in self.inputs:
296
+ input.display = False
297
+
298
+ self.add_expr_container = \
299
+ Center(
300
+ Button(
301
+ label="++",
302
+ id="add-expression",
303
+ ),
304
+ id="add-expression-container",
305
+ )
306
+
307
+ # self.plot = PlotWidget(invert_mouse_wheel=True)
308
+ self.plot = CasPlot()
309
+
310
+ @property
311
+ def num_inputs_displayed(self) -> int:
312
+ """Number of input fields currently visible."""
313
+ return sum(input_field.display for input_field in self.inputs)
314
+
315
+ def on_mount(self) -> None:
316
+ # Display the first input box
317
+ first_input = self.inputs[0]
318
+ first_input.display = True
319
+
320
+ # Initialize plot.
321
+ self.plot.set_xlimits(*DEFAULT_X_BOUNDS)
322
+ self.plot.set_ylimits(*DEFAULT_Y_BOUNDS)
323
+
324
+ # Add initial expressions
325
+ for idx, expr in enumerate(self.initial_exprs):
326
+ self.inputs[idx].input.value = expr
327
+ self.add_input()
328
+
329
+ def compose(self) -> ComposeResult:
330
+ """Screen layout and widgets"""
331
+
332
+ yield Header()
333
+ with Horizontal(id="main-container"):
334
+ with Vertical(id="menu-desc-container"):
335
+ # Screen Title
336
+ yield Static(
337
+ "LevyCAS - Graphing",
338
+ id='screen-title',
339
+ )
340
+
341
+ # Expression input area
342
+ with self.expression_inputs_container:
343
+ yield from self.inputs
344
+ yield self.add_expr_container
345
+
346
+ # Graphing description
347
+ yield Static(
348
+ "Welcome to LevyCAS Graphing! "
349
+ "Enter an expression in a box above, and view its graph "
350
+ "in the plot on the right. Hover over an input for help.",
351
+ id="screen-description"
352
+ )
353
+
354
+ # Graph Widget
355
+ with Vertical(id="plot-and-menu-container"):
356
+ yield self.plot
357
+ with Horizontal(id="welcome-container"):
358
+ yield Button("Return Home", name='switch-screen', id='welcome')
359
+ yield Button("Reset Plot", id="reset-plot")
360
+
361
+ def on_button_pressed(self, event: Button.Pressed) -> None:
362
+ """Dispatch button handlers"""
363
+ button = event.button
364
+ if button.id.startswith("expression-delete"):
365
+ input_container = button.query_ancestor("ExpressionInput")
366
+ self.remove_input(input_container)
367
+ elif button.id == "add-expression":
368
+ self.add_input()
369
+ elif button.id == "reset-plot":
370
+ self.reset_plot_limits()
371
+
372
+ def remove_input(self, input_container: ExpressionInput) -> None:
373
+ """Remove an expression input field from the display."""
374
+ input_container.input.clear()
375
+
376
+ # Don't remove the final input.
377
+ if self.num_inputs_displayed == 1:
378
+ return
379
+
380
+ # When an input is deleted, the add button should be visible.
381
+ input_container.display = False
382
+ if not self.add_expr_container.display:
383
+ self.add_expr_container.display = True
384
+
385
+ @on(ExpressionInput.Add)
386
+ def add_input(self) -> None:
387
+ """Add an expression input field to the display.
388
+
389
+ Focuses new widget.
390
+ """
391
+ # Display new input field after the visible ones.
392
+ for input in self.inputs:
393
+ if not input.display:
394
+ self.expression_inputs_container.move_child(
395
+ child=input,
396
+ before=self.add_expr_container,
397
+ )
398
+ input.display = True
399
+ input.query_one("Input").focus(scroll_visible=True)
400
+ break
401
+
402
+ # Remove add button if max fields are already visible
403
+ if self.num_inputs_displayed == MAX_PLOTS:
404
+ self.add_expr_container.display = False
405
+
406
+ def reset_plot_limits(self) -> None:
407
+ """Reset the axes limits of a plot back to default."""
408
+ self.plot.action_reset_scales()
409
+
410
+ def on_expression_input_plot(self, message: ExpressionInput.Plot) -> None:
411
+ """Plot the sent expression."""
412
+ idx, expr = message.idx, message.expr
413
+ self.plot.update_expression(idx, expr)
414
+
415
+ def on_expression_input_clear(self, message: ExpressionInput.Clear) -> None:
416
+ """Clear the indicated expression."""
417
+ idx = message.idx
418
+ self.plot.update_expression(idx, None)