From efa16d410b764d5401a4743735409c1479d68c45 Mon Sep 17 00:00:00 2001
From: Ken Perry <sirken@users.noreply.github.com>
Date: Sun, 26 May 2024 22:55:08 -0600
Subject: [PATCH 1/2] Find in lyrics percent statusbar (#1)

Add lyrics search functionality and a track percentage status bar. Enables scrolling 100% to the bottom of the track so that search and manual scroll are compatible
---
 lyrics/lyrics.cfg |   4 ++
 lyrics/window.py  | 154 ++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 154 insertions(+), 4 deletions(-)

diff --git a/lyrics/lyrics.cfg b/lyrics/lyrics.cfg
index 21ec904..19014a3 100644
--- a/lyrics/lyrics.cfg
+++ b/lyrics/lyrics.cfg
@@ -11,6 +11,7 @@ mpd_port=6600
 mpd_pass=
 #colors
 #offset=1
+statusbar=on
 
 [BINDINGS]
 up=arrow_up
@@ -31,6 +32,9 @@ autoswitchtoggle=a
 
 delete=d
 edit=e
+find=/
+find-next=n
+find-prev=p
 
 help=h
 
diff --git a/lyrics/window.py b/lyrics/window.py
index c448c88..8b0126c 100644
--- a/lyrics/window.py
+++ b/lyrics/window.py
@@ -51,7 +51,7 @@ def input(self, window, key):
 
 		elif key == self.binds['delete']:
 			if window.player.track.delete_lyrics():
-				window.stdscr.addstr(window.height - 1, window.width - 10,
+				window.stdscr.addstr(window.height - 1, 1,
 							' Deleted ', curses.A_REVERSE)
 		elif key == self.binds['help']:
 			window.stdscr.erase()
@@ -64,11 +64,13 @@ def input(self, window, key):
 			window.current_pos = 0
 			window.player.refresh(cache=True)
 			window.update_track()
+		elif key == self.binds['find']:
+			window.find()
 
 		# autoswitch toggle
 		elif key == self.binds['autoswitchtoggle']:
 			window.player.autoswitch = not window.player.autoswitch
-			window.stdscr.addstr(window.height - 1, window.width - 18,
+			window.stdscr.addstr(window.height - 1, 1,
 			                     f" Autoswitch: {'on' if window.player.autoswitch else 'off'} ", curses.A_REVERSE)
 
 class HelpPage:
@@ -134,6 +136,7 @@ def main(self):
 
 class Window:
 	def __init__(self, stdscr, player, timeout):
+		self.options = Config('OPTIONS')
 		self.stdscr = stdscr
 		self.height, self.width = stdscr.getmaxyx()
 		self.player = player
@@ -143,6 +146,7 @@ def __init__(self, stdscr, player, timeout):
 		self.pad_offset = 1
 		self.text_padding = 5
 		self.keys = Key()
+		self.find_position = 0
 
 		curses.use_default_colors()
 		self.stdscr.timeout(timeout)
@@ -170,7 +174,16 @@ def set_titlebar(self):
 		self.stdscr.addstr(1, 1, track_info[1], 
 					curses.A_REVERSE | curses.A_BOLD | curses.A_DIM)
 		self.stdscr.addstr(2, 1, track_info[2], curses.A_REVERSE)
-		
+
+	def set_statusbar(self):
+		if self.options['statusbar'] == 'on':
+			text = self.player.track.get_text(wrap=True, width=self.width - self.text_padding)
+			lines = text.split('\n')
+			if self.current_pos < 0:
+				self.current_pos = 0
+			pct_progress = f' {int(self.current_pos * 100 / len(lines)) + 1}% '
+			self.stdscr.insstr(self.height - 1, self.width - len(pct_progress), pct_progress, curses.A_DIM)
+
 	def set_offset(self):
 		if self.player.track.alignment == 0:
 				# center align
@@ -181,9 +194,10 @@ def set_offset(self):
 			self.pad_offset = (self.width - self.player.track.width)
 	
 	def scroll_down(self, step=1):
-		if self.current_pos < self.player.track.length - (self.height * 0.5):
+		if self.current_pos < self.player.track.length - step:
 			self.current_pos += step
 		else:
+			self.current_pos = self.player.track.length - 1
 			self.stdscr.addstr(self.height - 1, 1, 'END', curses.A_REVERSE)
 
 	def scroll_up(self, step=1):
@@ -194,6 +208,137 @@ def scroll_up(self, step=1):
 				self.stdscr.clrtoeol()
 			self.current_pos -= step
 
+	def find_check_keys(self, key=None, lines_map=[]):
+		if key == self.keys.binds['find-next']:
+			self.stdscr.addstr(self.height - 1, self.width - 3, 'n ')
+			self.stdscr.clrtoeol()
+			# reached end of matches, loop back to start
+			if self.find_position + 1 >= len(lines_map):
+				self.find_position = 0
+			else:
+				self.find_position += 1
+			return True
+		elif key == self.keys.binds['find-prev']:
+			self.stdscr.addstr(self.height - 1, self.width - 3, 'p ')
+			self.stdscr.clrtoeol()
+			if self.find_position - 1 < 0:
+				self.find_position = len(lines_map) - 1
+			else:
+				self.find_position -= 1
+			return True
+		# other keys for more accessibility
+		elif key == self.keys.binds['down']:
+			self.scroll_down()
+		elif key == self.keys.binds['up']:
+			self.scroll_up()
+		elif key == self.keys.binds['step-down']:
+			self.scroll_down(self.keys.binds['step-size'])
+		elif key == self.keys.binds['step-up']:
+			self.scroll_up(self.keys.binds['step-size'])
+		elif key == self.keys.binds['find']:
+			self.find()
+		return False
+
+	def find(self):
+		# wait for input
+		self.stdscr.timeout(-1)
+		prompt = ':'
+		self.stdscr.move(self.height - 1, 0)
+		self.stdscr.clrtoeol()
+		self.stdscr.addstr(self.height - 1, self.pad_offset, prompt)
+		self.set_statusbar()
+		# show cursor and key presses during find
+		curses.echo()
+		curses.curs_set(1)
+
+		# (y, x, input max length), case-insensitive
+		find_string = self.stdscr.getstr(self.height - 1, len(prompt)+self.pad_offset, 100).decode(encoding="utf-8").strip()
+
+		# hide cursor and key presses
+		curses.curs_set(0)
+		curses.noecho()
+
+		if find_string:
+			# use word wrap which covers both wrap/nowrap and ensures line count is accurate
+			text = self.player.track.get_text(wrap=True, width=self.width - self.text_padding)
+			lines = text.split('\n')
+
+			# [0,9,10,14] list of lines that contain a match
+			lines_map = []
+			for line_num, line in enumerate(lines):
+				# case-insensitive match
+				if find_string.lower() in line.lower():
+					lines_map.append(line_num)
+
+			if len(lines_map) > 0:
+
+				# continue search from current position
+				for line in lines_map:
+					# >= causes us to stay on the current line for a new search
+					if line >= self.current_pos:
+						self.find_position = lines_map.index(line)
+						break
+				# otherwise loop back to the start
+				else:
+					self.find_position = 0
+
+				while True:
+					# update current position based on where we are at in the find
+					self.current_pos = lines_map[self.find_position]
+
+					# duplicated from main() to manually refresh on find
+					self.stdscr.clear()
+					self.set_titlebar()
+					self.stdscr.refresh()
+					self.scroll_pad.refresh(self.current_pos, 0, 4, self.pad_offset, self.height - 2, self.width - 1)
+
+					# find & status bar output
+					find_string_output = f' {find_string} '
+					find_count_output = f" {self.find_position + 1}/{len(lines_map)} "
+					self.stdscr.addstr(self.height - 1, self.pad_offset, find_string_output, curses.A_REVERSE)
+					self.stdscr.insstr(self.height - 1, self.pad_offset + len(find_string_output) + 1, find_count_output)
+					# multiple matches, show next/prev
+					if len(lines_map) > 1:
+						help_output = f"[{chr(self.keys.binds['find-next'])}]=next, [{chr(self.keys.binds['find-prev'])}]=prev"
+						self.stdscr.addstr(self.height - 1, self.pad_offset + len(find_string_output) + len(find_count_output) + 2, help_output)
+					self.set_statusbar()
+
+					# highlight found text
+					line_text = lines[self.current_pos]
+					# case-insensitive, [4, 5, 21]
+					found_index_list = [i for i in range(len(line_text)) if line_text.lower().startswith(find_string.lower(), i)]
+					# loop over each character in the line, highlight found strings
+					highlight_end = -1
+					for cpos, char in enumerate(line_text):
+						attr = curses.A_NORMAL
+						# start of found string
+						if cpos in found_index_list:
+							highlight_end = cpos + len(find_string)
+							attr = curses.A_REVERSE
+						# inside string
+						elif highlight_end > cpos:
+							attr = curses.A_REVERSE
+						self.stdscr.addch(4, self.pad_offset + cpos, char, attr)
+
+					# after finding a match in a line, stop, wait for input
+					self.stdscr.timeout(10000)
+					key = self.stdscr.getch()
+					result = self.find_check_keys(key, lines_map)
+					if not result:
+						break
+
+			else:
+				output = ' not found '
+				self.stdscr.insstr(self.height - 1, self.pad_offset + len(prompt) + len(find_string) + 2, output, curses.A_REVERSE)
+				self.set_statusbar()
+				# timeout or key press
+				self.stdscr.timeout(5000)
+				key = self.stdscr.getch()
+				self.find_check_keys(key, lines_map)
+
+		# clear search line
+		self.stdscr.clear()
+
 	def update_track(self):
 		self.stdscr.clear()
 		self.scroll_pad.clear()
@@ -228,6 +373,7 @@ def main(self):
 				self.keys.input(self, key)
 
 				self.set_titlebar()
+				self.set_statusbar()
 				self.stdscr.refresh()
 				self.scroll_pad.refresh(self.current_pos, 0, 4, 
 							self.pad_offset, self.height - 2, self.width - 1)

From 80536d213e03b70082c12a065351609540c0b35d Mon Sep 17 00:00:00 2001
From: Samarth Jugran <jugransamarth@gmail.com>
Date: Tue, 20 Aug 2024 16:41:26 +0530
Subject: [PATCH 2/2] reset timeout after using search

---
 lyrics/__init__.py |  2 +-
 lyrics/window.py   | 12 +++++++-----
 setup.py           |  3 ++-
 3 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/lyrics/__init__.py b/lyrics/__init__.py
index b5e2b64..07c2e44 100644
--- a/lyrics/__init__.py
+++ b/lyrics/__init__.py
@@ -4,7 +4,7 @@
 
 CONFIG_PATH = Path.home().joinpath('.config', 'lyrics-in-terminal','lyrics.cfg')
 
-__version__ = '1.5.1-dev'
+__version__ = '1.6.0-dev'
 
 if not CONFIG_PATH.exists():
     from shutil import copy
diff --git a/lyrics/window.py b/lyrics/window.py
index 8b0126c..8d26e0d 100644
--- a/lyrics/window.py
+++ b/lyrics/window.py
@@ -147,9 +147,10 @@ def __init__(self, stdscr, player, timeout):
 		self.text_padding = 5
 		self.keys = Key()
 		self.find_position = 0
+		self.timeout = timeout
 
 		curses.use_default_colors()
-		self.stdscr.timeout(timeout)
+		self.stdscr.timeout(self.timeout)
 		self.set_up()
 
 	def set_up(self):
@@ -177,11 +178,10 @@ def set_titlebar(self):
 
 	def set_statusbar(self):
 		if self.options['statusbar'] == 'on':
-			text = self.player.track.get_text(wrap=True, width=self.width - self.text_padding)
-			lines = text.split('\n')
+			lines = self.player.track.length
 			if self.current_pos < 0:
 				self.current_pos = 0
-			pct_progress = f' {int(self.current_pos * 100 / len(lines)) + 1}% '
+			pct_progress = f' {round(self.current_pos * 100 / lines) + 1}% '
 			self.stdscr.insstr(self.height - 1, self.width - len(pct_progress), pct_progress, curses.A_DIM)
 
 	def set_offset(self):
@@ -252,7 +252,8 @@ def find(self):
 		curses.curs_set(1)
 
 		# (y, x, input max length), case-insensitive
-		find_string = self.stdscr.getstr(self.height - 1, len(prompt)+self.pad_offset, 100).decode(encoding="utf-8").strip()
+		find_string = self.stdscr.getstr(self.height - 1, len(prompt) + self.pad_offset, 100)
+		find_string = find_string.decode(encoding="utf-8").strip()
 
 		# hide cursor and key presses
 		curses.curs_set(0)
@@ -338,6 +339,7 @@ def find(self):
 
 		# clear search line
 		self.stdscr.clear()
+		self.stdscr.timeout(self.timeout)
 
 	def update_track(self):
 		self.stdscr.clear()
diff --git a/setup.py b/setup.py
index 10743d4..fce2c75 100644
--- a/setup.py
+++ b/setup.py
@@ -61,7 +61,8 @@ def updateConfigFile(self):
     packages=find_packages(),
     entry_points={
         'console_scripts': [
-            'lyrics = lyrics.lyrics_in_terminal:main'
+            'lyrics = lyrics.lyrics_in_terminal:main',
+            'lyt = lyrics.lyrics_in_terminal:main'
         ]
     },
     classifiers=[