├── .flake8
├── .gitignore
├── LICENSE
├── Notesh.desktop
├── README.md
├── documentation
├── .gitkeep
├── ChangeBackgroundColor.gif
├── CreateNote.gif
├── DynamicResize.gif
├── HoptexNotesh.gif
├── HoverOver.gif
├── Layers.gif
├── NewDrawable.png
├── NoteshApp.png
└── Resizing.gif
├── notesh
├── __init__.py
├── command_line.py
├── default_bindings.toml
├── drawables
│ ├── __init__.py
│ ├── box.py
│ ├── drawable.py
│ └── sticknote.py
├── main.css
├── main.py
├── play_area.py
├── utils.py
└── widgets
│ ├── __init__.py
│ ├── color_picker.py
│ ├── focusable_footer.py
│ ├── multiline_input.py
│ ├── sidebar.py
│ └── sidebar_left.py
├── pyproject.toml
├── requirements.txt
├── setup.cfg
└── setup.py
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ; ignore = F403, F401
3 | ignore = F403, F401, F841, F811, F541
4 | exclude = .git,__pycache__,old,build,dist,venv
5 | max-line-length = 88
6 | max-complexity = 18
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | venv/
2 | __pycache__
3 | notes.json
4 | notes*.json
5 | dist/
6 | build/
7 | *.egg-info
8 |
9 | test
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Cvaniak
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Notesh.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Version=1.0
3 | Type=Application
4 | Name=Notesh
5 | Comment=Sticky notes App in your Terminal!
6 | Exec=python3 -m notesh.main
7 | Icon=edit
8 | Terminal=true
9 | StartupNotify=true
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
📝 NoteSH
3 |
4 |
5 |
6 | Fully functional sticky notes App in your Terminal! Built with Textual, an amazing TUI framework!
7 |
8 |
9 |
10 |
11 |
12 |
13 | ## In last Update
14 |
15 | * [**Hoptex**](https://github.com/Cvaniak/Hoptex) Support (you can focus anything easy now)!
16 | * User default note file
17 |
18 | 
19 |
20 | ## Installation
21 |
22 | Best option to install is using [pipx](https://github.com/pypa/pipx):
23 |
24 | ```bash
25 | pipx install notesh
26 | # but it is still possible to do it with just pip:
27 | pip install notesh
28 | ```
29 |
30 | ## Usage
31 |
32 | To start using just type in your terminal:
33 |
34 | ```bash
35 | notesh
36 | ```
37 |
38 | it will create new file notes.json in current directory.
39 | You can also specify file by using `-f` flag:
40 |
41 | ```bash
42 | notesh -f MyNotes.json
43 | # or full/relative path
44 | notesh -f ~/Documents/MyNotes.json
45 | ```
46 |
47 | ## ➕ Create new Note
48 |
49 | * To create new note just press `Ctrl+A`
50 | * You can change color with buttons but also using scroll
51 | * To edit note just click in its body
52 |
53 | 
54 |
55 | ## 🧅 It supports layers
56 |
57 | * To move note grab it top part and move with mouse
58 |
59 | 
60 |
61 | ## 🗚 You can resize notes
62 |
63 | * To resize grab left bottom corner and move with mouse
64 |
65 | 
66 |
67 | ## 💡 And background is resizable
68 |
69 | * If you make make background to big it will readjust after you reopen App
70 | * You can also click `CTRL-Mouse` to look around whole wall
71 |
72 | 
73 |
74 | ## 💡 Highlight when mouse is over
75 |
76 | 
77 |
78 | ## ➕ New Drawable that support borders change
79 |
80 | 
81 |
82 | ## ⌨️ Vim/Custom key bindings
83 |
84 | You can now do everything using KEYBOARD!
85 | This is first version so if you have any suggestions please write them in existing issue.
86 | Default keybindings are in `default_bindings.toml`
87 | file that is in root of installation.
88 | You can also create second file `user_bindings.toml` where you can overwrite defaults.
89 |
90 | ### What you can do
91 |
92 | * Change focus `focus_next/focus_previous` using `ctrl+i,ctrl+j/ctrl+o,ctrl+k`
93 | * Edit note `edit` using `i`
94 | * When note is focused you can move it with `j/k/l/h`.
95 | Also adding shift moves it more with one click
96 | * Clicking `unfocus` using `escape` returns from edit mode,
97 | and unfocus drawable if not in edit mode.
98 | * Resize note using `+/-` for vertical and `>/<` for horizontal
99 | * Bring 'ctrl+f' Forward and `ctrl+b` Backward Note
100 |
101 | ### Bindings file
102 |
103 |
104 | Default file
105 |
106 | ```toml
107 | # These are default, they also are displayed at the footer
108 | [default]
109 | quit = ["ctrl+q,ctrl+c", "Quit"]
110 | toggle_sidebar_left = ["ctrl+e", "Sidebar Left"]
111 | add_note = ["ctrl+a", "Create Stick Note"]
112 | add_box = ["ctrl+x", "Create Box"]
113 | save_notes = ["ctrl+s", "Save Notes"]
114 | unfocus = ["escape", "Unfocus"]
115 | "app.toggle_dark" = ["ctrl+t", "Dark/Light"]
116 |
117 | [moving_drawables]
118 | # Default movement
119 | left = "h"
120 | right = "l"
121 | up = "k"
122 | down = "j"
123 | # You can add number after _ and it will move note that many times
124 | left_5 = "H"
125 | right_5 = "L"
126 | up_5 = "K"
127 | down_5 = "J"
128 |
129 | [normal_insert]
130 | # there is only `next` and `previous` and the order is not changable yet
131 | focus_next = "ctrl+i,ctrl+j"
132 | focus_previous = "ctrl+o,ctrl+k"
133 | unfocus = "escape"
134 |
135 | [normal]
136 | edit = "i"
137 | delete = "Q"
138 | add_note = "o"
139 | add_box = "O"
140 |
141 | # For special characters like `+` or `<` you need to use names
142 | # You can check the name using textual `textual keys`
143 | [resize_drawable]
144 | h_plus = "greater_than_sign"
145 | h_minus = "less_than_sign"
146 | v_plus = "plus"
147 | v_minus = "minus"
148 |
149 | # It brings at the top or bottom the note
150 | [bring_drawable]
151 | forward = "ctrl+f"
152 | backward = "ctrl+b"
153 |
154 | [hoptex]
155 | focus = "ctrl+n"
156 | quit = "escape,ctrl+c"
157 | unfocus = "escape,ctrl+c"
158 | ```
159 |
160 |
161 |
162 |
163 | ## Change Background Color in Left Sidebar
164 |
165 | By default you can use `ctrl+e` to open Left Sidebar:
166 |
167 | 
168 |
169 | ## NEW FEATURES
170 |
171 | ## TODO
172 |
173 | There are many thigs to add! If you have idea, please create Issue with your suggestions.
174 |
175 | * [ ] Safe saving (now if there are any bugs you may lost your notes)
176 | * [x] Vim Key bindings
177 | * Wait for feedback
178 | * [ ] Duplicate Note
179 | * [ ] Hiding menu (Color Picker etc.)
180 | * [x] TOML config file
181 | * [ ] Left Sidebar (for background and preferences)
182 | * [x] Background color
183 | * [ ] Align tool for text
184 | * [ ] Fixed layers (if needed)
185 | * [ ] Diffrent Drawables:
186 | * [ ] Check List
187 | * [ ] Arrows
188 | * [ ] Help Screen
189 | * [ ] Command Pallet support
190 | * [ ] Menu to choose borders
191 | * [ ] Buttons to add new notes
192 |
193 | and also resolve problems:
194 |
195 | * [ ] Multiline Input (currently textual does not support it and here we have my hacky solution)
196 |
197 | ## Thanks
198 |
199 | Big thanks to [Will McGugan](https://github.com/willmcgugan) and all members and contributors of [Textualize.io](https://textualize.io)!
200 | Go checkout [Textual](https://github.com/Textualize/textual) amazing TUI framework on which this app is based.
201 |
--------------------------------------------------------------------------------
/documentation/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/.gitkeep
--------------------------------------------------------------------------------
/documentation/ChangeBackgroundColor.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/ChangeBackgroundColor.gif
--------------------------------------------------------------------------------
/documentation/CreateNote.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/CreateNote.gif
--------------------------------------------------------------------------------
/documentation/DynamicResize.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/DynamicResize.gif
--------------------------------------------------------------------------------
/documentation/HoptexNotesh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/HoptexNotesh.gif
--------------------------------------------------------------------------------
/documentation/HoverOver.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/HoverOver.gif
--------------------------------------------------------------------------------
/documentation/Layers.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/Layers.gif
--------------------------------------------------------------------------------
/documentation/NewDrawable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/NewDrawable.png
--------------------------------------------------------------------------------
/documentation/NoteshApp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/NoteshApp.png
--------------------------------------------------------------------------------
/documentation/Resizing.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/documentation/Resizing.gif
--------------------------------------------------------------------------------
/notesh/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/__init__.py
--------------------------------------------------------------------------------
/notesh/command_line.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from __future__ import annotations
3 |
4 | from notesh.main import NoteApp
5 |
6 |
7 | def run():
8 | import argparse
9 |
10 | parser = argparse.ArgumentParser(description="Run Sticky Notes in your Terminal!")
11 | parser.add_argument(
12 | "-f",
13 | "--file",
14 | default=NoteApp.DEFAULT_FILE,
15 | help=f"Notes file to use. Defaults to $NOTESH_FILE or $XDG_DATA_HOME/notesh/notes.json (currently: {NoteApp.DEFAULT_FILE!r})",
16 | required=False,
17 | )
18 | argsx = parser.parse_args()
19 | NoteApp(file=argsx.file).run()
20 |
21 |
22 | if __name__ == "__main__":
23 | run()
24 |
--------------------------------------------------------------------------------
/notesh/default_bindings.toml:
--------------------------------------------------------------------------------
1 | [default]
2 | quit = ["ctrl+q", "Quit"]
3 | toggle_sidebar_left = ["ctrl+e", "Sidebar Left"]
4 | add_note = ["ctrl+a", "Create Stick Note"]
5 | add_box = ["ctrl+x", "Create Box"]
6 | save_notes = ["ctrl+s", "Save Notes"]
7 | unfocus = ["escape", "Unfocus"]
8 | "app.toggle_dark" = ["ctrl+t", "Dark/Light"]
9 |
10 | [moving_drawables]
11 | left = "h"
12 | right = "l"
13 | up = "k"
14 | down = "j"
15 | left_5 = "H"
16 | right_5 = "L"
17 | up_5 = "K"
18 | down_5 = "J"
19 |
20 | [normal_insert]
21 | focus_next = "ctrl+i,ctrl+j"
22 | focus_previous = "ctrl+o,ctrl+k"
23 | unfocus = "escape"
24 |
25 | [normal]
26 | edit = "i"
27 | delete = "Q"
28 | add_note = "o"
29 | add_box = "O"
30 |
31 | [resize_drawable]
32 | h_plus = "greater_than_sign"
33 | h_minus = "less_than_sign"
34 | v_plus = "plus"
35 | v_minus = "minus"
36 |
37 | [bring_drawable]
38 | forward = "ctrl+f"
39 | backward = "ctrl+b"
40 |
41 | [hoptex]
42 | focus = "ctrl+n"
43 | quit = "escape,ctrl+c"
44 | unfocus = "escape,ctrl+c"
45 |
--------------------------------------------------------------------------------
/notesh/drawables/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/drawables/__init__.py
--------------------------------------------------------------------------------
/notesh/drawables/box.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, Type, TypeVar
4 |
5 | from textual.app import events
6 | from textual.color import Color
7 | from textual.geometry import Offset, Size
8 | from textual.reactive import reactive
9 | from textual.widget import Widget
10 |
11 | from notesh.drawables.drawable import Body, Drawable, Resizer
12 | from notesh.widgets.multiline_input import MultilineArray
13 |
14 | BORDERS = [
15 | "outer",
16 | "ascii",
17 | "round",
18 | "solid",
19 | "double",
20 | "dashed",
21 | "heavy",
22 | "hkey",
23 | "vkey",
24 | "none",
25 | ]
26 |
27 | _T = TypeVar("_T")
28 |
29 |
30 | class Box(Drawable):
31 | type: str = "box"
32 | border_index: int = 0
33 | border_type: reactive[str] = reactive(BORDERS[border_index], always_update=True)
34 |
35 | def __init__(
36 | self,
37 | body: str = "",
38 | color: str = "#ffaa00",
39 | pos: Offset = Offset(0, 0),
40 | size: Size = Size(20, 14),
41 | parent: Optional[Widget] = None,
42 | border_color: str = "#ffaa00",
43 | border_type: str = "outer",
44 | id: str | None = None,
45 | ) -> None:
46 | super().__init__(id=id, init_parts=False, color=color, pos=pos, parent=parent, size=size, body=body)
47 |
48 | self.border_color = Color.parse(border_color)
49 | self.border_index = BORDERS.index(border_type)
50 | self.border_type = BORDERS[self.border_index]
51 | self._body = body
52 |
53 | self.init_parts()
54 |
55 | def init_parts(self) -> None:
56 | self.body = Body(self, body=self._body, id="default-body")
57 | self.resizer = Resizer(body=" ", id=f"{self.id}-resizer", parent=self)
58 |
59 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
60 | if isinstance(new_color, str):
61 | base_color = Color.parse(new_color)
62 | else:
63 | base_color = new_color
64 |
65 | if part_type == "" or part_type == "body":
66 | self.color = base_color
67 | else:
68 | self.border_color = base_color
69 | self.update_layout(duration)
70 |
71 | def update_layout(self, duration: float = 1.0):
72 | base_color = self.color
73 | border_color = self.border_color
74 | if self.is_entered:
75 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
76 | border_color = border_color.darken(0.1) if border_color.brightness > 0.9 else border_color.lighten(0.1)
77 |
78 | self.body.styles.animate("background", value=base_color, duration=duration)
79 |
80 | self.body.styles.border = (self.border_type, border_color.darken(0.1))
81 | self.body.styles.border_left = (self.border_type, border_color.lighten(0.1))
82 | self.body.styles.border_top = (self.border_type, border_color.lighten(0.1))
83 | self.resizer.styles.animate("background", value=border_color.darken(0.1), duration=duration)
84 |
85 | def next_border(self):
86 | self.border_index = (self.border_index + 1) % len(BORDERS)
87 | self.border_type = BORDERS[self.border_index]
88 | self.update_layout(duration=1.0)
89 |
90 | def multiline_array_changed(self, event: MultilineArray.Changed):
91 | text = [str(x.value) for x in event.input.lines]
92 | self.body.body = " \n".join(text)
93 |
94 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
95 | widgets["multiline_array"].remove_class("-hidden")
96 | widgets["body_color_picker"].remove_class("-hidden")
97 | widgets["border_picker"].remove_class("-hidden")
98 | widgets["border_color_picker"].remove_class("-hidden")
99 | widgets["delete_button"].remove_class("-hidden")
100 |
101 | widgets["multiline_array"].recreate_multiline(str(self.body.body))
102 | widgets["body_color_picker"].update_colors(self.color)
103 | widgets["border_color_picker"].update_colors(self.border_color)
104 |
105 | def dump(self) -> dict[str, Any]:
106 | return {
107 | "body": self.body.body,
108 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
109 | "color": self.color.hex6,
110 | "border_color": self.border_color.hex6,
111 | "border_type": self.border_type,
112 | "size": (self.styles.width.value, self.styles.height.value),
113 | "type": self.type,
114 | }
115 |
116 | @classmethod
117 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
118 | return cls(
119 | id=drawable_id,
120 | body=obj["body"],
121 | color=obj["color"],
122 | pos=Offset(*obj["pos"]) - offset,
123 | size=Size(*obj["size"]),
124 | border_color=obj["border_color"],
125 | border_type=obj["border_type"],
126 | )
127 |
--------------------------------------------------------------------------------
/notesh/drawables/drawable.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, Type, TypeVar, cast
4 |
5 | from rich.markdown import Markdown
6 | from textual import events
7 | from textual.app import ComposeResult
8 | from textual.color import Color
9 | from textual.containers import Vertical
10 | from textual.geometry import Offset, Size
11 | from textual.message import Message
12 | from textual.reactive import reactive
13 | from textual.widget import Widget
14 | from textual.widgets import Input, Static
15 |
16 | from notesh.utils import generate_short_uuid
17 | from notesh.widgets.multiline_input import MultilineArray
18 |
19 | _T = TypeVar("_T")
20 |
21 |
22 | class Drawable(Static):
23 | can_focus: bool = True
24 | type: str = "drawable"
25 | is_entered: reactive[bool] = reactive(False)
26 |
27 | def __init__(
28 | self,
29 | id: str | None = None,
30 | body: str = "",
31 | color: str = "#ffaa00",
32 | pos: Offset = Offset(0, 0),
33 | size: Size = Size(20, 14),
34 | parent: Optional[Widget] = None,
35 | init_parts: bool = True,
36 | ) -> None:
37 | if id is None or id == "":
38 | id = f"note-{generate_short_uuid()}"
39 | super().__init__(id=id)
40 | self.note_id: str = id
41 | self.clicked = (0, 0)
42 | self.styles.layer = f"{id}"
43 | self.color = Color.parse(color)
44 | self.styles.offset = pos
45 | self.styles.width = size.width
46 | self.styles.height = size.height
47 | self.pparent = parent
48 |
49 | if init_parts:
50 | self.init_parts()
51 |
52 | def init_parts(self) -> None:
53 | self.body = Body(self, body="", id="default-body")
54 | self.resizer = Resizer(body=" ", id=f"{self.id}-resizer", parent=self)
55 |
56 | def drawable_body(self) -> ComposeResult:
57 | yield Vertical(
58 | self.body,
59 | )
60 | yield self.resizer
61 |
62 | def compose(self) -> ComposeResult:
63 | yield from self.drawable_body()
64 |
65 | self.change_color(self.color, duration=0.0)
66 | self.bring_forward()
67 |
68 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
69 | if isinstance(new_color, str):
70 | base_color = Color.parse(new_color)
71 | else:
72 | base_color = new_color
73 |
74 | if part_type == "" or part_type == "body":
75 | self.color = base_color
76 | self.update_layout(duration)
77 |
78 | def update_layout(self, duration: float = 1.0):
79 | base_color = self.color
80 | if self.is_entered:
81 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
82 |
83 | self.body.styles.animate("background", value=base_color, duration=duration)
84 |
85 | self.body.styles.border = ("outer", base_color.darken(0.1))
86 | self.body.styles.border_left = ("outer", base_color.lighten(0.1))
87 | self.body.styles.border_top = ("outer", base_color.lighten(0.1))
88 | self.resizer.styles.animate("background", value=base_color.darken(0.1), duration=duration)
89 |
90 | async def drawable_is_moved_from_key(self, offset: Offset):
91 | note = self
92 | note.offset = note.offset + offset
93 | self.post_message(Drawable.Move(drawable=self, offset=offset))
94 |
95 | async def move(self, direction: str, value: int = 1):
96 | d = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0)}
97 | offset = Offset(*d[direction]) * value
98 | await self.drawable_is_moved_from_key(offset)
99 |
100 | async def drawable_is_moved(self, event: events.MouseMove):
101 | if self.clicked is not None and event.button != 0:
102 | note = self
103 | if event.delta:
104 | note.offset = note.offset + event.delta
105 | self.post_message(Drawable.Move(drawable=self, offset=event.delta))
106 |
107 | async def drawable_is_focused(self, event: events.MouseEvent, display_sidebar: bool = False):
108 | self.clicked = event.offset
109 | self.bring_forward()
110 | self.post_message(Drawable.Focus(self.note_id, display_sidebar))
111 |
112 | async def drawable_is_unfocused(self, event: events.MouseUp) -> None:
113 | self.clicked = None
114 |
115 | async def resize_drawable(self, delta_x: int, delta_y: int):
116 | note = self
117 | note.styles.width = note.styles.width.value + delta_x
118 | note.styles.height = note.styles.height.value + delta_y
119 | note.refresh()
120 |
121 | async def drawable_is_resized(self, event: events.MouseMove) -> None:
122 | if self.clicked is not None and event.button != 0:
123 | await self.resize_drawable(event.delta_x, event.delta_y)
124 |
125 | async def on_mouse_move(self, event: events.MouseMove) -> None:
126 | ...
127 | # await self.drawable_is_moved(event)
128 |
129 | async def on_mouse_down(self, event: events.MouseDown) -> None:
130 | if self.app.mouse_captured is None:
131 | self.capture_mouse()
132 | await self.drawable_is_focused(event)
133 |
134 | async def on_mouse_up(self, event: events.MouseUp) -> None:
135 | if self.app.mouse_captured is None:
136 | self.capture_mouse(False)
137 | await self.drawable_is_unfocused(event)
138 |
139 | async def on_click(self, event: events.Click):
140 | self.post_message(Drawable.Clicked(drawable=self))
141 | event.stop()
142 |
143 | async def on_focus(self, event: events.Focus):
144 | await self.on_enter(cast(events.Enter, event))
145 | self.post_message(Drawable.Focus(self.note_id, False))
146 |
147 | async def on_blur(self, event: events.Blur):
148 | await self.on_leave(cast(events.Leave, event))
149 |
150 | async def on_enter(self, event: events.Enter):
151 | self.is_entered = True
152 |
153 | async def on_leave(self, event: events.Leave):
154 | self.is_entered = False
155 |
156 | async def watch_is_entered(self, new_value: bool) -> None:
157 | if new_value:
158 | self.update_layout(duration=0.3)
159 | else:
160 | self.update_layout(duration=0.1)
161 |
162 | def next_border(self):
163 | ...
164 |
165 | def bring_forward(self):
166 | layers = tuple(x for x in self.screen.styles.layers if x not in [self.layer, f"{self.layer}-resizer"])
167 | self.screen.styles.layers = layers + (self.styles.layer, f"{self.styles.layer}-resizer")
168 |
169 | def bring_backward(self):
170 | layers = tuple(x for x in self.screen.styles.layers if x not in [self.layer, f"{self.layer}-resizer"])
171 | self.screen.styles.layers = (f"{self.styles.layer}", f"{self.styles.layer}-resizer") + layers
172 |
173 | def input_changed(self, event: Input.Changed):
174 | ...
175 |
176 | def multiline_array_changed(self, event: MultilineArray.Changed):
177 | ...
178 |
179 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
180 | widgets["body_color_picker"].remove_class("-hidden")
181 | widgets["delete_button"].remove_class("-hidden")
182 |
183 | widgets["body_color_picker"].update_colors(self.color)
184 |
185 | def dump(self) -> dict[str, Any]:
186 | return {
187 | "body": self.body.body,
188 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
189 | "color": self.color.hex6,
190 | "size": (self.styles.width.value, self.styles.height.value),
191 | "type": self.type,
192 | }
193 |
194 | @classmethod
195 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
196 | return cls(
197 | id=drawable_id,
198 | body=obj["body"],
199 | color=obj["color"],
200 | pos=Offset(*obj["pos"]) - offset,
201 | size=Size(*obj["size"]),
202 | )
203 |
204 | class Mess(Message):
205 | def __init__(self, value: str | None) -> None:
206 | super().__init__()
207 | self.value = value
208 |
209 | class Focus(Message):
210 | def __init__(self, index: str, display_sidebar: bool = False) -> None:
211 | super().__init__()
212 | self.index = index
213 | self.display_sidebar = display_sidebar
214 |
215 | class Move(Message):
216 | def __init__(
217 | self,
218 | drawable: Drawable,
219 | offset: Offset,
220 | ) -> None:
221 | super().__init__()
222 | self.drawable = drawable
223 | # this offset is used because when we update drawable offset
224 | # it does not update region and style until first idle
225 | self.offset = offset
226 |
227 | class Clicked(Message):
228 | def __init__(
229 | self,
230 | drawable: Drawable,
231 | ) -> None:
232 | super().__init__()
233 | self.drawable = drawable
234 |
235 |
236 | class DrawablePart(Static):
237 | body: reactive[str] = reactive("")
238 |
239 | def __init__(
240 | self,
241 | parent: Drawable,
242 | name: str | None = None,
243 | id: str | None = None,
244 | classes: str | None = None,
245 | body: str = "",
246 | ) -> None:
247 | super().__init__(body, name=name, id=id, classes=classes)
248 | self.clicked = Offset(0, 0)
249 | self.pparent: Drawable = parent
250 | self.body = str(body)
251 |
252 | def watch_body(self, body_text: str):
253 | self.update(Markdown(body_text))
254 |
255 | async def on_mouse_down(self, event: events.MouseDown):
256 | self.capture_mouse()
257 | await self.pparent.drawable_is_focused(event)
258 |
259 | async def on_mouse_up(self, event: events.MouseUp):
260 | self.capture_mouse(False)
261 | await self.pparent.drawable_is_unfocused(event)
262 |
263 | async def on_mouse_move(self, event: events.MouseMove) -> None:
264 | ...
265 |
266 | async def on_enter(self, event: events.Enter):
267 | await self.pparent.on_enter(event)
268 |
269 | async def on_leave(self, event: events.Leave):
270 | await self.pparent.on_leave(event)
271 |
272 |
273 | class Body(DrawablePart):
274 | async def on_mouse_move(self, event: events.MouseMove) -> None:
275 | ...
276 | await self.pparent.drawable_is_moved(event)
277 |
278 |
279 | class Resizer(DrawablePart):
280 | def __init__(
281 | self,
282 | parent: Drawable,
283 | name: str | None = None,
284 | id: str | None = None,
285 | classes: str | None = None,
286 | body: str = "",
287 | ) -> None:
288 | super().__init__(parent, name=name, id=id, classes=classes, body=body)
289 | self.styles.layer = f"{id}"
290 |
291 | async def on_mouse_move(self, event: events.MouseMove) -> None:
292 | await self.pparent.drawable_is_resized(event)
293 |
--------------------------------------------------------------------------------
/notesh/drawables/sticknote.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, Type, TypeVar
4 |
5 | from textual import events
6 | from textual.app import ComposeResult
7 | from textual.color import Color
8 | from textual.containers import Horizontal, Vertical
9 | from textual.geometry import Offset, Size
10 | from textual.widget import Widget
11 | from textual.widgets import Input
12 |
13 | from notesh.drawables.drawable import Drawable, DrawablePart, Resizer
14 | from notesh.utils import generate_short_uuid
15 | from notesh.widgets.multiline_input import MultilineArray
16 |
17 |
18 | def build_color(any_color: str | Color) -> tuple[Color, Color, Color, Color]:
19 | if isinstance(any_color, str):
20 | color: Color = Color.parse(any_color)
21 | else:
22 | color = any_color
23 |
24 | return (color.lighten(0.13), color, color.darken(0.12), color.darken(0.3))
25 |
26 |
27 | _T = TypeVar("_T")
28 |
29 |
30 | class Note(Drawable):
31 | type: str = "note"
32 |
33 | def __init__(
34 | self,
35 | title: str = "",
36 | body: str = "",
37 | color: str = "#ffaa00",
38 | pos: Offset = Offset(0, 0),
39 | size: Size = Size(20, 14),
40 | parent: Optional[Widget] = None,
41 | id: str | None = None,
42 | ) -> None:
43 | if id is None or id == "":
44 | id = f"note-{generate_short_uuid()}"
45 | self._title = title
46 | self._body = body
47 | super().__init__(id=id, color=color, pos=pos, parent=parent, size=size)
48 |
49 | def init_parts(self) -> None:
50 | self.title = NoteTop(id=f"note-top", parent=self, body=self._title)
51 | self.body = NoteBody(id=f"note-body", parent=self, body=self._body)
52 | self.spacer = Spacer(body="▌", id=f"note-spacer", parent=self)
53 | self.resizer_left = ResizerLeft(body="▌", id=f"note-resizer-left", parent=self)
54 | self.resizer = Resizer(body="◢█", id=f"note-resizer", parent=self)
55 |
56 | def drawable_body(self) -> ComposeResult:
57 | yield Vertical(
58 | Vertical(
59 | self.title,
60 | self.spacer,
61 | id="note-toper",
62 | ),
63 | self.body,
64 | Horizontal(
65 | self.resizer_left,
66 | self.resizer,
67 | id="note-resizer-bar",
68 | ),
69 | )
70 |
71 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
72 | if isinstance(new_color, str):
73 | base_color = Color.parse(new_color)
74 | else:
75 | base_color = new_color
76 |
77 | self.color = base_color
78 | self.update_layout(duration)
79 |
80 | def update_layout(self, duration: float = 1.0):
81 | base_color = self.color
82 | if self.is_entered:
83 | base_color = base_color.darken(0.1) if base_color.brightness > 0.9 else base_color.lighten(0.1)
84 |
85 | lighter, default, darker, much_darker = build_color(base_color)
86 |
87 | self.spacer.styles.background = much_darker
88 | self.spacer.styles.color = lighter
89 |
90 | self.title.styles.animate("background", value=default, duration=duration)
91 | self.title.styles.border_top = ("outer", lighter)
92 | self.title.styles.border_left = ("outer", lighter)
93 | self.title.styles.border_right = ("outer", much_darker)
94 |
95 | self.body.styles.animate("background", value=darker, duration=duration)
96 | self.body.styles.border_right = ("outer", much_darker)
97 | self.body.styles.border_bottom = ("none", much_darker)
98 | self.body.styles.border_left = ("outer", lighter)
99 |
100 | self.resizer_left.styles.background = much_darker
101 | self.resizer_left.styles.color = lighter
102 |
103 | self.resizer.styles.background = much_darker
104 | self.resizer.styles.color = default
105 |
106 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
107 | widgets["input"].remove_class("-hidden")
108 | widgets["multiline_array"].remove_class("-hidden")
109 | widgets["body_color_picker"].remove_class("-hidden")
110 | widgets["delete_button"].remove_class("-hidden")
111 |
112 | widgets["input"].value = str(self.title.body)
113 | widgets["multiline_array"].recreate_multiline(str(self.body.body))
114 | widgets["body_color_picker"].update_colors(self.color)
115 |
116 | def input_changed(self, event: Input.Changed):
117 | self.title.body = str(event.value)
118 |
119 | def multiline_array_changed(self, event: MultilineArray.Changed):
120 | text = [str(x.value) for x in event.input.lines]
121 | self.body.body = " \n".join(text)
122 |
123 | def dump(self) -> dict[str, Any]:
124 | return {
125 | "title": self.title.body,
126 | "body": self.body.body,
127 | "pos": (self.styles.offset.x.value, self.styles.offset.y.value),
128 | "color": self.color.hex6,
129 | "size": (self.styles.width.value, self.styles.height.value),
130 | "type": self.type,
131 | }
132 |
133 | @classmethod
134 | def load(cls: Type[_T], obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)):
135 | return cls(
136 | id=drawable_id,
137 | title=obj["title"],
138 | body=obj["body"],
139 | color=obj["color"],
140 | pos=Offset(*obj["pos"]) - offset,
141 | size=Size(*obj["size"]),
142 | )
143 |
144 |
145 | class NoteBody(DrawablePart):
146 | async def on_mouse_move(self, event: events.MouseMove) -> None:
147 | await self.pparent.drawable_is_moved(event)
148 |
149 |
150 | class NoteTop(DrawablePart):
151 | async def on_mouse_move(self, event: events.MouseMove) -> None:
152 | await self.pparent.drawable_is_moved(event)
153 |
154 |
155 | class Spacer(DrawablePart):
156 | async def on_mouse_move(self, event: events.MouseMove) -> None:
157 | await self.pparent.drawable_is_moved(event)
158 |
159 |
160 | class ResizerLeft(DrawablePart):
161 | async def on_mouse_move(self, event: events.MouseMove) -> None:
162 | await self.pparent.drawable_is_moved(event)
163 |
--------------------------------------------------------------------------------
/notesh/main.css:
--------------------------------------------------------------------------------
1 | Screen {
2 | layers: log note_disactive footer sidebar topper;
3 | background: $background-darken-1;
4 | overflow-x: hidden;
5 | overflow-y: hidden;
6 | }
7 |
8 | PlayArea {
9 | opacity: 100%;
10 | background: $surface;
11 | layer: note_disactive;
12 | border: outer $secondary;
13 | overflow-x: hidden;
14 | overflow-y: hidden;
15 | layout: grid;
16 | }
17 |
18 | Footer {
19 | layer: footer;
20 | }
21 |
22 | Note {
23 | layer: note_disactive;
24 | background:;
25 | border: none;
26 | text-style:;
27 | rule: ;
28 | padding: 0 0 0 0;
29 | margin: 0;
30 | height: 14;
31 | min-height: 6;
32 | width: 20;
33 | overflow: hidden;
34 | opacity: 100%;
35 | }
36 |
37 | Note Static {
38 | background: $secondary-darken-1;
39 | color: $text;
40 | border: none;
41 | width: 100%;
42 | height: 100%;
43 | overflow: hidden;
44 | text-style:;
45 | rule:;
46 |
47 | border: none;
48 | border-right: outer $secondary-darken-3;
49 | border-left: outer $secondary-lighten-1;
50 | border-top: none;
51 | border-bottom: outer $secondary-darken-3;
52 | }
53 |
54 | #note-toper {
55 | dock: top;
56 | height: 4;
57 | }
58 |
59 | #note-top {
60 | text-align: center;
61 | background: $secondary;
62 | border-top: outer $secondary-lighten-1;
63 | border-left: outer $secondary-lighten-1;
64 | border-bottom: none;
65 | height: 3;
66 | }
67 |
68 | #note-spacer {
69 | background: $secondary-darken-3;
70 | color: $secondary-lighten-1;
71 | border: none;
72 | height: 1;
73 | min-height: 1;
74 | }
75 |
76 | #note-resizer-left {
77 | background: $secondary-darken-3;
78 | color: $secondary-lighten-1;
79 | border: none;
80 | border-bottom: tall;
81 | height: 1;
82 | min-height: 1;
83 | width: 1fr;
84 | content-align: left bottom;
85 | }
86 |
87 | #note-resizer {
88 | background: $secondary-darken-3;
89 | color: $secondary-lighten-1;
90 | border: none;
91 | height: 1;
92 | min-height: 1;
93 | width: 2;
94 | content-align: right bottom;
95 | }
96 |
97 | #note-resizer-bar {
98 | dock: bottom;
99 | height: 1;
100 |
101 | }
102 |
103 | Sidebar {
104 | layer: sidebar;
105 | dock: right;
106 | width: 32;
107 | height: 100%;
108 | padding: 0 0 0 0;
109 | border: outer $accent-darken-3;
110 | transition: offset 500ms in_out_cubic;
111 | background: $panel;
112 | }
113 |
114 | Sidebar:focus-within {
115 | offset: 0 0 !important;
116 | }
117 |
118 | Sidebar.-hidden {
119 | offset-x: 100%;
120 | }
121 |
122 | #sidebar-title {
123 | border: heavy $secondary;
124 | padding: 0 2;
125 | text-style: bold;
126 | }
127 |
128 | MultilineArray {
129 | background: $boost;
130 | color: $text;
131 | border: heavy $secondary-darken-3;
132 | height: auto;
133 | min-height: 1;
134 | }
135 |
136 |
137 | MultilineInput {
138 | border: none;
139 | }
140 |
141 | Sidebar Button {
142 | width: 100%;
143 | height: 3;
144 | margin: 0 1;
145 | }
146 |
147 |
148 | Sidebar MultilineArray.-hidden {
149 | min-height: 0;
150 | min-width: 0;
151 | height: 0;
152 | width: 0;
153 | border: none;
154 | visibility: hidden;
155 | dock: bottom;
156 | }
157 |
158 | Sidebar ColorPicker.-hidden {
159 | min-height: 0;
160 | min-width: 0;
161 | height: 0;
162 | width: 0;
163 | border: none;
164 | visibility: hidden;
165 | dock: bottom;
166 | }
167 |
168 | Sidebar Input.-hidden {
169 | min-height: 0;
170 | min-width: 0;
171 | height: 0;
172 |
173 | width: 0;
174 | border: none;
175 | padding: 0;
176 | visibility: hidden;
177 | dock: bottom;
178 | }
179 |
180 | Sidebar Button.-hidden {
181 | min-height: 0;
182 | min-width: 0;
183 | height: 0;
184 | width: 0;
185 | border: none;
186 | visibility: hidden;
187 | dock: bottom;
188 | }
189 |
190 | ColorPicker {
191 | align-horizontal: center;
192 | background: $panel;
193 | /* background: $accent; */
194 | height: auto;
195 | margin: 0 0 0 0;
196 | }
197 |
198 | #color-picker-title {
199 | border: heavy $primary-lighten-3;
200 | padding: 0 2;
201 | text-style: bold;
202 | text-align: center;
203 |
204 | }
205 |
206 | ColorPickerDisplay {
207 | background: $panel;
208 | /* border: outer $panel-darken-3; */
209 | border-bottom: outer $panel-darken-3;
210 | content-align: center middle;
211 | padding: 0;
212 | margin: 0 1 0 1;
213 | color: $text;
214 | text-style: bold;
215 | height: 2;
216 | width: 100%;
217 | }
218 |
219 | ColorPickerChanger {
220 | grid-size: 3;
221 | height: 2;
222 | width: 8;
223 | }
224 |
225 | #color-changers {
226 | padding: 0;
227 | margin: 0 0 1 1;
228 | height: 2;
229 | width: auto;
230 | }
231 |
232 | #random-color {
233 | min-width: 1;
234 | min-height: 1;
235 | width: 4;
236 | height: 3;
237 | padding: 0;
238 | margin: 0;
239 | background: black;
240 | color: white;
241 | border: none;
242 | align-horizontal: center;
243 | content-align: center top;
244 | }
245 |
246 | ColorPickerChanger Static {
247 | background: $primary;
248 | row-span: 3;
249 | column-span: 2;
250 | min-width: 1;
251 | min-height: 1;
252 | height: 2;
253 | width: 6;
254 | color: $text;
255 | border: none;
256 | padding: 0;
257 | margin: 0;
258 | content-align: center top;
259 | text-style: bold;
260 | text-align: center;
261 | align-horizontal: center;
262 | }
263 |
264 | ColorPickerChanger Button {
265 | row-span: 1;
266 | column-span: 1;
267 | height: 1;
268 | width: 3;
269 | background: $accent;
270 | }
271 |
272 | ColorPickerChanger Button.-up {
273 | background: $accent-lighten-1;
274 | }
275 |
276 | ColorPickerChanger Button.-down {
277 | background: $accent-darken-3;
278 | }
279 |
280 | ColorPickerChanger Button:focus.-up {
281 | background: $accent-lighten-3;
282 | }
283 |
284 | ColorPickerChanger Button:focus.-down {
285 | background: $accent-darken-1;
286 | }
287 |
288 | Sidebar #border-picker {
289 | margin: 0 1 1 1;
290 | }
291 |
292 | Drawable {
293 | background: $error;
294 | min-width: 4;
295 | min-height: 3;
296 | color: $text;
297 | /* border: outer $primary; */
298 | /* border-bottom: outer $primary; */
299 | overflow-x: hidden;
300 | overflow-y: hidden;
301 | }
302 |
303 | Drawable #default-body {
304 | color: $text;
305 | background: $panel;
306 | min-width: 4;
307 | min-height: 1;
308 | border: outer $primary;
309 | height: 100%;
310 | overflow-x: hidden;
311 | overflow-y: hidden;
312 | }
313 |
314 | Drawable Resizer{
315 | opacity: 100%;
316 | color: $error;
317 | dock: bottom;
318 | height: 1;
319 | width: 2;
320 | min-height: 1;
321 | min-width: 1;
322 | align-horizontal: right;
323 | border: none;
324 | overflow-x: hidden;
325 | overflow-y: hidden;
326 | }
327 |
328 | Drawable Resizer:hover{
329 | opacity: 80%;
330 | }
331 |
332 | SidebarLeft {
333 | layer: sidebar;
334 | dock: left;
335 | width: 32;
336 | height: 100%;
337 | padding: 0 0 0 0;
338 | border: outer $accent-darken-3;
339 | transition: offset 500ms in_out_cubic;
340 | background: $panel;
341 | }
342 | SidebarLeft:focus-within {
343 | offset: 0 0 !important;
344 | }
345 |
346 | SidebarLeft.-hidden {
347 | offset-x: -100%;
348 | }
349 |
350 | HopScreen {
351 | background: 0%;
352 | }
353 |
--------------------------------------------------------------------------------
/notesh/main.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from pathlib import Path
5 | from typing import Optional
6 |
7 | from textual.app import App, ComposeResult
8 | from textual.binding import Binding
9 | from textual.geometry import Offset, Size
10 | from textual.keys import KEY_ALIASES
11 | from textual.widget import Widget
12 |
13 | from notesh.drawables.drawable import Drawable
14 | from notesh.play_area import PlayArea
15 | from notesh.utils import calculate_size_for_file, load_binding_config_file, load_drawables, save_drawables, set_bindings
16 | from notesh.widgets.focusable_footer import FocusableFooter
17 | from notesh.widgets.sidebar import DeleteDrawable, Sidebar
18 | from notesh.widgets.sidebar_left import SidebarLeft
19 | from hoptex.configs import HoptexBindingConfig
20 | from hoptex.decorator import hoptex
21 |
22 | KEY_ALIASES["backspace"] = ["ctrl+h"]
23 |
24 |
25 | def load_confing_hoptex():
26 | conf = load_binding_config_file(str(Path(__file__).parent / "default_bindings.toml"))
27 | conf.update(load_binding_config_file(str(Path(__file__).parent / "user_bindings.toml")))
28 | return conf.get("hoptex", {})
29 |
30 |
31 | # Not perfect solution to load file here,
32 | # but cant load confing for hoptex other way
33 | hoptex_conf = load_confing_hoptex()
34 |
35 | hoptex_binding = HoptexBindingConfig(
36 | focus=hoptex_conf.get("hoptex_focus", "ctrl+n"),
37 | quit=hoptex_conf.get("hoptex_quit", "escape,ctrl+c"),
38 | unfocus="",
39 | )
40 |
41 |
42 | @hoptex(bindings=hoptex_binding)
43 | class NoteApp(App[None]):
44 | CSS_PATH = "main.css"
45 |
46 | BINDINGS = [
47 | Binding("ctrl+c", "quit", "Quit"),
48 | ]
49 |
50 | DEFAULT_FILE = os.environ.get(
51 | "NOTESH_FILE",
52 | str(
53 | (
54 | Path(os.getenv("APPDATA", Path.home()))
55 | if os.name == "nt"
56 | else Path(os.getenv("XDG_DATA_HOME", Path("~/.local/share").expanduser()))
57 | )
58 | / "notesh"
59 | / "notes.json"
60 | ),
61 | )
62 |
63 | def __init__(
64 | self,
65 | watch_css: bool = False,
66 | file: str = DEFAULT_FILE,
67 | ):
68 | super().__init__(watch_css=watch_css)
69 | self.file = file
70 | self.footer = FocusableFooter()
71 | self.sidebar_left = SidebarLeft(classes="-hidden")
72 | self.sidebar = Sidebar(classes="-hidden")
73 |
74 | def compose(self) -> ComposeResult:
75 | min_size, max_size = calculate_size_for_file(self.file)
76 | self.play_area = PlayArea(min_size=min_size, max_size=max_size, screen_size=self.size)
77 | self.action_load_notes(min_size)
78 | self.sidebar_left.set_play_area(self.play_area)
79 |
80 | self._load_key_bindings()
81 |
82 | yield self.sidebar
83 | yield self.sidebar_left
84 | yield self.play_area
85 | yield self.footer
86 | self._hoptex_parent_widgets: set[Widget] = {self.play_area}
87 |
88 | self.set_focus(self.footer)
89 |
90 | async def action_delete(self):
91 | await self._delete_drawable()
92 |
93 | async def _edit_drawable(self):
94 | if self.play_area.focused_drawable is None:
95 | return
96 | self._hoptex_parent_widgets.add(self.sidebar)
97 | await self.sidebar.set_drawable(self.play_area.focused_drawable, True)
98 | self.set_focus(self.sidebar.get_child())
99 | self.play_area.can_focus_children = False
100 |
101 | async def action_edit(self):
102 | await self._edit_drawable()
103 |
104 | async def on_drawable_clicked(self, event: Drawable.Clicked):
105 | self.play_area.focused_drawable = event.drawable
106 | await self._edit_drawable()
107 |
108 | async def _move_drawable(self, direction: str) -> None:
109 | value = 1
110 | if "_" in direction:
111 | direction_parsed = direction.split("_")
112 | direction, value = direction_parsed[0], int(direction_parsed[1])
113 | await self.play_area.move_drawable(direction, value)
114 |
115 | async def _bring(self, direction: str) -> None:
116 | if self.play_area.focused_drawable is not None:
117 | getattr(self.play_area.focused_drawable, f"bring_{direction}")()
118 |
119 | async def _resize(self, direction: str) -> None:
120 | d = {"h_plus": (1, 0), "h_minus": (-1, 0), "v_plus": (0, 1), "v_minus": (0, -1)}
121 | if self.play_area.focused_drawable is not None:
122 | await self.play_area.focused_drawable.resize_drawable(*d[direction])
123 |
124 | def _unfocus(self, fully: bool = False):
125 | self.play_area.can_focus_children = True
126 | self.sidebar.set_focus(False)
127 | self.sidebar_left.set_focus(False)
128 | self._hoptex_parent_widgets.discard(self.sidebar)
129 | self._hoptex_parent_widgets.discard(self.sidebar_left)
130 | # Already Unfocused
131 | if self.focused is None:
132 | return
133 | # Unfocuss Fully (forced) or from view with selected one drawable
134 | if self.focused is self.play_area.focused_drawable or fully:
135 | self.set_focus(self.footer)
136 | self.play_area.focused_drawable = None
137 | return
138 | self.set_focus(self.play_area.focused_drawable)
139 | self.play_area.focused_drawable = None
140 |
141 | async def _delete_drawable(self, drawable: Optional[Drawable] = None):
142 | self.play_area.delete_drawable(drawable)
143 | await self.sidebar.set_drawable(None)
144 | if self.play_area.can_focus:
145 | self.set_focus(self.play_area)
146 | else:
147 | self._unfocus()
148 |
149 | async def action_unfocus(self):
150 | self._unfocus()
151 |
152 | def action_add_note(self) -> None:
153 | new_drawable = self.play_area.add_new_drawable("note")
154 | self._add_new_drawable(new_drawable)
155 |
156 | def action_add_drawable(self) -> None:
157 | new_drawable = self.play_area.add_new_drawable("drawable")
158 | self._add_new_drawable(new_drawable)
159 |
160 | def action_add_box(self) -> None:
161 | new_drawable = self.play_area.add_new_drawable("box")
162 | self._add_new_drawable(new_drawable)
163 |
164 | def action_toggle_sidebar(self) -> None:
165 | if self.sidebar.toggle_focus():
166 | self._hoptex_parent_widgets.add(self.sidebar)
167 | else:
168 | self._hoptex_parent_widgets.discard(self.sidebar)
169 |
170 | def action_toggle_sidebar_left(self) -> None:
171 | if not self.sidebar_left.toggle_focus():
172 | self._hoptex_parent_widgets.discard(self.sidebar_left)
173 | focus_candidat = self.play_area.focused_drawable
174 | self.play_area.can_focus_children = True
175 | if focus_candidat is not None:
176 | self.set_focus(focus_candidat)
177 | else:
178 | self._hoptex_parent_widgets.add(self.sidebar_left)
179 | self.play_area.can_focus_children = False
180 | self.set_focus(self.sidebar_left.children[0])
181 |
182 | def action_save_notes(self) -> None:
183 | save_drawables(self.file, self.play_area.drawables, list(self.screen.layers), self.play_area.dump())
184 |
185 | def action_load_notes(self, min_size: Size = Size(0, 0)) -> None:
186 | self.play_area.clear_drawables()
187 | drawables, background = load_drawables(self.file)
188 | for name, drawable_obj in drawables:
189 | self.play_area.add_parsed_drawable(drawable_obj, name, Offset(min_size.width, min_size.height))
190 | self.play_area.load(background)
191 | self.refresh()
192 |
193 | async def action_quit(self) -> None:
194 | self.action_save_notes()
195 | self.exit() # type: ignore
196 |
197 | async def on_play_area_clicked(self, message: PlayArea.Clicked):
198 | self._unfocus(fully=True)
199 |
200 | async def on_drawable_focus(self, message: Drawable.Focus):
201 | drawable = self.screen.get_widget_by_id(message.index)
202 | if isinstance(drawable, Drawable):
203 | await self.sidebar.set_drawable(drawable, message.display_sidebar)
204 |
205 | async def on_delete_drawable(self, message: DeleteDrawable) -> None:
206 | await self._delete_drawable(message.drawable)
207 |
208 | def _add_new_drawable(self, new_drawable: Drawable) -> None:
209 | self.set_focus(new_drawable)
210 | self.play_area.focused_drawable = new_drawable
211 | self.play_area.can_focus_children = True
212 |
213 | def _load_key_bindings(self):
214 | conf = load_binding_config_file(str(Path(__file__).parent / "default_bindings.toml"))
215 | conf.update(load_binding_config_file(str(Path(__file__).parent / "user_bindings.toml")))
216 |
217 | set_bindings(self, conf["default"], show=True)
218 |
219 | set_bindings(self, conf["moving_drawables"], func=self._move_drawable)
220 | set_bindings(self, conf["bring_drawable"], func=self._bring)
221 | set_bindings(self, conf["resize_drawable"], func=self._resize)
222 |
223 | set_bindings(self, conf["normal_insert"])
224 | set_bindings(self.play_area, conf["normal"])
225 |
226 |
227 | if __name__ == "__main__":
228 | app = NoteApp(watch_css=True)
229 | app.run() # type: ignore
230 |
--------------------------------------------------------------------------------
/notesh/play_area.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Optional, OrderedDict, cast
4 | from textual.app import ComposeResult
5 | from textual.color import Color
6 |
7 | from textual.containers import Container
8 | from textual.events import Click, MouseDown, MouseMove, MouseUp
9 | from textual.geometry import Offset, Size
10 | from textual.message import Message
11 | from textual.reactive import reactive
12 | from textual.widget import Widget
13 |
14 | from notesh.drawables.box import Box
15 | from notesh.drawables.drawable import Drawable
16 | from notesh.drawables.sticknote import Note
17 |
18 | CHUNK_SIZE = Offset(20, 5)
19 |
20 |
21 | class PlayArea(Container):
22 | can_focus: bool = True
23 | drawables: list[Drawable] = []
24 | is_draggin = False
25 | focused_drawable: Optional[Drawable] = None
26 | background_type: reactive[str] = reactive("plain")
27 |
28 | def __init__(
29 | self,
30 | *children: Widget,
31 | name: str | None = None,
32 | id: str | None = None,
33 | classes: str | None = None,
34 | min_size: Size = Size(0, 0),
35 | max_size: Size = Size(100, 40),
36 | screen_size: Size = Size(100, 100),
37 | color: str = "#444444",
38 | border_color: str = "#ffaa00",
39 | ) -> None:
40 | super().__init__(*children, name=name, id=id, classes=classes)
41 | calculated_width, calculated_height = self._calculate_size(min_size, max_size)
42 | self.styles.width, self.styles.height = calculated_width, calculated_height
43 | self.offset += self._calculate_additional_offset(screen_size, Size(calculated_width, calculated_height))
44 | self.color = Color.parse(color)
45 | self.border_color = Color.parse(border_color)
46 |
47 | def compose(self) -> ComposeResult:
48 | self.change_color(self.color, duration=0.0)
49 | yield from ()
50 |
51 | def change_color(self, new_color: str | Color, duration: float = 1.0, part_type: str = "body") -> None:
52 | if isinstance(new_color, str):
53 | base_color = Color.parse(new_color)
54 | else:
55 | base_color = new_color
56 |
57 | if part_type == "" or part_type == "body":
58 | self.color = base_color
59 | else:
60 | self.border_color = base_color
61 | self.update_layout(duration)
62 |
63 | def update_layout(self, duration: float = 1.0):
64 | base_color = self.color
65 | border_color = self.border_color
66 |
67 | self.styles.animate("background", value=base_color, duration=duration)
68 | self.styles.border = ("outer", border_color)
69 |
70 | def sidebar_layout(self, widgets: OrderedDict[str, Widget]) -> None:
71 | widgets["body_color_picker"].remove_class("-hidden")
72 | widgets["border_color_picker"].remove_class("-hidden")
73 |
74 | widgets["body_color_picker"].update_colors(self.color)
75 | widgets["border_color_picker"].update_colors(self.border_color)
76 |
77 | def add_new_drawable(self, drawable_type: str) -> Drawable:
78 | d = {"note": Note, "box": Box}
79 | drawable = cast(Drawable, d.get(drawable_type, Drawable)())
80 | self._mount_drawable(drawable)
81 |
82 | return drawable
83 |
84 | def add_parsed_drawable(self, obj: dict[Any, Any], drawable_id: str, offset: Offset = Offset(0, 0)) -> None:
85 | drawable_type = obj["type"]
86 | d = {"note": Note, "box": Box}
87 | drawable = cast(Drawable, d.get(drawable_type, Drawable).load(obj, drawable_id, offset))
88 | self._mount_drawable(drawable)
89 |
90 | def clear_drawables(self) -> None:
91 | while self.drawables:
92 | self.drawables.pop().remove()
93 |
94 | def delete_drawable(self, drawable: Optional[Drawable] = None) -> None:
95 | if drawable is None:
96 | drawable = self.focused_drawable
97 | if drawable is None:
98 | return
99 |
100 | self.drawables = [note for note in self.drawables if note != drawable]
101 | drawable.remove()
102 | self.focused_drawable = None
103 | if len(self.drawables) == 0:
104 | self.can_focus = True
105 |
106 | async def on_mouse_move(self, event: MouseMove) -> None:
107 | if event.ctrl and self.is_draggin:
108 | await self._move_play_area(event.delta)
109 |
110 | async def on_mouse_down(self, event: MouseDown) -> None:
111 | if event.ctrl:
112 | self.is_draggin = True
113 | self.capture_mouse()
114 |
115 | async def on_mouse_up(self, _: MouseUp) -> None:
116 | self.is_draggin = False
117 | self.capture_mouse(False)
118 |
119 | async def on_click(self, event: Click) -> None:
120 | self.post_message(PlayArea.Clicked())
121 |
122 | async def on_drawable_move(self, event: Drawable.Move) -> None:
123 | await self._resize_field_to_drawable(event.drawable, event.offset)
124 |
125 | async def on_drawable_focus(self, message: Drawable.Focus) -> None:
126 | drawable = self.screen.get_widget_by_id(message.index)
127 | self.focused_drawable = cast(Drawable, drawable)
128 |
129 | async def move_drawable(self, direction: str, value: int) -> None:
130 | if self.focused_drawable is not None:
131 | await self.focused_drawable.move(direction, value)
132 |
133 | def _calculate_additional_offset(self, size_a: Size, size_b: Size):
134 | return Offset((size_a.width - size_b.width) // 2, (size_a.height - size_b.height) // 2)
135 |
136 | def _calculate_size(self, min_size: Size, max_size: Size):
137 | calculated_width = ((max_size.width - min_size.width + 1) // CHUNK_SIZE.x) * CHUNK_SIZE.x + CHUNK_SIZE.x
138 | calculated_height = ((max_size.height - min_size.height + 1) // CHUNK_SIZE.y) * CHUNK_SIZE.y + CHUNK_SIZE.y
139 | return calculated_width, calculated_height
140 |
141 | def _mount_drawable(self, drawable: Drawable) -> None:
142 | self.drawables.append(drawable)
143 | self.mount(drawable)
144 | self.is_draggin = False
145 | self.can_focus = False
146 |
147 | async def _move_play_area(self, offset: Offset) -> None:
148 | self.offset = self.offset + offset
149 |
150 | async def _resize_field_to_drawable(self, drawable: Drawable, offset: Offset = Offset(0, 0)) -> None:
151 | xx, yy = offset.x, offset.y
152 | if drawable.region.right + xx >= self.region.right:
153 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x
154 |
155 | if drawable.region.bottom + yy >= self.region.bottom:
156 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y
157 |
158 | if drawable.region.x + xx <= self.region.x:
159 | self.styles.width = self.styles.width.value + CHUNK_SIZE.x
160 | self.styles.offset = (self.styles.offset.x.value - CHUNK_SIZE.x, self.styles.offset.y.value)
161 | for child in self.children:
162 | child.styles.offset = (child.styles.offset.x.value + CHUNK_SIZE.x, child.styles.offset.y.value)
163 |
164 | if drawable.region.y + yy <= self.region.y:
165 | self.styles.height = self.styles.height.value + CHUNK_SIZE.y
166 | self.styles.offset = (self.styles.offset.x.value, self.styles.offset.y.value - CHUNK_SIZE.y)
167 | for child in self.children:
168 | child.styles.offset = (child.styles.offset.x.value, child.styles.offset.y.value + CHUNK_SIZE.y)
169 |
170 | def dump(self) -> dict[str, Any]:
171 | return {
172 | "color": self.color.hex6,
173 | "border_color": self.border_color.hex6,
174 | "type": self.background_type,
175 | }
176 |
177 | def load(self, obj: Optional[dict[Any, Any]]):
178 | if obj is None:
179 | return
180 | self.color = Color.parse(obj["color"])
181 | self.border_color = Color.parse(obj["border_color"])
182 | self.background_type = obj["type"]
183 |
184 | class Clicked(Message):
185 | def __init__(
186 | self,
187 | ) -> None:
188 | super().__init__()
189 |
--------------------------------------------------------------------------------
/notesh/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import os
5 | from pathlib import Path
6 | import sys
7 | import uuid
8 | from functools import partial
9 | from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union
10 | from textual.app import App
11 |
12 | import tomli
13 | from textual.geometry import Size
14 |
15 | if TYPE_CHECKING:
16 | from textual.containers import Container
17 |
18 | from notesh.drawables.drawable import Drawable
19 |
20 |
21 | def generate_short_uuid() -> str:
22 | return uuid.uuid4().hex[:4]
23 |
24 |
25 | def calculate_size_for_file(file_name: str) -> tuple[Size, Size]:
26 | if not os.path.exists(file_name):
27 | return Size(0, 0), Size(50, 20)
28 |
29 | with open(file_name, "r") as file:
30 | obj = json.load(file)
31 |
32 | keys = [x for x in obj.keys() if x not in ["background", "layers"]]
33 |
34 | mxx, mxy = -sys.maxsize, -sys.maxsize
35 | mnx, mny = sys.maxsize, sys.maxsize
36 | for drawable in sorted(keys, key=lambda x: obj["layers"].index(x)):
37 | mxx = max(mxx, obj[drawable]["pos"][0] + obj[drawable]["size"][0])
38 | mnx = min(mnx, obj[drawable]["pos"][0])
39 | mxy = max(mxy, obj[drawable]["pos"][1] + obj[drawable]["size"][1])
40 | mny = min(mny, obj[drawable]["pos"][1])
41 |
42 | mxx = 50 if mxx == sys.maxsize else max(mxx, 50)
43 | mxy = 20 if mxy == sys.maxsize else max(mxy, 20)
44 | mnx = 0 if mnx == sys.maxsize else mnx
45 | mny = 0 if mny == sys.maxsize else mny
46 |
47 | return Size(mnx, mny), Size(mxx, mxy)
48 |
49 |
50 | def save_drawables(
51 | file_name: str, drawables: list[Drawable], layers: list[str], background: Optional[dict[Any, Any]] = None
52 | ) -> None:
53 | obj: dict[str, Any] = {"layers": []}
54 | layers_set: set[str] = set()
55 | drawable: Drawable
56 | for drawable in drawables:
57 | if drawable.id is None:
58 | continue
59 | obj[drawable.id] = drawable.dump()
60 | layers_set.add(drawable.id)
61 |
62 | obj["layers"].extend([x for x in layers if x in layers_set])
63 | if background is not None:
64 | obj["background"] = background
65 |
66 | Path(file_name).parent.mkdir(parents=True, exist_ok=True)
67 | with open(file_name, "w") as file:
68 | json.dump(obj, file, indent=4)
69 |
70 |
71 | def load_drawables(file_name: str) -> tuple[list[tuple[str, dict[Any, Any]]], Optional[dict[Any, Any]]]:
72 | if not os.path.exists(file_name):
73 | return [], None
74 |
75 | with open(file_name, "r") as file:
76 | obj = json.load(file)
77 |
78 | if not obj:
79 | return [], None
80 |
81 | background = None
82 | if "background" in obj:
83 | background = obj["background"]
84 |
85 | keys = [x for x in obj.keys() if x not in ["background", "layers"]]
86 | return [(name, obj[name]) for name in sorted(keys, key=lambda x: obj["layers"].index(x))], background
87 |
88 |
89 | def load_binding_config_file(file_name: str) -> dict[str, Any]:
90 | try:
91 | if not os.path.exists(file_name):
92 | return {}
93 | with open(file_name, "rb") as f:
94 | conf = tomli.load(f)
95 | return conf
96 | except (FileNotFoundError, PermissionError, tomli.TOMLDecodeError):
97 | return {}
98 |
99 |
100 | def set_bindings(
101 | where: Union[Container, App[None]],
102 | config: dict[str, list[str] | str],
103 | show: bool = False,
104 | func: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None,
105 | ):
106 | for key, value in config.items():
107 | key_binding: str
108 | description: str
109 | if isinstance(value, list):
110 | key_binding, description = value[0], value[1]
111 | elif isinstance(value, str): # type: ignore
112 | key_binding, description = value, ""
113 | else:
114 | continue
115 | where._bindings.bind(key_binding, key, description, show=show, priority=False) # type: ignore
116 | if func is not None:
117 | setattr(where, f"action_{key}", partial(func, direction=key))
118 |
--------------------------------------------------------------------------------
/notesh/widgets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Cvaniak/NoteSH/911f0e2460dc858e1e41aad83662ffce7efd8448/notesh/widgets/__init__.py
--------------------------------------------------------------------------------
/notesh/widgets/color_picker.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from random import randint
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Grid, Horizontal, Vertical
8 | from textual.events import MouseScrollDown, MouseScrollUp
9 | from textual.message import Message
10 | from textual.reactive import reactive
11 | from textual.widget import Widget
12 | from textual.widgets import Button, Static
13 |
14 |
15 | class ColorPickerRandom(Button):
16 | ...
17 |
18 |
19 | class ColorPickerDisplay(Static):
20 | ...
21 |
22 |
23 | class ColorPickerChanger(Grid):
24 | value: reactive[int] = reactive(0)
25 |
26 | def __init__(
27 | self,
28 | *children: Widget,
29 | parent: ColorPicker,
30 | color_arg: str = "",
31 | id: str | None = None,
32 | ) -> None:
33 | super().__init__(*children, id=id)
34 | self.pparent = parent
35 | self.argument = color_arg
36 | self.static_widget = Static(" ", classes="-color")
37 | w = {"r": 50, "g": 50, "b": 50}
38 | w.update({self.argument: 210})
39 | self.static_widget.styles.background = Color(**w)
40 | self.value = getattr(self.pparent, self.argument)
41 |
42 | self.button_up = Button("▲", id="up", classes="-up")
43 | self.button_down = Button("▼", id="down", classes="-down")
44 |
45 | def compose(self) -> ComposeResult:
46 | yield self.static_widget
47 | yield self.button_up
48 | yield self.button_down
49 |
50 | def watch_value(self, new_value: int):
51 | self.static_widget.update(f"{self.argument}\n{new_value:<3}")
52 |
53 | def on_button_pressed(self, event: Button.Pressed):
54 | new_value = 0
55 | button_id = event.button.id
56 | if button_id == "up":
57 | new_value = 10
58 | if button_id == "down":
59 | new_value = -10
60 |
61 | _value = getattr(self.pparent, self.argument) + new_value
62 | setattr(self.pparent, self.argument, _value)
63 | self.value = getattr(self.pparent, self.argument)
64 |
65 | async def on_mouse_scroll_down(self, event: MouseScrollDown):
66 | _value = getattr(self.pparent, self.argument) + 1
67 | setattr(self.pparent, self.argument, _value)
68 | self.value = getattr(self.pparent, self.argument)
69 |
70 | async def on_mouse_scroll_up(self, event: MouseScrollUp):
71 | _value = getattr(self.pparent, self.argument) - 1
72 | setattr(self.pparent, self.argument, _value)
73 | self.value = getattr(self.pparent, self.argument)
74 |
75 |
76 | class ColorPicker(Vertical):
77 | r: reactive[int] = reactive(0)
78 | g: reactive[int] = reactive(0)
79 | b: reactive[int] = reactive(0)
80 | hue: reactive[int] = reactive(0)
81 |
82 | def __init__(
83 | self,
84 | *children: Widget,
85 | title: str = "Color Picker",
86 | type: str = "",
87 | name: str | None = None,
88 | id: str | None = None,
89 | classes: str | None = None,
90 | ) -> None:
91 | super().__init__(*children, name=name, id=id, classes=classes)
92 | self.color_display = ColorPickerDisplay("")
93 | self.color_display.styles.background = Color(self.r, self.g, self.b)
94 | self.title = Static(title, id="color-picker-title")
95 | self.type = type
96 | self.color_changers = {
97 | "r": ColorPickerChanger(color_arg="r", parent=self, id="change-r"),
98 | "g": ColorPickerChanger(color_arg="g", parent=self, id="change-g"),
99 | "b": ColorPickerChanger(color_arg="b", parent=self, id="change-b"),
100 | }
101 |
102 | def compose(self) -> ComposeResult:
103 | yield self.title
104 | yield self.color_display
105 | yield Horizontal(*self.color_changers.values(), Button(" ?? ?? ", id="random-color"), id="color-changers")
106 |
107 | def update_colors(self, color: Color):
108 | self.r, self.g, self.b = color.rgb
109 | for c in self.color_changers:
110 | self.color_changers[c].value = getattr(self, c)
111 |
112 | self.color_display.styles.background = color
113 |
114 | async def update_color(self):
115 | color = Color(self.r, self.g, self.b)
116 | self.color_display.styles.background = color
117 | for c in self.color_changers:
118 | self.color_changers[c].value = getattr(self, c)
119 |
120 | self.post_message(self.Change(color, argument=self.type))
121 |
122 | def on_button_pressed(self, event: Button.Pressed):
123 | button_id = event.button.id
124 | if button_id == "random-color":
125 | for i in "rgb":
126 | setattr(self, i, randint(30, 220))
127 |
128 | @staticmethod
129 | def _clamp(value: int) -> int:
130 | return max(0, min(value, 255))
131 |
132 | def validate_r(self, new_value: int) -> int:
133 | return self._clamp(new_value)
134 |
135 | def validate_g(self, new_value: int) -> int:
136 | return self._clamp(new_value)
137 |
138 | def validate_b(self, new_value: int) -> int:
139 | return self._clamp(new_value)
140 |
141 | async def watch_r(self, new_value: int):
142 | await self.update_color()
143 |
144 | async def watch_g(self, new_value: int):
145 | await self.update_color()
146 |
147 | async def watch_b(self, new_value: int):
148 | await self.update_color()
149 |
150 | class Change(Message):
151 | def __init__(self, color: Color | str, argument: str) -> None:
152 | super().__init__()
153 | self.color = color
154 | self.type = argument
155 |
--------------------------------------------------------------------------------
/notesh/widgets/focusable_footer.py:
--------------------------------------------------------------------------------
1 | from textual.widgets import Footer
2 |
3 |
4 | class FocusableFooter(Footer):
5 | can_focus = True
6 |
--------------------------------------------------------------------------------
/notesh/widgets/multiline_input.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from textual.app import ComposeResult
4 | from textual.binding import Binding
5 | from textual.containers import Vertical
6 | from textual.message import Message
7 | from textual.widgets import Input
8 |
9 | from notesh.utils import generate_short_uuid
10 |
11 |
12 | class MultilineInput(Input):
13 | BINDINGS = Input.BINDINGS
14 | BINDINGS.extend(
15 | [
16 | Binding("up", "cursor_up", "cursor up", show=False),
17 | Binding("down", "cursor_down", "cursor down", show=False),
18 | ]
19 | )
20 |
21 | def action_cursor_up(self) -> None:
22 | self.post_message(self.Arrow(self, "up"))
23 |
24 | def action_cursor_down(self) -> None:
25 | self.post_message(self.Arrow(self, "down"))
26 |
27 | def action_delete_left(self) -> None:
28 | super().action_delete_left()
29 | if not self.value:
30 | self.post_message(self.Backspace(self))
31 |
32 | class Backspace(Message):
33 | def __init__(self, multiline_input: MultilineInput) -> None:
34 | super().__init__()
35 | self.multiline_input = multiline_input
36 |
37 | class Arrow(Message):
38 | def __init__(self, multiline_input: MultilineInput, top_down: str) -> None:
39 | super().__init__()
40 | self.multiline_input = multiline_input
41 | self.top_down = top_down
42 |
43 |
44 | class MultilineArray(Vertical):
45 | lines = [MultilineInput("", id="sidebar-input-0")]
46 |
47 | def compose(self) -> ComposeResult:
48 | for line in self.lines:
49 | yield line
50 |
51 | def on_input_submitted(self, event: Input.Submitted):
52 | idx = self.lines.index(event.input)
53 | new_input = MultilineInput("", id=f"sidebar-input-{generate_short_uuid()}")
54 | self.lines.insert(idx + 1, new_input)
55 | self.mount(new_input, after=event.input)
56 | self.screen.set_focus(new_input)
57 |
58 | def on_multiline_input_backspace(self, event: MultilineInput.Backspace):
59 | if event.multiline_input not in self.lines:
60 | return
61 | idx = self.lines.index(event.multiline_input)
62 | if len(self.lines) >= 2:
63 | self.lines.remove(event.multiline_input)
64 | event.multiline_input.remove()
65 | if self.lines:
66 | self.screen.set_focus(self.lines[idx - 1])
67 |
68 | def on_multiline_input_arrow(self, event: MultilineInput.Arrow):
69 | idx = self.lines.index(event.multiline_input)
70 | n = len(self.lines)
71 | if event.top_down == "up":
72 | if idx == 0:
73 | return
74 | self.screen.set_focus(self.lines[idx - 1])
75 | else:
76 | if idx + 1 == n:
77 | return
78 | self.screen.set_focus(self.lines[idx + 1])
79 |
80 | def recreate_multiline(self, value: str) -> None:
81 | while self.lines:
82 | self.lines.pop().remove()
83 |
84 | for line in value.split(" \n"):
85 | self.lines.append(MultilineInput(line, id=f"sidebar-input-{len(self.lines)}"))
86 | self.mount(self.lines[-1])
87 |
88 | async def on_input_changed(self, event: MultilineInput.Changed):
89 | event.stop()
90 | self.post_message(self.Changed(self))
91 |
92 | class Changed(Message):
93 | def __init__(self, sender: MultilineArray) -> None:
94 | super().__init__()
95 | self.input = sender
96 |
--------------------------------------------------------------------------------
/notesh/widgets/sidebar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, OrderedDict
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Vertical
8 | from textual.message import Message
9 | from textual.widget import Widget
10 | from textual.widgets import Button, Input
11 |
12 | from notesh.drawables.drawable import Drawable
13 | from notesh.play_area import PlayArea
14 | from notesh.widgets.color_picker import ColorPicker
15 | from notesh.widgets.multiline_input import MultilineArray
16 |
17 |
18 | class DeleteDrawable(Message):
19 | def __init__(self, drawable: Drawable) -> None:
20 | super().__init__()
21 | self.drawable = drawable
22 |
23 |
24 | class Sidebar(Vertical):
25 | can_focus_children: bool = False
26 |
27 | def __init__(
28 | self,
29 | *children: Widget,
30 | name: str | None = None,
31 | id: str | None = None,
32 | classes: str | None = None,
33 | ) -> None:
34 | super().__init__(*children, name=name, id=id, classes=classes)
35 | self.drawable: Optional[Drawable] = None
36 |
37 | input = Input("Title", id="sidebar-title")
38 | multiline_array = MultilineArray()
39 |
40 | body_color_picker = ColorPicker(type="body", title="Body Color Picker")
41 |
42 | border_color_picker = ColorPicker(type="border", title="Border Color Picker")
43 | border_type = Button("Change Border", id="border-picker", variant="warning")
44 |
45 | button = Button("Delete Note", id="delete-sticknote", variant="error")
46 |
47 | self.widget_list: OrderedDict[str, Widget] = OrderedDict(
48 | {
49 | "input": input,
50 | "multiline_array": multiline_array,
51 | "body_color_picker": body_color_picker,
52 | "border_color_picker": border_color_picker,
53 | "border_picker": border_type,
54 | "delete_button": button,
55 | }
56 | )
57 |
58 | def compose(self) -> ComposeResult:
59 | self.current_layout = Vertical(*self.widget_list.values())
60 | yield self.current_layout
61 |
62 | def change_sidebar(self):
63 | for widget in self.widget_list.values():
64 | widget.add_class("-hidden")
65 |
66 | if self.drawable is not None:
67 | self.drawable.sidebar_layout(self.widget_list)
68 |
69 | async def set_drawable(self, drawable: Optional[Drawable], display_sidebar: bool = False):
70 | self.drawable = drawable
71 | self.change_sidebar()
72 |
73 | if self.drawable is None:
74 | self.set_focus(False)
75 | elif display_sidebar:
76 | self.set_focus(True)
77 | self.refresh()
78 |
79 | async def on_input_changed(self, event: Input.Changed):
80 | if self.drawable is not None:
81 | self.drawable.input_changed(event)
82 |
83 | async def on_multiline_array_changed(self, event: MultilineArray.Changed):
84 | if self.drawable is not None:
85 | self.drawable.multiline_array_changed(event)
86 |
87 | def change_drawable_color(self, color: Color | str, part_type: str):
88 | if self.drawable:
89 | self.drawable.change_color(color, part_type=part_type)
90 |
91 | def on_button_pressed(self, event: Button.Pressed):
92 | if not self.drawable:
93 | return
94 | button_id = event.button.id
95 | if button_id == "delete-sticknote":
96 | if not self.drawable:
97 | return
98 | self.post_message(DeleteDrawable(self.drawable))
99 | self.screen.query_one(PlayArea).post_message(DeleteDrawable(self.drawable))
100 | self.refresh()
101 | if button_id == "border-picker":
102 | self.drawable.next_border()
103 |
104 | def on_color_picker_change(self, message: ColorPicker.Change):
105 | self.change_drawable_color(message.color, message.type)
106 |
107 | def get_child(self, index: Optional[int] = None) -> Optional[Widget]:
108 | if index is None:
109 | for child in self.widget_list.values():
110 | if child.has_class("-hidden"):
111 | continue
112 | # Should be change to something pretier
113 | if isinstance(child, MultilineArray):
114 | return child.lines[0]
115 | else:
116 | return child
117 | return None
118 | child: Widget = list(self.widget_list.values())[index % len(self.widget_list)]
119 | if child.has_class("-hidden"):
120 | return None
121 | return child
122 |
123 | def toggle_focus(self) -> bool:
124 | if self.has_class("-hidden"):
125 | self.set_focus(True)
126 | return True
127 | else:
128 | self.set_focus(False)
129 | return False
130 |
131 | def set_focus(self, focus: bool):
132 | if focus is True:
133 | self.remove_class("-hidden")
134 | self.can_focus_children = True
135 | else:
136 | self.add_class("-hidden")
137 | self.can_focus_children = False
138 |
--------------------------------------------------------------------------------
/notesh/widgets/sidebar_left.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Optional, OrderedDict
4 |
5 | from textual.app import ComposeResult
6 | from textual.color import Color
7 | from textual.containers import Vertical
8 | from textual.widget import Widget
9 | from textual.widgets import Static
10 |
11 | from notesh.drawables.drawable import Drawable
12 | from notesh.play_area import PlayArea
13 | from notesh.widgets.color_picker import ColorPicker
14 |
15 |
16 | class SidebarLeft(Vertical):
17 | can_focus_children: bool = False
18 |
19 | def __init__(
20 | self,
21 | *children: Widget,
22 | name: str | None = None,
23 | id: str | None = None,
24 | classes: str | None = None,
25 | ) -> None:
26 | super().__init__(*children, name=name, id=id, classes=classes)
27 | self.drawable: Optional[Drawable] = None
28 |
29 | self.play_area = None
30 |
31 | title = Static("Here more settings soon!", id="sidebar-title")
32 | body_color_picker = ColorPicker(type="body", title="Body Color Picker")
33 | border_color_picker = ColorPicker(type="border", title="Border Color Picker")
34 |
35 | self.widget_list: OrderedDict[str, Widget] = OrderedDict(
36 | {
37 | "title": title,
38 | "body_color_picker": body_color_picker,
39 | "border_color_picker": border_color_picker,
40 | }
41 | )
42 |
43 | def compose(self) -> ComposeResult:
44 | self.current_layout = Vertical(*self.widget_list.values())
45 | yield self.current_layout
46 |
47 | def toggle_focus(self):
48 | if self.has_class("-hidden"):
49 | self.set_focus(True)
50 | return True
51 | else:
52 | self.set_focus(False)
53 | return False
54 |
55 | def set_focus(self, focus: bool):
56 | if focus is True:
57 | self.remove_class("-hidden")
58 | self.can_focus_children = True
59 | else:
60 | self.add_class("-hidden")
61 | self.can_focus_children = False
62 |
63 | def set_play_area(self, play_area: PlayArea):
64 | self.play_area = play_area
65 | self.play_area.sidebar_layout(self.widget_list)
66 |
67 | def change_play_area_color(self, color: Color | str, part_type: str) -> None:
68 | if self.play_area:
69 | self.play_area.change_color(color, part_type=part_type)
70 |
71 | def on_color_picker_change(self, message: ColorPicker.Change):
72 | self.change_play_area_color(message.color, message.type)
73 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 120
3 | include = '\.pyi?$'
4 | exclude = '''
5 | /(
6 | | \.git
7 | | venv
8 | | build
9 | | dist
10 | )/
11 | '''
12 |
13 | [tool.pyright]
14 | typeCheckingMode = "strict"
15 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | textual
2 | tomli
3 | hoptex
4 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | license_files = LICENSE
3 | license = MIT
4 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from setuptools import setup
3 |
4 | # Read the long description from README.md
5 | this_directory = Path(__file__).parent
6 | long_description = (this_directory / "README.md").read_text()
7 |
8 | setup(
9 | name="Notesh",
10 | version="0.8.0",
11 | description="NoteSH: A fully functional sticky notes App in your Terminal!",
12 | long_description=long_description,
13 | long_description_content_type="text/markdown",
14 | url="http://github.com/Cvaniak/Notesh",
15 | author="Cvaniak",
16 | author_email="igna.cwaniak@gmail.com",
17 | packages=["notesh", "notesh.drawables", "notesh.widgets"],
18 | python_requires=">=3.7, <4",
19 | install_requires=["textual==0.37.1", "tomli==2.0.1", "hoptex==0.2.0"],
20 | entry_points={"console_scripts": ["notesh=notesh.command_line:run"]},
21 | package_data={
22 | "notesh": ["*.css", "notesh/*.css", "default_bindings.toml"]
23 | },
24 | data_files=[("share/applications", ["Notesh.desktop"])],
25 | include_package_data=True,
26 | zip_safe=False,
27 | )
28 |
--------------------------------------------------------------------------------