diff --git a/ChangeLog.md b/ChangeLog.md index 45a839e..5e98a2c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -11,6 +11,7 @@ ([#96](https://github.com/davep/braindrop/pull/96)) - Free text search now also looks in the domain of a raindrop. ([#96](https://github.com/davep/braindrop/pull/96)) +- Added raindrop types to the navigation panel. ## v0.5.0 diff --git a/src/braindrop/app/data/__init__.py b/src/braindrop/app/data/__init__.py index eb44a8b..f7f971f 100644 --- a/src/braindrop/app/data/__init__.py +++ b/src/braindrop/app/data/__init__.py @@ -10,7 +10,7 @@ ) from .exit_state import ExitState from .local import LocalData, local_data_file -from .raindrops import Raindrops, TagCount +from .raindrops import Raindrops, TagCount, TypeCount from .token import token_file ############################################################################## @@ -23,6 +23,7 @@ "LocalData", "Raindrops", "TagCount", + "TypeCount", "save_configuration", "token_file", "update_configuration", diff --git a/src/braindrop/app/data/raindrops.py b/src/braindrop/app/data/raindrops.py index 6f0a637..6f6454d 100644 --- a/src/braindrop/app/data/raindrops.py +++ b/src/braindrop/app/data/raindrops.py @@ -7,6 +7,7 @@ ############################################################################## # Python imports. from dataclasses import dataclass +from functools import total_ordering from typing import Callable, Counter, Iterable, Iterator ############################################################################## @@ -18,6 +19,7 @@ from ...raindrop import ( Collection, Raindrop, + RaindropType, SpecialCollection, Tag, ) @@ -60,6 +62,28 @@ def _getter(data: TagCount) -> int: return _getter +############################################################################## +@dataclass(frozen=True) +@total_ordering +class TypeCount: + """Holds the count details of a raindrop type.""" + + type: RaindropType + """The type.""" + count: int + """The count of raindrops of that type.""" + + def __gt__(self, value: object, /) -> bool: + if isinstance(value, TypeCount): + return self.type > value.type + raise NotImplementedError + + def __eq__(self, value: object, /) -> bool: + if isinstance(value, TypeCount): + return self.type == value.type + raise NotImplementedError + + ############################################################################## class Filter: """Base class for the raindrop filters.""" @@ -101,6 +125,29 @@ def __eq__(self, value: object) -> bool: return str(value) == self._tag return super().__eq__(value) + class IsOfType(Filter): + """Filter class to check if a raindrop is of a given type.""" + + def __init__(self, raindrop_type: RaindropType) -> None: + """Initialise the object. + + Args: + raindrop_type: The raindrop type to filter on. + """ + self._type = raindrop_type + """The type of raindrop to filter for.""" + + def __rand__(self, raindrop: Raindrop) -> bool: + return raindrop.type == self._type + + def __str__(self) -> str: + return str(self._type) + + def __eq__(self, value: object) -> bool: + if isinstance(value, Raindrops.IsOfType): + return str(value) == self._type + return super().__eq__(value) + class Containing(Filter): """Filter class to check if a raindrop contains some specific text.""" @@ -241,6 +288,12 @@ def unfiltered(self) -> Raindrops: def description(self) -> str: """The description of the content of the Raindrop grouping.""" filters = [] + if raindrop_types := [ + f"{raindrop_type}" + for raindrop_type in self._filters + if isinstance(raindrop_type, self.IsOfType) + ]: + filters.append(f"type {' and '.join(raindrop_types)}") if search_text := [ f'"{text}"' for text in self._filters if isinstance(text, self.Containing) ]: @@ -257,6 +310,16 @@ def tags(self) -> list[TagCount]: tags.extend(set(raindrop.tags)) return [TagCount(name, count) for name, count in Counter(tags).items()] + @property + def types(self) -> list[TypeCount]: + """The list of types found amongst the Raindrops.""" + return [ + TypeCount(name, count) + for name, count in Counter[RaindropType]( + raindrop.type for raindrop in self + ).items() + ] + def __and__(self, new_filter: Filter) -> Raindrops: """Get the raindrops that match a given filter. @@ -289,6 +352,17 @@ def tagged(self, tag: Tag | str) -> Raindrops: """ return self & self.Tagged(tag) + def of_type(self, raindrop_type: RaindropType) -> Raindrops: + """Get the raindrops of a given type. + + Args: + raindrop_type: The type to look for. + + Returns: + The subset of Raindrops that are of the type. + """ + return self & self.IsOfType(raindrop_type) + def containing(self, search_text: str) -> Raindrops: """Get the raindrops containing the given text. diff --git a/src/braindrop/app/messages/__init__.py b/src/braindrop/app/messages/__init__.py index 2501983..4195e27 100644 --- a/src/braindrop/app/messages/__init__.py +++ b/src/braindrop/app/messages/__init__.py @@ -2,12 +2,13 @@ ############################################################################## # Local imports. -from .main import ShowCollection, ShowTagged +from .main import ShowCollection, ShowOfType, ShowTagged ############################################################################## # Exports. __all__ = [ "ShowCollection", + "ShowOfType", "ShowTagged", ] diff --git a/src/braindrop/app/messages/main.py b/src/braindrop/app/messages/main.py index 3e4fa64..d053a94 100644 --- a/src/braindrop/app/messages/main.py +++ b/src/braindrop/app/messages/main.py @@ -10,7 +10,7 @@ ############################################################################## # Local imports. -from ...raindrop import Collection, Tag +from ...raindrop import Collection, RaindropType, Tag ############################################################################## @@ -22,6 +22,15 @@ class ShowCollection(Message): """The collection to show.""" +############################################################################## +@dataclass +class ShowOfType(Message): + """A message that requests that Raindrops of a particular type are shown.""" + + raindrop_type: RaindropType + """The raindrop type to filter on.""" + + ############################################################################## @dataclass class ShowTagged(Message): diff --git a/src/braindrop/app/screens/main.py b/src/braindrop/app/screens/main.py index 0dc61fb..bdbdf7c 100644 --- a/src/braindrop/app/screens/main.py +++ b/src/braindrop/app/screens/main.py @@ -64,7 +64,7 @@ token_file, update_configuration, ) -from ..messages import ShowCollection, ShowTagged +from ..messages import ShowCollection, ShowOfType, ShowTagged from ..providers import CollectionCommands, CommandsProvider, MainCommands, TagCommands from ..widgets import Navigation, RaindropDetails, RaindropsView from .confirm import Confirm @@ -350,6 +350,15 @@ def action_search_tags_command(self) -> None: severity="information", ) + @on(ShowOfType) + def command_show_of_type(self, command: ShowOfType) -> None: + """handle the command that requests we show Raindrops of a given type. + + Args: + command: The command. + """ + self.active_collection = self.active_collection.of_type(command.raindrop_type) + @on(ShowTagged) def command_show_tagged(self, command: ShowTagged) -> None: """Handle the command that requests we show Raindrops with a given tag. diff --git a/src/braindrop/app/widgets/navigation.py b/src/braindrop/app/widgets/navigation.py index 87c36a8..8c2c4d7 100644 --- a/src/braindrop/app/widgets/navigation.py +++ b/src/braindrop/app/widgets/navigation.py @@ -21,12 +21,14 @@ from textual.widgets import OptionList from textual.widgets.option_list import Option +from braindrop.raindrop.raindrop import RaindropType + ############################################################################## # Local imports. from ...raindrop import API, Collection, SpecialCollection, Tag from ..commands import ShowAll, ShowUnsorted, ShowUntagged -from ..data import LocalData, Raindrops, TagCount -from ..messages import ShowCollection, ShowTagged +from ..data import LocalData, Raindrops, TagCount, TypeCount +from ..messages import ShowCollection, ShowOfType, ShowTagged from .extended_option_list import OptionListEx @@ -98,6 +100,41 @@ def prompt(self) -> RenderableType: return prompt +############################################################################## +class TypeView(Option): + """Option for showing a raindrop type.""" + + def __init__(self, raindrop_type: TypeCount) -> None: + """Initialise the object. + + Args: + raindrop_type: The type to show. + """ + self._type = raindrop_type + """The type being viewed.""" + super().__init__(self.prompt, id=f"_type_{self._type.type}") + + @property + def prompt(self) -> RenderableType: + """The prompt for the type. + + Returns: + A renderable that is the prompt. + """ + prompt = Table.grid(expand=True) + prompt.add_column(ratio=1) + prompt.add_column(justify="right") + prompt.add_row( + str(self._type.type.capitalize()), f"[dim i]{self._type.count}[/]" + ) + return prompt + + @property + def type(self) -> RaindropType: + """The raindrop type.""" + return self._type.type + + ############################################################################## class TagView(Option): """Option for showing a tag.""" @@ -329,6 +366,18 @@ def _by_count(tags: list[TagCount]) -> list[TagCount]: """ return sorted(tags, key=TagCount.the_count(), reverse=True) + def _show_types_for(self, collection: Raindrops) -> None: + """Show types relating to a given collection. + + Args: + collection: The collection to show the types for. + """ + with self.preserved_highlight: + if self.data is not None and (types := collection.types): + self.add_option(Title(f"Types ({len(types)})")) + for raindrop_type in sorted(types): + self.add_option(TypeView(raindrop_type)) + def _show_tags_for(self, collection: Raindrops) -> None: """Show tags relating a given collection. @@ -336,7 +385,6 @@ def _show_tags_for(self, collection: Raindrops) -> None: collection: The collection to show the tags for. """ with self.preserved_highlight: - self._main_navigation() if self.data is not None and (tags := collection.tags): self.add_option(Title(f"Tags ({len(tags)})")) for tag in (self._by_count if self.tags_by_count else self._by_name)( @@ -351,7 +399,10 @@ def watch_data(self) -> None: def watch_active_collection(self) -> None: """React to the currently-active collection being changed.""" - self._show_tags_for(self.active_collection) + with self.preserved_highlight: + self._main_navigation() + self._show_types_for(self.active_collection) + self._show_tags_for(self.active_collection) self._refresh_lines() # https://github.com/Textualize/textual/issues/5431 def watch_tags_by_count(self) -> None: @@ -370,6 +421,8 @@ def _collection_selected(self, message: OptionList.OptionSelected) -> None: self.post_message(ShowCollection(message.option.collection)) elif isinstance(message.option, TagView): self.post_message(ShowTagged(message.option.tag)) + elif isinstance(message.option, TypeView): + self.post_message(ShowOfType(message.option.type)) ### navigation.py ends here