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.
- levycas/__init__.py +13 -0
- levycas/__main__.py +160 -0
- levycas/cli/__init__.py +1 -0
- levycas/cli/__main__.py +59 -0
- levycas/cli/screens/__init__.py +5 -0
- levycas/cli/screens/graph.py +418 -0
- levycas/cli/screens/script.py +108 -0
- levycas/cli/screens/styles/graphing.tcss +118 -0
- levycas/cli/screens/styles/scripting.tcss +92 -0
- levycas/cli/screens/styles/welcome.tcss +24 -0
- levycas/cli/screens/welcome.py +16 -0
- levycas/expressions/__init__.py +33 -0
- levycas/expressions/exp.py +59 -0
- levycas/expressions/expression.py +872 -0
- levycas/expressions/trig.py +71 -0
- levycas/operations/__init__.py +101 -0
- levycas/operations/algebraic_ops.py +287 -0
- levycas/operations/calculus_ops.py +583 -0
- levycas/operations/equation_ops.py +41 -0
- levycas/operations/exponential_ops.py +156 -0
- levycas/operations/expression_ops.py +214 -0
- levycas/operations/factorization_ops.py +546 -0
- levycas/operations/numerical_ops.py +212 -0
- levycas/operations/polynomial_ops.py +896 -0
- levycas/operations/simplification_ops.py +461 -0
- levycas/operations/trig_ops.py +351 -0
- levycas/parser/__init__.py +8 -0
- levycas/parser/lexer.py +50 -0
- levycas/parser/parser.py +245 -0
- levycas/scripting/__init__.py +13 -0
- levycas/scripting/errors.py +7 -0
- levycas/scripting/execution.py +253 -0
- levycas/scripting/scripting.py +438 -0
- levycas-1.0.0.dist-info/METADATA +230 -0
- levycas-1.0.0.dist-info/RECORD +39 -0
- levycas-1.0.0.dist-info/WHEEL +5 -0
- levycas-1.0.0.dist-info/entry_points.txt +2 -0
- levycas-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- 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()
|
levycas/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The LevyCAS CLI; contains the Textual app interface and widgets."""
|
levycas/cli/__main__.py
ADDED
|
@@ -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,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)
|