blob: 9679a7fcac0ffa97c84051f40e190809e45b0cab [file]
# SPDX-License-Identifier: GPL-2.0
# text viewer
import os
import subprocess
import _hkml_cli
import _hkml_subproc
import hkml_list
import hkml_mail_note
import hkml_open
import hkml_view
import hkml_view_mails
import hkml_write
class TextViewData:
mail = None
mails_slist = None # ScrollableList of parent mails list
unfolded_lines = None
mail_item = None
def __init__(self, mail, mails_slist=None, mail_item=None):
self.mail = mail
self.mails_slist = mails_slist
self.mail_item = mail_item
def menu_exec_git(slist, answer, selection):
hkml_view.shell_mode_end(slist)
git_cmd = selection.data
words = git_cmd.split()
try:
output = subprocess.check_output(
words, stderr=subprocess.DEVNULL).decode().split('\n')
except Exception as e:
output = ['failed: %s' % e, '',
'wrong commit id, or you are not on the git repo?']
show_text_viewer(slist.screen, args=ShowTextViewerArgs(text_lines=output))
hkml_view.shell_mode_start(slist)
def menu_hkml_thread(slist, answer, selection):
hkml_view.shell_mode_end(slist)
msgid = selection.data
args = hkml_view_mails.hkml_list_args_for_msgid(msgid)
hkml_view_mails.gen_show_mails_list(slist.screen, args)
hkml_view.shell_mode_start(slist)
def menu_hkml_open(slist, answer, selection):
hkml_view.shell_mode_end(slist)
msgid = selection.data
args = hkml_view_mails.hkml_list_args_for_msgid(msgid)
args.stdout = True # to bypass dim_old suggestion
# do not suggest manifest update, since this is for msgid.
list_data, err = hkml_list.args_to_mails_list_data(
args, suggest_manifest_update=False)
for line_nr, mail_idx in list_data.line_nr_mail_idx_map.items():
mail = list_data.mail_items[mail_idx].mail
if mail.get_msgid() == msgid:
_, cols = slist.screen.getmaxyx()
lines = hkml_open.mail_display_str(mail, cols).split('\n')
show_text_viewer(slist.screen,
args=ShowTextViewerArgs(text_lines=lines))
break
hkml_view.shell_mode_start(slist)
def menu_open_file(slist, answer, selection):
hkml_view.shell_mode_end(slist)
file_path = selection.data
with open(file_path, 'r') as f:
lines = f.read().split('\n')
show_text_viewer(slist.screen, args=ShowTextViewerArgs(text_lines=lines))
hkml_view.shell_mode_start(slist)
def menu_open_file_editor(slist, answer, selection):
file_path = selection.data
err = hkml_write.open_editor(file_path, 'file')
if err is not None:
print(err)
exit(1)
def is_git_hash(word):
if len(word) < 10:
return False
for c in word:
if c not in '0123456789abcdef':
return False
return True
def menu_selections_for_commit(line):
for separator in [',', '(', ')', '/', '[', ']', '"']:
line = line.replace(separator, ' ')
selections = []
for word in line.split():
if not is_git_hash(word):
continue
show_cmd = 'git show %s' % word
log_five_cmd = 'git log -n 5 %s' % word
log_oneline_cmd = 'git log --oneline -n 64 %s' % word
selections.append(
_hkml_cli.Selection(
show_cmd, handle_fn=menu_exec_git, data=show_cmd))
selections.append(
_hkml_cli.Selection(
log_five_cmd, handle_fn=menu_exec_git, data=log_five_cmd))
selections.append(
_hkml_cli.Selection(
log_oneline_cmd, handle_fn=menu_exec_git,
data=log_oneline_cmd))
return selections
def get_msgid_from_public_inbox_link(word):
'''
If it is http url and has @ in a field, assume it is msgid link
'''
if not word.startswith('http'):
return None
tokens = word.split('/')
if len(tokens) < 4:
return None
for token in tokens[3:]:
if '@' in token:
return token
return None
def menu_selections_for_msgid(line):
for separator in [',', '(', ')', '[', ']', '"']:
line = line.replace(separator, ' ')
selections = []
for word in line.split():
msgid = get_msgid_from_public_inbox_link(word)
if msgid is None:
continue
selections.append(_hkml_cli.Selection(
'hkml thread %s' % msgid, handle_fn=menu_hkml_thread, data=msgid))
selections.append(_hkml_cli.Selection(
'hkml open %s' % msgid, handle_fn=menu_hkml_open, data=msgid))
return selections
def menu_exec_web(slist, answer, selection):
cmd = selection.data
subprocess.call(cmd.split())
def menu_selections_for_url(line):
for separator in [',', '(', ')', '[', ']', '"']:
line = line.replace(separator, ' ')
selections = []
for word in line.split():
if not word.startswith('http://') and not word.startswith('https://'):
continue
if _hkml_subproc.cmd_available('lynx'):
cmd = 'lynx %s' % word
selections.append(_hkml_cli.Selection(
cmd, handle_fn=menu_exec_web, data=cmd))
if _hkml_subproc.cmd_available('w3m'):
cmd = 'w3m %s' % word
selections.append(_hkml_cli.Selection(
cmd, handle_fn=menu_exec_web, data=cmd))
return selections
def menu_selections_for_files(line):
for separator in [',', '(', ')', '[', ']', '"', ':']:
line = line.replace(separator, ' ')
found_files = {}
selections = []
for word in line.split():
# file paths on diff starts with a/ and b/, e.g.,
#
# --- a/tools/testing/selftests/damon/damon_nr_regions.py
# +++ b/tools/testing/selftests/damon/damon_nr_regions.py
if word.startswith('a/') or word.startswith('b/'):
word = word[2:]
if not word in found_files and os.path.isfile(word):
found_files[word] = True
selections.append(_hkml_cli.Selection(
text='hkml open file %s' % word, handle_fn=menu_open_file,
data=word))
selections.append(_hkml_cli.Selection(
text='open %s with a text editor' % word,
handle_fn=menu_open_file_editor, data=word))
return selections
def is_showing_mail(slist):
# slist.data should be TextViewData
return slist.data.mail is not None
def get_showing_mail(slist):
# slist.data should be TextViewData
mail = slist.data.mail
if mail is None:
return None, 'not showing mail?'
return mail, None
def get_showing_mail_item(slist):
mail_item = slist.data.mail_item
if mail_item is None:
return None, 'not showing mail item?'
return mail_item, None
def reply_mail(c, slist):
# maybe called from tui/cli menu
if slist.parent_list is not None:
slist = slist.parent_list
mail, err = get_showing_mail(slist)
if err is not None:
slist.toast('parent is not a mail?')
return
nr_header_rows = 0
for line in slist.lines:
nr_header_rows += 1
if line == '':
break
hkml_view_mails.reply_mail(
slist, mail, cursor_row=slist.focus_row - nr_header_rows)
def menu_reply_mail(slist, answer, selection):
mail, err = get_showing_mail(slist)
# todo: handle err is not None case
hkml_view.shell_mode_end(slist)
hkml_view_mails.reply_mail(slist, mail)
hkml_view.shell_mode_start(slist)
def forward_mail(c, slist):
# maybe called from tui/cli menu
if slist.parent_list is not None:
slist = slist.parent_list
mail, err = get_showing_mail(slist)
if err is not None:
slist.toast('parent is not a mail?')
return
hkml_view_mails.forward_mail(slist, mail)
def menu_forward_mail(slist, answer, selection):
mail, err = get_showing_mail(slist)
# todo: handle err is not None case
hkml_view.shell_mode_end(slist)
hkml_view_mails.forward_mail(slist, mail)
hkml_view.shell_mode_start(slist)
def write_draft_mail(c, slist):
# maybe called from tui/cli menu
if slist.parent_list is not None:
slist = slist.parent_list
mail, err = get_showing_mail(slist)
if err is not None:
slist.toast('parent is not a mail?')
return
hkml_view_mails.write_mail_draft(slist, mail)
def menu_write_draft(slist, answer, selection):
mail, err = get_showing_mail(slist)
# todo: handle err is not None case
hkml_view.shell_mode_end(slist)
hkml_view_mails.write_mail_draft(slist, mail)
hkml_view.shell_mode_start(slist)
def menu_manage_tags(slist, answer, selection):
mail, err = get_showing_mail(slist)
# todo: handle err is not None case
hkml_view_mails.manage_tags_of_mail(slist, mail)
def menu_handle_patches(slist, answer, selection):
mail_item, err = get_showing_mail_item(slist)
# todo: handle err is not None case
hkml_view_mails.handle_patches_of_mail_item(mail_item)
def menu_jump(slist, answer, selection):
selections = [
_hkml_cli.Selection('beginning of next different depth'),
_hkml_cli.Selection('end of previous different depth')]
answer, selection, err = _hkml_cli.ask_selection(
desc='Select where to jump.', selections=selections,
default_selection_idx=0)
current_depth = mail_depth(slist.lines[slist.focus_row])
if selection == selections[0]:
for row_idx in range(slist.focus_row + 1, len(slist.lines)):
line = slist.lines[row_idx]
if mail_depth(line) != current_depth:
slist.focus_row = row_idx
return
return
elif selection == selections[1]:
for row_idx in range(slist.focus_row - 1, -1, -1):
line = slist.lines[row_idx]
if mail_depth(line) != current_depth:
slist.focus_row = row_idx
return
return
def menu_add_note(slist, answer, selection):
mail, err = get_showing_mail(slist)
if err is not None:
print(err)
return
desc_lines = ['Add note for', '']
if slist.focus_row > 5:
desc_lines.append('[...]')
desc_lines += slist.lines[slist.focus_row - 5:slist.focus_row + 1]
desc_lines.append('')
note, err = _hkml_cli.ask_input(desc='\n'.join(desc_lines), prompt='Enter')
if err is not None:
print(err)
return
nr_lines_before_body = slist.lines.index('') + 1
note_line_nr = slist.focus_row - nr_lines_before_body
hkml_mail_note.add_note(mail.get_msgid(), note_line_nr, note)
def menu_remove_notes(slist, answer, selection):
mail, err = get_showing_mail(slist)
if err is not None:
print(err)
return
msgid = mail.get_msgid()
notes = hkml_mail_note.get_notes_for(msgid)
if notes is None:
print('No note for the mail')
return
nr_lines_before_body = slist.lines.index('') + 1
note_line_nr = slist.focus_row - nr_lines_before_body
if not note_line_nr in notes.line_notes:
print('No note on the line')
return
desc_lines = ['Remove notes for', '']
if slist.focus_row > 5:
desc_lines.append('[...]')
desc_lines += slist.lines[slist.focus_row - 5:slist.focus_row + 1]
desc_lines.append('')
desc_lines.append('Notes for the line:')
for idx, note in enumerate(notes.line_notes[note_line_nr]):
desc_lines.append('%d: %s' % (idx, note.text))
desc_lines.append('')
desc = '\n'.join(desc_lines)
prompt='Enter indices of the notes separated by comma, or "all" for all'
answer, err = _hkml_cli.ask_input(desc=desc, prompt=prompt)
if err is not None:
print(err)
return
if answer == 'all':
indices = [i for i in range(len(notes.line_notes[note_line_nr]))]
else:
try:
indices = [int(x) for x in answer.split(',')]
except:
print('wrong input')
return
hkml_mail_note.remove_notes(msgid, note_line_nr, indices)
def menu_selections_for_mail():
return [
_hkml_cli.Selection('reply', handle_fn=menu_reply_mail),
_hkml_cli.Selection('forward', handle_fn=menu_forward_mail),
_hkml_cli.Selection(
'continue draft writing', handle_fn=menu_write_draft),
_hkml_cli.Selection('manage tags',
handle_fn=menu_manage_tags),
_hkml_cli.Selection(
'handle as patches', handle_fn=menu_handle_patches),
_hkml_cli.Selection(
'jump cursor to ...', handle_fn=menu_jump),
_hkml_cli.Selection(
'add a note to the line', handle_fn=menu_add_note),
_hkml_cli.Selection(
'remove notes of the line', handle_fn=menu_remove_notes),
]
def menu_wrap_text(slist, answer, selection):
if slist.wrapped_text():
slist.unwrap_text()
else:
slist.wrap_text()
def menu_save_content_as(slist, answer, selection):
hkml_view.save_as('\n'.join(slist.lines))
def menu_open_content_with(slist, answer, selection):
err = hkml_view.open_content_with('\n'.join(slist.lines))
if err is not None:
print(err)
def indent_depth(line):
depth = 0
for c in line:
if c in [' ', '\t']:
depth += 1
else:
return depth
return depth
def menu_fold_lines(slist, answer, selection):
focus_row = slist.focus_row
current_depth = indent_depth(slist.lines[focus_row])
lines_to_fold = 0
for line in slist.lines[focus_row + 1:]:
if indent_depth(line) > current_depth:
lines_to_fold += 1
else:
break
text_view_data = slist.data
if text_view_data.unfolded_lines is None:
text_view_data.unfolded_lines = slist.lines
slist.lines = slist.lines[:focus_row + 1] + \
['[%d lines folded]' % lines_to_fold] + \
slist.lines[focus_row + 1 + lines_to_fold:]
def menu_unfold_all_lines(slist, answer, selection):
text_view_data = slist.data
if text_view_data.unfolded_lines is None:
print('No folded lines')
return
slist.lines = text_view_data.unfolded_lines
def menu_selections(slist):
line = slist.lines[slist.focus_row]
selections = menu_selections_for_commit(line)
selections += menu_selections_for_msgid(line)
selections += menu_selections_for_url(line)
selections += menu_selections_for_files(line)
if is_showing_mail(slist):
selections += menu_selections_for_mail()
if slist.wrapped_text():
menu_text = 'unwrap text'
else:
menu_text = 'wrap text'
selections.append(
_hkml_cli.Selection(menu_text, handle_fn=menu_wrap_text,
data=slist))
selections.append(
_hkml_cli.Selection(
text='save screen content to ...',
handle_fn=menu_save_content_as, data=slist))
selections.append(
_hkml_cli.Selection(
text='open screen content with ...',
handle_fn=menu_open_content_with, data=slist))
selections.append(
_hkml_cli.Selection(
text='fold below indented lines',
handle_fn=menu_fold_lines, data=slist))
selections.append(
_hkml_cli.Selection(
text='unfold all folded lines',
handle_fn=menu_unfold_all_lines, data=slist))
return selections
def show_text_viewer_menu(c, slist):
hkml_view.shell_mode_start(slist)
desc_lines = ['selected line: %s' % slist.lines[slist.focus_row]]
text_view_data = slist.data
if text_view_data.mail is not None:
msgid = text_view_data.mail.get_msgid()[1:-1]
desc_lines.append('msgid: %s' % msgid)
desc = '\n'.join(desc_lines)
_hkml_cli.ask_selection(
desc=desc,
prompt='Enter menu item number',
handler_common_data=slist,
selections=menu_selections(slist))
hkml_view.shell_mode_end(slist)
def get_text_viewer_handlers(data):
if data is not None and data.mail is not None:
handlers = [
hkml_view.InputHandler(['r'], reply_mail, 'reply'),
hkml_view.InputHandler(['f'], forward_mail, 'forward'),
]
else:
handlers = []
return handlers + [
hkml_view.InputHandler(
['m'], show_text_viewer_menu, 'open menu'),
]
def text_color_callback(slist, line_idx):
is_hunk = False
for start, end in slist.hunk_lines:
if start <= line_idx and line_idx < end:
is_hunk = True
break
line = slist.lines[line_idx]
if len(line) == 0:
return hkml_view.normal_color
if is_hunk:
if line[0] == '+':
return hkml_view.add_color
elif line[0] == '-':
return hkml_view.delete_color
elif line.startswith('> > ') or line.startswith('>> '):
return hkml_view.original_color
elif line[0] == '>':
return hkml_view.focus_color
return hkml_view.normal_color
def hunk_length(lines, orig_content, new_content):
length = 0
for line in lines:
if line.startswith('-'):
orig_content -= 1
elif line.startswith('+'):
new_content -= 1
elif line.startswith(' ') or line == '':
orig_content -= 1
new_content -= 1
else:
return length
length += 1
if orig_content < 0 or new_content < 0 or \
(orig_content == new_content == 0):
return length
return length
def hunk_lines(text_lines):
indices = []
idx = 0
while idx < len(text_lines):
line = text_lines[idx]
if not line.startswith('@@'):
idx += 1
continue
fields = line.split()
# format: "@@ -l,s +l,s @@ optional section heading"
if len(fields) < 4:
idx += 1
continue
numbers = fields[1].split(',') + fields[2].split(',')
if len(numbers) != 4:
idx += 1
continue
try:
numbers = [int(x) for x in numbers]
except:
idx += 1
continue
orig_content, new_content = numbers[1], numbers[3]
hunk_len = hunk_length(text_lines[idx + 1:], orig_content, new_content)
indices.append([idx + 1, idx + 1 + hunk_len])
idx += hunk_len + 1
return indices
def mail_depth(line):
depth = 0
for c in line:
if not c in ['>', ' ']:
break
if c == '>':
depth += 1
return depth
def is_context_line(line):
# e.g., "On Fri, 2 May 2025 08:49:49 -0700 SeongJae Park <sj@kernel.org> wrote:"
if line.endswith('wrote:'):
return True
# e.g., "* SeongJae Park <sj@kernel.org> [250509 01:47]:"
# todo: maybe need more test...?
if line.endswith(']:'):
return True
return False
def parse_mail_contexts(text_lines):
'''
Parse which depth origin lines are sent by who, when.
'''
contexts = {} # key: depth (int), value: context line (strting)
for idx, line in enumerate(text_lines):
if not is_context_line(line):
continue
depth = mail_depth(line) + 1
if depth in contexts:
continue
contexts[depth] = line
return contexts
def set_mail_contexts(slist):
contexts = parse_mail_contexts(slist.lines)
mail_item = slist.data.mail_item
mail = mail_item.mail
contexts[0] = '%s, %s' % (
mail.get_from(), mail.get_field('local-date'))
depth = 1
while mail_item.parent_item is not None:
mail_item = mail_item.parent_item
mail = mail_item.mail
contexts[depth] = '%s, %s' % (
mail.get_from(), mail.get_field('local-date'))
depth += 1
slist.data.mail_contexts = contexts
def mail_draw_callback(slist):
focused_line = slist.lines[slist.focus_row]
depth = mail_depth(focused_line)
text_view_data = slist.data
if not depth in text_view_data.mail_contexts:
context = 'unknown'
else:
context = text_view_data.mail_contexts[depth]
slist.bottom_lines = ['# context: %s' % context]
def mail_line_callback(slist, line_idx):
'''
If the previous line has notes, add those at the beginning of the current
line.
'''
line = slist.lines[line_idx]
msgid = slist.data.mail.get_msgid()
notes = hkml_mail_note.get_notes_for(msgid)
if notes is None:
return line
body_start_idx = slist.lines.index('') + 1
prev_body_line_idx = line_idx - 1 - body_start_idx
if prev_body_line_idx in notes.line_notes:
notes_text = ','.join(
[n.text for n in notes.line_notes[prev_body_line_idx]])
return ' /* hkml note: %s */ %s' % (notes_text, line)
# TODO: handle notes on the last line.
return line
class ShowTextViewerArgs:
text_lines = None
text_view_data = None # TextViewData object
cursor_position = None
def __init__(self, text_lines, text_view_data=None, cursor_position=None):
self.text_lines = text_lines
self.text_view_data = text_view_data
self.cursor_position = cursor_position
def show_text_viewer(screen, args):
'''
This function can be passed to view() as draw_fn().
args is a ShowTextViewerArgs object.
'''
text_lines = args.text_lines
text_view_data = args.text_view_data
cursor_position = args.cursor_position
slist = hkml_view.ScrollableList(
screen, text_lines, get_text_viewer_handlers(text_view_data))
if text_view_data is None:
text_view_data = TextViewData(mail=None)
slist.data = text_view_data
slist.hunk_lines = hunk_lines(text_lines)
slist.color_callback = text_color_callback
if cursor_position is not None:
slist.focus_row, slist.focus_col = cursor_position
better_wrap, longest_columns = slist.better_wrap_text()
if better_wrap is True and not slist.wrapped_text():
hkml_view.shell_mode_start(slist)
answer = input('\n'.join([
'There are lines that may better to be wrapped (%d columns).' %
longest_columns,
'May I wrap those?',
'You can [un]wrap later from the menu (\'m\' key).',
'Answer: [y/N] ']))
if answer.lower() == 'y':
slist.wrap_text()
print('Ok, wrapped the lines')
else:
print('Ok, kept those as is')
hkml_view.shell_mode_end(slist)
if is_showing_mail(slist):
set_mail_contexts(slist)
slist.draw_callback = mail_draw_callback
slist.line_callback = mail_line_callback
slist.draw()
return slist