Skip to content

Commit 37adbaa

Browse files
committed
Search feature: merged feature/word-search
1 parent f997e81 commit 37adbaa

11 files changed

Lines changed: 295 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ But with a sleek and contemporary appearance that's sure to captivate you!
1515
- Lets you open images
1616
- Text justification
1717
- Dark & light colorscheme
18+
- Regex search
1819

1920
## Requirements
2021

@@ -77,8 +78,7 @@ MaxTextWidth = 80
7778
TextJustification = justify
7879

7980
# currently using pretty=yes is slow
80-
# and taking huge amount of memory (more than GUI reader)
81-
# but it can render table pretty well
81+
# and taking huge amount of memory
8282
Pretty = no
8383

8484
PageScrollDuration = 0.2
@@ -103,21 +103,46 @@ Home = home,g
103103
End = end,G
104104
OpenToc = tab
105105
OpenMetadata = M
106-
OpenHelp = question_mark
106+
OpenHelp = f1
107+
SearchForward = slash
108+
SearchBackward = question_mark
109+
NextMatch = n
110+
PreviousMatch = N
111+
Confirm = enter
107112
CloseOrQuit = q,escape
108113
Screenshot = f12
109114
```
110115

111-
## Current Limitations
116+
## Known Limitations
112117

113-
Compared to [epy](https://github.com/wustho/epy), currently `baca` has some missing features.
114-
But these are planned to be implemented to `baca` in the near future:
118+
- When searching for specific phrases in `baca`,
119+
keep in mind that it may not be able to find them if they span across two lines,
120+
much like in the search behavior of editor vi(m).
115121

116-
- [ ] **TODO** Search feature
117-
- [ ] **TODO** Bookmarks
118-
- [ ] **TODO** FictionBook support
119-
- [ ] **TODO** URL reading support
120-
- [ ] **TODO** Transparent background
122+
For example, `baca` won't be able to find the phrase `"for it"` because it is split into two lines
123+
in this example.
124+
125+
```
126+
...
127+
she had forgotten the little golden key, and when she went back to the table for
128+
it, she found she could not possibly reach it: she could see it quite plainly
129+
...
130+
```
131+
132+
133+
Additionally, `baca` may struggle to locate certain phrases due to adjustments made for text justification.
134+
See the example above, `"see_it"` may become `"see__it"` due to adjusted spacing between words.
135+
In this case, it may be more effective to use a regex search for `"see +it"` or simply search for the word `"see"` alone.
136+
137+
Overall, `baca`'s search feature is most effective for locating individual words
138+
rather than phrases that may be split across multiple lines or impacted by text justification.
139+
140+
- Compared to [epy](https://github.com/wustho/epy), currently `baca` has some missing features.
141+
But these are planned to be implemented to `baca` in the near future:
142+
143+
- [ ] **TODO** Bookmarks
144+
- [ ] **TODO** FictionBook support
145+
- [ ] **TODO** URL reading support
121146

122147
## Credits
123148

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "baca"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
description = "TUI Ebook Reader"
55
authors = ["Benawi Adha <benawiadha@gmail.com>"]
66
license = "GPL-3.0"

‎src/baca/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
__appname__ = "baca"
2-
__version__ = "0.1.7"
2+
__version__ = "0.1.8"
33
__license__ = "GPL-3.0"
44
__author__ = "Benawi Adha"
55
__email__ = "benawiadha@gmail.com"

‎src/baca/app.py‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import dataclasses
12
import subprocess
2-
from dataclasses import asdict
33
from datetime import datetime
44
from pathlib import Path
55
from typing import Type
@@ -11,12 +11,12 @@
1111
from textual.widgets import LoadingIndicator
1212

1313
from .components.contents import Content
14-
from .components.events import DoneLoading, FollowThis, OpenThisImage, Screenshot
15-
from .components.windows import Alert, DictDisplay, ToC
14+
from .components.events import DoneLoading, FollowThis, OpenThisImage, Screenshot, SearchSubmitted
15+
from .components.windows import Alert, DictDisplay, SearchInputPrompt, ToC
1616
from .config import load_config
1717
from .ebooks import Ebook
1818
from .exceptions import ImageViewerDoesNotExist
19-
from .models import KeyMap, ReadingHistory
19+
from .models import Coordinate, KeyMap, ReadingHistory, SearchMode
2020
from .utils.app_resources import get_resource_file
2121
from .utils.keys_parser import dispatch_key
2222

@@ -33,6 +33,7 @@ def __init__(self, ebook_path: Path, ebook_class: Type[Ebook]):
3333
# TODO: make reactive and display percentage
3434
# as alternative for scrollbar
3535
self.reading_progress = 0.0
36+
self.search_mode = None
3637

3738
def on_load(self, _: events.Load) -> None:
3839
assert self._loop is not None
@@ -63,8 +64,8 @@ async def on_done_loading(self, event: DoneLoading) -> None:
6364
def init_render() -> None:
6465
# restore reading progress
6566
# make sure to call this after refresh so the screen.max_scroll_y != 0
66-
historic_y = self.ebook_state.reading_progress * self.screen.max_scroll_y
67-
self.screen.scroll_to(None, historic_y, speed=0, animate=False) # type: ignore
67+
self.reading_progress = self.ebook_state.reading_progress * self.screen.max_scroll_y
68+
self.screen.scroll_to(None, self.reading_progress, duration=0, animate=False) # type: ignore
6869

6970
self.get_widget_by_id("startup-loader", LoadingIndicator).remove()
7071

@@ -103,7 +104,7 @@ async def on_key(self, event: events.Key) -> None:
103104
keymaps = self.config.keymaps
104105
await dispatch_key(
105106
[
106-
KeyMap(keymaps.close, self.action_quit),
107+
KeyMap(keymaps.close, self.action_cancel_search_or_quit),
107108
KeyMap(keymaps.scroll_down, self.screen.action_scroll_down),
108109
KeyMap(keymaps.scroll_up, self.screen.action_scroll_up),
109110
# KeyMap(keymaps.page_up, self.screen.action_page_up),
@@ -117,8 +118,12 @@ async def on_key(self, event: events.Key) -> None:
117118
KeyMap(keymaps.open_help, self.action_open_help),
118119
KeyMap(keymaps.toggle_dark, self.action_toggle_dark),
119120
KeyMap(keymaps.screenshot, lambda: self.post_message(Screenshot())),
120-
# TODO: search feature
121-
# KeyMap(["D"], lambda: self.log("baca--->>>", self.content._segments[3]._render_cache.lines[1].text)),
121+
KeyMap(keymaps.search_forward, lambda: self.action_input_search(forward=True)),
122+
KeyMap(keymaps.search_backward, lambda: self.action_input_search(forward=False)),
123+
KeyMap(keymaps.next_match, self.action_search_next),
124+
KeyMap(keymaps.prev_match, self.action_search_prev),
125+
KeyMap(keymaps.confirm, self.action_stop_search),
126+
# KeyMap(["D"], lambda: self.log()),
122127
],
123128
event,
124129
)
@@ -133,7 +138,7 @@ async def alert(self, message: str) -> None:
133138
async def action_open_metadata(self) -> None:
134139
if self.metadata_window is None:
135140
metadata_window = DictDisplay(
136-
config=self.config, id="metadata", title="Metadata", data=asdict(self.ebook.get_meta())
141+
config=self.config, id="metadata", title="Metadata", data=dataclasses.asdict(self.ebook.get_meta())
137142
)
138143
await self.mount(metadata_window)
139144

@@ -147,9 +152,45 @@ def action_page_up(self) -> None:
147152
raise SkipAction()
148153
self.screen.scroll_page_up(duration=self.config.page_scroll_duration)
149154

155+
async def action_input_search(self, forward: bool) -> None:
156+
await self.mount(SearchInputPrompt(forward=forward))
157+
158+
async def action_search_next(self) -> bool:
159+
if self.search_mode is not None:
160+
new_coord = await self.content.search_next(
161+
self.search_mode.pattern_str,
162+
self.search_mode.current_coord,
163+
self.search_mode.forward,
164+
)
165+
if new_coord is not None:
166+
self.search_mode = dataclasses.replace(self.search_mode, current_coord=new_coord)
167+
return True
168+
else:
169+
# TODO: inconsistent alert window size on initial search
170+
await self.alert(f"Found no match: '{self.search_mode.pattern_str}'")
171+
172+
return False
173+
174+
async def action_search_prev(self) -> None:
175+
if self.search_mode is not None:
176+
new_coord = await self.content.search_next(
177+
self.search_mode.pattern_str,
178+
self.search_mode.current_coord,
179+
not self.search_mode.forward,
180+
)
181+
if new_coord is not None:
182+
self.search_mode = dataclasses.replace(self.search_mode, current_coord=new_coord)
183+
184+
async def action_stop_search(self) -> None:
185+
if self.search_mode is not None:
186+
self.search_mode = None
187+
await self.content.clear_search()
188+
150189
async def action_open_help(self) -> None:
151190
if self.help_window is None:
152-
keymap_data = {k.replace("_", " ").title(): ",".join(v) for k, v in asdict(self.config.keymaps).items()}
191+
keymap_data = {
192+
k.replace("_", " ").title(): ",".join(v) for k, v in dataclasses.asdict(self.config.keymaps).items()
193+
}
153194
help_window = DictDisplay(config=self.config, id="help", title="Keymaps", data=keymap_data)
154195
await self.mount(help_window)
155196

@@ -173,6 +214,26 @@ async def action_open_toc(self) -> None:
173214
# NOTE: await to prevent broken layout
174215
await self.mount(toc)
175216

217+
async def action_cancel_search_or_quit(self) -> None:
218+
if self.search_mode is not None:
219+
self.screen.scroll_to(
220+
0, self.search_mode.saved_position * self.screen.max_scroll_y, duration=self.config.page_scroll_duration
221+
)
222+
await self.action_stop_search()
223+
else:
224+
await self.action_quit()
225+
226+
async def on_search_submitted(self, message: SearchSubmitted) -> None:
227+
self.search_mode = SearchMode(
228+
pattern_str=message.value,
229+
current_coord=Coordinate(-1 if message.forward else self.content.size.width, self.screen.scroll_offset.y),
230+
forward=message.forward,
231+
saved_position=self.reading_progress,
232+
)
233+
is_found = await self.action_search_next()
234+
if not is_found:
235+
self.search_mode = None
236+
176237
async def on_follow_this(self, message: FollowThis) -> None:
177238
self.content.scroll_to_section(message.nav_point)
178239
# NOTE: remove after refresh so the event get handled

‎src/baca/components/contents.py‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import re
2+
13
from rich.markdown import Markdown
24
from rich.text import Text
35
from textual import events
46
from textual.app import ComposeResult
7+
from textual.geometry import Region
8+
from textual.strip import Strip
59
from textual.widget import Widget
610
from textual.widgets import DataTable
711
from textual.widgets.markdown import Markdown as PrettyMarkdown
812

913
from ..ebooks import Ebook
10-
from ..models import Config, SegmentType
14+
from ..models import Config, Coordinate, SegmentType
1115
from .events import OpenThisImage
1216

1317

@@ -69,6 +73,34 @@ def __init__(self, config: Config, value: str, nav_point: str | None = None):
6973
self.nav_point = nav_point
7074

7175

76+
class SearchMatch(Widget):
77+
can_focus = False
78+
79+
def __init__(self, match_str: str, coordinate: Coordinate):
80+
super().__init__()
81+
self.match_str = match_str
82+
self.coordinate = coordinate
83+
84+
def on_mount(self):
85+
self.styles.offset = (self.coordinate.x, self.coordinate.y)
86+
87+
def render(self):
88+
return self.match_str
89+
90+
def scroll_visible(self):
91+
# NOTE: need to override default .scroll_visible().
92+
# Somehow this widget.virtual_region_with_margin
93+
# will cause the screen to scroll to 0.
94+
self.screen.scroll_to_region(
95+
Region(
96+
x=self.coordinate.x,
97+
y=self.coordinate.y,
98+
width=self.virtual_size.width,
99+
height=self.virtual_size.height,
100+
)
101+
)
102+
103+
72104
class Content(Widget):
73105
can_focus = False
74106

@@ -107,6 +139,41 @@ def render(self):
107139
def compose(self) -> ComposeResult:
108140
yield from iter(self._segments)
109141

142+
def get_text_at(self, y: int) -> str | None:
143+
accumulated_height = 0
144+
for segment in self._segments:
145+
if accumulated_height + segment.virtual_size.height > y:
146+
return segment.render_lines(Region(0, y - accumulated_height, self.virtual_size.width, 1))[0].text
147+
accumulated_height += segment.virtual_size.height
148+
149+
# TODO: annotate
150+
async def search_next(
151+
self, pattern_str: str, current_coord: Coordinate = Coordinate(-1, 0), forward: bool = True
152+
) -> Coordinate | None:
153+
pattern = re.compile(pattern_str, re.IGNORECASE)
154+
current_x = current_coord.x
155+
line_range = (
156+
range(current_coord.y, self.virtual_size.height) if forward else reversed(range(0, current_coord.y + 1))
157+
)
158+
for linenr in line_range:
159+
line_text = self.get_text_at(linenr)
160+
if line_text is not None:
161+
for match in pattern.finditer(line_text):
162+
is_next_match = (match.start() > current_x) if forward else (match.start() < current_x)
163+
if is_next_match:
164+
await self.clear_search()
165+
166+
match_str = match.group()
167+
match_coord = Coordinate(match.start(), linenr)
168+
match_widget = SearchMatch(match_str, match_coord)
169+
await self.mount(match_widget)
170+
match_widget.scroll_visible()
171+
return match_coord
172+
current_x = -1 if forward else self.size.width # maybe virtual_size?
173+
174+
async def clear_search(self) -> None:
175+
await self.query(SearchMatch.__name__).remove()
176+
110177
def scroll_to_widget(self, *args, **kwargs) -> bool:
111178
return self.screen.scroll_to_widget(*args, **kwargs)
112179

‎src/baca/components/events.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,12 @@ def __init__(self, value: str):
1919
self.value = value
2020

2121

22+
class SearchSubmitted(Message):
23+
def __init__(self, value: str, forward: bool):
24+
super().__init__()
25+
self.value = value
26+
self.forward = forward
27+
28+
2229
class Screenshot(Message):
2330
pass

0 commit comments

Comments
 (0)