Commit Graph

743 Commits

Author SHA1 Message Date
Isaiah Odhner
3e9f1dd94b Tidy imports 2023-09-15 23:44:22 -04:00
Isaiah Odhner
22ce654579 Move method next to other ColorsBox event handler 2023-09-15 23:36:08 -04:00
Isaiah Odhner
82e0fbada7 Re-optimize ColorsBox palette updating 2023-09-15 23:31:29 -04:00
Isaiah Odhner
0647e7be8b Decouple ColorsBox from PaintApp
There's no practical utility in this unless I want to make the palette size variable,
and this makes performance worse updating the screen when the palette changes,
but I'm on a mission to remove `from textual_paint.paint import PaintApp`
since it can't be at top level due to cyclic imports, and it has
side effects when importing `textual_paint.args` in turn.
2023-09-15 23:25:21 -04:00
Isaiah Odhner
a2cf93ae99 Update palette reactively 2023-09-15 23:00:29 -04:00
Isaiah Odhner
f1d288d79c Make palette immutable 2023-09-15 22:53:40 -04:00
Isaiah Odhner
80d9f38318 Move ThemedIcon to a separate file 2023-09-15 22:07:04 -04:00
Isaiah Odhner
0f617dd8c4 Dynamically theme message box icons 2023-09-15 22:03:34 -04:00
Isaiah Odhner
7a13659d48 Fix "Show Details" not changing to "Hide Details" when expanding error 2023-09-15 20:44:40 -04:00
Isaiah Odhner
0c44759b28 Refactor to avoid using negative coords to mean "no mouse position"
My TODO comment said "add an attribute to ToolPreviewUpdate or make it's x/y Optional", which is both bad grammar ("it's" vs "its") and a bad idea, since the Canvas isn't involved in this update, so its event definition shouldn't have to be complicated by it.

I like this much better, just splitting the event handler into two functions, giving room to express the optional nature of the coordinate in the signature, and avoiding constructing two message objects at both of the callsites.
2023-09-15 20:21:19 -04:00
Isaiah Odhner
b6e2c245a2 Get file dialog snapshot tests passing on Windows
Now it's just a few round trip tests failing, and maybe a flaky snapshot test for the character selector dialog.
2023-09-15 17:13:15 -04:00
Isaiah Odhner
8cd164bb37 Fix preventing icon swaps during pytest, for Windows Terminal and Kitty
If git blame tools were smarter/easier to use, or if I was using a node-based programming paradigm, I would've never made this mistake. I would have done the natural and correct thing in the first place, but instead, I tried to avoid indenting a large block of code, which generates a noisy commit, and a barrier when using git blame.
2023-09-15 17:08:13 -04:00
Isaiah Odhner
ba4d023387 Fix status bar showing -100, -100 value I used for color updates 2023-09-14 20:21:05 -04:00
Isaiah Odhner
91b2c285fe Re-color in-progress polygon/curve immediately 2023-09-14 20:04:31 -04:00
Isaiah Odhner
3f1c6c964a Fix Polygon tool detecting double clicks despite distance 2023-09-14 16:32:40 -04:00
Isaiah Odhner
da7f8f350e Not sure about un-dimming colors in header icon 2023-09-14 16:13:10 -04:00
Isaiah Odhner
bf8e445f69 Make palette state local to the app instance 2023-09-14 02:31:49 -04:00
Isaiah Odhner
9280dcdb92 Fix ASCII-only mode test failures 2023-09-13 23:41:37 -04:00
Isaiah Odhner
3b247cde1a Move window titlebar icons to icons module 2023-09-13 23:27:46 -04:00
Isaiah Odhner
3922d1e3d0 Move ColorsBox to a new file 2023-09-13 22:36:32 -04:00
Isaiah Odhner
548f381ad1 Move CharInput to a new file 2023-09-13 22:36:20 -04:00
Isaiah Odhner
9efc2592aa Move selected tool highlighting to ToolsBox 2023-09-13 22:15:15 -04:00
Isaiah Odhner
072f8327c0 Move ToolsBox to a new file 2023-09-13 22:07:16 -04:00
Isaiah Odhner
88b1de94ed Improve Canvas docstring 2023-09-13 21:54:46 -04:00
Isaiah Odhner
5e561c02b0 Move Canvas widget and MetaGlyphFont class to new files 2023-09-13 21:54:39 -04:00
Isaiah Odhner
1946188927 Move Action class to a new file 2023-09-13 19:19:05 -04:00
Isaiah Odhner
5ee858f4f2 Move Tool enum to new file 2023-09-13 19:09:40 -04:00
Isaiah Odhner
5d579a61c4 Move header icon to icons module 2023-09-13 19:01:02 -04:00
Isaiah Odhner
326d7b551a Remove unused import
was used by --recode-samples
2023-09-13 18:58:03 -04:00
Isaiah Odhner
6c55c31696 Move icons to a new file 2023-09-13 18:51:54 -04:00
Isaiah Odhner
4a0b91e323 Move CharacterSelectorDialogWindow to a separate file 2023-09-13 12:55:36 -04:00
Isaiah Odhner
bb047c04e7 Move --recode-samples to an automated test 2023-09-12 21:54:19 -04:00
Isaiah Odhner
1f1bf577f2 Disable tool icon swaps when running in pytest 2023-09-12 21:08:24 -04:00
Isaiah Odhner
08847ca91b Fix Free-Form Select behavior when melding with negative coordinates 2023-09-12 20:16:55 -04:00
Isaiah Odhner
d6705912b3 Fix DirectoryTree retaining real pathlib access
This fixes the following error:

    ----------------------------------------------- Captured stderr call -----------------------------------------------
    ╭───────────────────── Traceback (most recent call last) ──────────────────────╮
    │ /home/io/Projects/textual-paint/src/textual_paint/file_dialogs.py:86 in      │
    │ on_mount                                                                     │
    │                                                                              │
    │    83 │   def on_mount(self) -> None:                                        │
    │    84 │   │   """Called when the window is mounted."""                       │
    │    85 │   │   self.content.mount(                                            │
    │ ❱  86 │   │   │   EnhancedDirectoryTree(path="/"),                           │
    │    87 │   │   │   Horizontal(                                                │
    │    88 │   │   │   │   Label(_("File name:")),                                │
    │    89 │   │   │   │   Input(classes="filename_input autofocus", value=self._ │
    │                                                                              │
    │ ╭──────────────────── locals ────────────────────╮                           │
    │ │ self = OpenDialogWindow(id='window_auto_id_0') │                           │
    │ ╰────────────────────────────────────────────────╯                           │
    │                                                                              │
    │ in __init__:119                                                              │
    │                                                                              │
    │   116 │   │   self._load_queue: Queue[TreeNode[DirEntry]] = Queue()          │
    │   117 │   │   super().__init__(                                              │
    │   118 │   │   │   str(path),                                                 │
    │ ❱ 119 │   │   │   data=DirEntry(self.PATH(path)),                            │
    │   120 │   │   │   name=name,                                                 │
    │   121 │   │   │   id=id,                                                     │
    │   122 │   │   │   classes=classes,                                           │
    │                                                                              │
    │ ╭───────────────── locals ──────────────────╮                                │
    │ │  classes = None                           │                                │
    │ │ disabled = False                          │                                │
    │ │       id = None                           │                                │
    │ │     name = None                           │                                │
    │ │     path = '/'                            │                                │
    │ │     self = EnhancedDirectoryTree(         │                                │
    │ │            │   id='some fake shit',       │                                │
    │ │            │   name='some more fake shit' │                                │
    │ │            )                              │                                │
    │ ╰───────────────────────────────────────────╯                                │
    │                                                                              │
    │ in __new__:960                                                               │
    │                                                                              │
    │    957 │   def __new__(cls, *args, **kwargs):                                │
    │    958 │   │   if cls is Path:                                               │
    │    959 │   │   │   cls = WindowsPath if os.name == 'nt' else PosixPath       │
    │ ❱  960 │   │   self = cls._from_parts(args)                                  │
    │    961 │   │   if not self._flavour.is_supported:                            │
    │    962 │   │   │   raise NotImplementedError("cannot instantiate %r on your  │
    │    963 │   │   │   │   │   │   │   │   │     % (cls.__name__,))              │
    │                                                                              │
    │ ╭──────────── locals ─────────────╮                                          │
    │ │   args = ('/',)                 │                                          │
    │ │    cls = <class 'pathlib.Path'> │                                          │
    │ │ kwargs = {}                     │                                          │
    │ ╰─────────────────────────────────╯                                          │
    │                                                                              │
    │ in _from_parts:594                                                           │
    │                                                                              │
    │    591 │   │   # We need to call _parse_args on the instance, so as to get t │
    │    592 │   │   # right flavour.                                              │
    │    593 │   │   self = object.__new__(cls)                                    │
    │ ❱  594 │   │   drv, root, parts = self._parse_args(args)                     │
    │    595 │   │   self._drv = drv                                               │
    │    596 │   │   self._root = root                                             │
    │    597 │   │   self._parts = parts                                           │
    │                                                                              │
    │ ╭──────────────────────────── locals ─────────────────────────────╮          │
    │ │ args = ('/',)                                                   │          │
    │ │  cls = <class 'pathlib.Path'>                                   │          │
    │ │ self = <repr-error "'Path' object has no attribute '_flavour'"> │          │
    │ ╰─────────────────────────────────────────────────────────────────╯          │
    │                                                                              │
    │ in _parse_args:587                                                           │
    │                                                                              │
    │    584 │   │   │   │   │   │   "argument should be a str object or an os.Pat │
    │    585 │   │   │   │   │   │   "object returning str, not %r"                │
    │    586 │   │   │   │   │   │   % type(a))                                    │
    │ ❱  587 │   │   return cls._flavour.parse_parts(parts)                        │
    │    588 │                                                                     │
    │    589 │   @classmethod                                                      │
    │    590 │   def _from_parts(cls, args):                                       │
    │                                                                              │
    │ ╭──────────── locals ────────────╮                                           │
    │ │     a = '/'                    │                                           │
    │ │  args = ('/',)                 │                                           │
    │ │   cls = <class 'pathlib.Path'> │                                           │
    │ │ parts = ['/']                  │                                           │
    │ ╰────────────────────────────────╯                                           │
    ╰──────────────────────────────────────────────────────────────────────────────╯
    AttributeError: type object 'Path' has no attribute '_flavour'

...which was itself masked by an error:

    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/_doc.py:136: in take_svg_screenshot
        app.run(
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:1207: in run
        asyncio.run(run_app())
    /usr/lib/python3.10/asyncio/runners.py:44: in run
        return loop.run_until_complete(main)
    /usr/lib/python3.10/asyncio/base_events.py:649: in run_until_complete
        return future.result()
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:1196: in run_app
        await self.run_async(
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:1168: in run_async
        await app._shutdown()
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:2186: in _shutdown
        await self._close_all()
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:2166: in _close_all
        await self._prune_node(stack_screen)
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:2656: in _prune_node
        await asyncio.gather(*close_messages)
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/message_pump.py:453: in _close_messages
        await self._task
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/message_pump.py:469: in _process_messages
        await self._pre_process()
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/message_pump.py:490: in _pre_process
        self.app._handle_exception(error)
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:1822: in _handle_exception
        self._fatal_error()
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/app.py:1827: in _fatal_error
        traceback = Traceback(
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/traceback.py:264: in __init__
        trace = self.extract(
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/traceback.py:449: in extract
        locals={
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/traceback.py:450: in <dictcomp>
        key: pretty.traverse(
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/pretty.py:852: in traverse
        node = _traverse(_object, root=True)
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/pretty.py:647: in _traverse
        args = list(iter_rich_args(rich_repr_result))
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/rich/pretty.py:614: in iter_rich_args
        for arg in rich_args:
    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/widget.py:2516: in __rich_repr__
        yield "id", self.id, None
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    self = <[AttributeError("'EnhancedDirectoryTree' object has no attribute '_id'") raised in repr()] EnhancedDirectoryTree object at     0x7f06b38c89d0>

        @property
        def id(self) -> str | None:
            """The ID of this node, or None if the node has no ID."""
    >       return self._id
    E       AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?

    /home/io/Projects/textual-paint/.venv/lib/python3.10/site-packages/textual/dom.py:483: AttributeError

...which I worked around by adding class vars to EnhancedDirectoryTree:

    _id = "some fake shit"
    _name = "some more fake shit"

It was also a heisenbug. I was able to run the tests, even update snapshots, running in debug mode in VS Code.
2023-09-12 19:45:41 -04:00
Isaiah Odhner
dce55cd833 Fix further errors with pyfakefs 2023-09-12 19:45:41 -04:00
Isaiah Odhner
7f91138a63 Use pyfakefs for file dialog tests
- The app's directory structure is not a constant, and shouldn't play into this test. Aside from codebase restructuring, directories like `__pycache__` can come and go.
- Even if a temporary directory were created with files enough to fill the view, the scrollbar would still change based on the folder structure outside of the temporary folder.
- pyfakefs is one way to ensure a consistent view of a folder structure for testing. It allows adding real folders in a readonly way. It's more complicated than I thought it would be going in, since I had to add workarounds for pyfiglet and pytest-textual-snapshot, and handle an edge case in my EnhancedDirectoryTree (which got an error which seems to be swallowed?), not to mention pyfakefs raises an error saying "No such file or directory in the fake filesystem" when actually it's the real directory not existing when trying to add it to the fake filesystem, and VS Code was hiding stack frames and refusing to step into library code, and it turned out that I was resolving the absolute path wrong, but it looked right to me because the only part that was missing was "textual-paint", when, at a glance it seemed present, since the "textual_paint" part was present. Ay-ay-ay!
- I don't know if this will fix the problem I saw where these tests' snapshots all changed with no visual or even structural changes, just the IDs of elements changing. I don't know what caused that.

Oh yeah and this is still actually a problem:
============================================= short test summary info ==============================================
FAILED tests/test_snapshots.py::test_paint_open_dialog[light_unicode] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_open_dialog[dark_unicode] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_open_dialog[light_ascii] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_open_dialog[dark_ascii] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_save_dialog[light_unicode] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_save_dialog[dark_unicode] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_save_dialog[light_ascii] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
FAILED tests/test_snapshots.py::test_paint_save_dialog[dark_ascii] - AttributeError: 'EnhancedDirectoryTree' object has no attribute '_id'. Did you mean: 'id'?
========================== 8 failed, 56 passed, 1 xfailed, 1 warning in 152.86s (0:02:32) ==========================
It worked when running in debug, but not when running normally.
2023-09-12 19:45:41 -04:00
Isaiah Odhner
dbc8acaa39 WIP: Try to fix premature closing of polygon
Try to prevent double click logic from triggering when mouse moves between first and second clicks significantly
2023-09-11 17:56:03 -04:00
Isaiah Odhner
bda6fa3f0a Prevent document recovery interfering with tests 2023-09-11 17:55:28 -04:00
Isaiah Odhner
2e89e73f37 Fix About Paint dialog test failing due to version string changes
Make version info static when running in pytest.

I also tried adding in test_snapshots.py:

    import textual_paint
    textual_paint.__version__ = "snapshot test edition 1"

    import textual_paint.__init__ as init
    init.__version__ = "snapshot test edition 2"

which seemed to have no effect.

Since I already have special case logic for __version__ in __init__.py,
I'm reasonably happy with this solution.
2023-09-09 00:19:39 -04:00
Isaiah Odhner
278490f7d5 WIP: Add more tests 2023-09-08 20:03:30 -04:00
Isaiah Odhner
79d63b0291 Rename module to indicate broadened scope 2023-09-08 20:03:17 -04:00
Isaiah Odhner
57ae2e8d44 Clean up 2023-09-08 20:03:17 -04:00
Isaiah Odhner
3c2a03ac82 Get snapshot tests passing :)
I did TDD with screenshots.
2023-09-08 14:55:35 -04:00
Isaiah Odhner
8a4ca8d380 Fix toggling light/dark mode not affecting file/folder icons in tree
I already fixed my first bug caught by the snapshot testing!
These variables were intended as constants, but were being mutated.
I recall writing it as `prefix = (...).stylize_before(...)` and then
moving it to a new line when I realized it was mutation-style method,
not so much the chaining-style factory that I wanted, but I conceived
of it too much as a stylistic distinction in the moment, looking back.
Mutation style means mutation!

Side note: tests also showed a spurious change of a cursor blinking.
I don't really know whether that's in this changeset or not, because
the workflow involves re-running the tests to update the baseline, and
the nice visual diffs provided in the snapshot report aren't available
when viewing the commit diff.
1. If the SVGs were separate files, I could see the diffs on GitHub
   or in GitHub Desktop, and maybe some other Git clients.
   It would also make it a lot easier to simply view the baselines,
   which is useful in general.
2. It would be nice if built-in components didn't cause spurious diffs,
   including the Input's cursor blinking and the Header's clock ticking.
   I already removed the clock from my gallery app, because it's a sort
   of trivial decision, but Inputs I'll have to reckon with.
2023-09-08 02:31:54 -04:00
Isaiah Odhner
f09f5fbf55 Test light and dark theme variations with a pytest fixture
First I tried setting PYTEST_TEXTUAL_PAINT_ARGS as an environment variable, to be interpreted by args.py, but it turns out args.py is only executed once, not once per test. It's not using subprocesses, only importing and reimporting the app code, and instantiating new App instances, so parts of the code that are at the top level of modules is only evaluated once.

So I found a new strategy, of importing the `args` object in the test fixture and modifying it directly.

I also realized the --ascii-only option permanently modifies Textual's widgets and borders, and my own widgets, for the life of the process, so I'm holding off on that one. I should be able to make --ascii-only mode more dynamic, and could even target it as a runtime toggle, as a goal, since that's basically what I'll need to achieve to get it working for the tests, but thinking of it as a feature is more fun.
2023-09-08 02:18:48 -04:00
Isaiah Odhner
ca23a7712c Remove clock from gallery app
I don't feel like trying to figure out how to spoof the time for snapshot testing.
2023-09-07 16:53:31 -04:00
Isaiah Odhner
690b969fa4 Run "Organize Imports" on all files
I used a very dangerous extension that has a lot of include/exclude
options that are unclear how they interact, and it doesn't apparently
respect your gitignore settings, and it has no no preview or warning.

  "commandOnAllFiles.excludeFolders": ["node_modules", "out", ".vscode-test", "media", ".git", ".history", ".venv", ".venv*", ".*"],
  "commandOnAllFiles.commands": {
    "Organize Imports": {
      "command": "editor.action.organizeImports",
      "includeFileExtensions": [".py"],
      "includeFolders": ["src"]
    }
  },
2023-09-07 16:41:59 -04:00
Isaiah Odhner
580da11949 Check if running in pytest instead of ignoring a specific argument 2023-09-07 16:41:59 -04:00
Isaiah Odhner
95256a411a Kludge: ignore --snapshot-update argument 2023-09-07 15:38:23 -04:00